diff --git a/.gitattributes b/.gitattributes index 3cb68a29e..4c0a6c312 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,6 @@ tools/mangoapp/** text=auto eol=lf tools/comet/LICENSE whitespace=-blank-at-eof tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof -# These helpers are packaged verbatim and executed inside the Linux runtime. tools/linuxfs/overlay/** text eol=lf tools/linuxfs/desktop/** text eol=lf tools/mangoapp/mangoapp text eol=lf diff --git a/.github/trusted-test-builds.txt b/.github/trusted-test-builds.txt index 9b09f6132..f91528f39 100644 --- a/.github/trusted-test-builds.txt +++ b/.github/trusted-test-builds.txt @@ -1,4 +1,3 @@ -# GitHub PR openers trusted for automatic release-signed test builds. FX192 K11MCH1 kingokksa diff --git a/.github/workflows/build-fex.yml b/.github/workflows/build-fex.yml index 8ec3369a1..deb099100 100644 --- a/.github/workflows/build-fex.yml +++ b/.github/workflows/build-fex.yml @@ -1,8 +1,5 @@ name: Build FEX (x86-64 Steam client) -# Checks tools/fex: the same build DroidDeck-Components' build-fex.yml runs from this repository to publish FEX -# (its single `fex` release and fex.json, which the app follows). Nothing is published from here. - on: push: paths: @@ -31,7 +28,6 @@ jobs: with: persist-credentials: false - name: Build in Arch Linux ARM - # Inputs reach the shell as environment variables, never pasted into the script. env: FEX_EXTRA_CXXFLAGS: ${{ inputs.cxxflags }} FEX_THUNKS: ${{ inputs.thunks == 'true' && '1' || '0' }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c45b2102..7418c5bfb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,6 +63,8 @@ jobs: echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" git config user.name "ci" git config user.email "ci@users.noreply.github.com" + # Partial clone: a merged blob that only exists in a fork's history can't be lazily fetched (GitHub: "not our ref"); merge-tree writes it locally first. + git merge-tree --write-tree HEAD origin/main >/dev/null || true git merge --no-ff --no-edit origin/main - name: Resolve trusted manifest base id: trusted @@ -100,6 +102,7 @@ jobs: git fetch --no-tags origin "$BUILD_BASE_SHA" git config user.name "ci" git config user.email "ci@users.noreply.github.com" + git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true git merge --no-ff --no-edit FETCH_HEAD - name: Verify pinned source tree env: @@ -158,6 +161,7 @@ jobs: git fetch --no-tags origin "$BUILD_BASE_SHA" git config user.name "ci" git config user.email "ci@users.noreply.github.com" + git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true git merge --no-ff --no-edit FETCH_HEAD - name: Verify pinned source tree env: @@ -319,6 +323,7 @@ jobs: !/docs/releases/media/ sparse-checkout-cone-mode: false persist-credentials: false + submodules: recursive - name: Check human authorship env: @@ -379,6 +384,7 @@ jobs: git fetch --no-tags origin "$BUILD_BASE_SHA" BASE=$(git rev-parse --short FETCH_HEAD) HEAD=$(git rev-parse --short HEAD) + git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then { echo "### Mergeable" @@ -531,6 +537,30 @@ jobs: - name: Build the add-on packs run: bash tools/packs/build.sh app/build/packs + - name: Pin the Syncthing source + id: syncthing-src + run: | + set -euo pipefail + git submodule update --init --recursive tools/syncthing/syncthing-android + echo "commit=$(git -C tools/syncthing/syncthing-android/syncthing/src/github.com/syncthing/syncthing rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Cache Syncthing + id: syncthing + uses: actions/cache@v4 + with: + path: app/src/main/jniLibs/arm64-v8a/libsyncthingnative.so + key: syncthing-${{ env.NDK_VERSION }}-${{ hashFiles('tools/syncthing/build.sh', 'tools/syncthing/syncthing-android/gradle/libs.versions.toml') }}-${{ steps.syncthing-src.outputs.commit }} + - uses: actions/setup-go@v5 + if: steps.syncthing.outputs.cache-hit != 'true' + with: + go-version: stable + cache: false + - name: Build Syncthing + if: steps.syncthing.outputs.cache-hit != 'true' + run: | + set -euo pipefail + export NDK="$ANDROID_HOME/ndk/$NDK_VERSION" + tools/syncthing/build.sh app/src/main/jniLibs/arm64-v8a + - name: Note when the proot pack pin is behind this build run: | set -euo pipefail @@ -684,6 +714,7 @@ jobs: git fetch --no-tags origin "$BUILD_BASE_SHA" git config user.name "ci" git config user.email "ci@users.noreply.github.com" + git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true git merge --no-ff --no-edit FETCH_HEAD - name: Verify backend and test source match env: @@ -737,6 +768,7 @@ jobs: git fetch --no-tags origin "$BUILD_BASE_SHA" git config user.name "ci" git config user.email "ci@users.noreply.github.com" + git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true git merge --no-ff --no-edit FETCH_HEAD - name: Verify backend and test source match env: diff --git a/.github/workflows/publish-packs.yml b/.github/workflows/publish-packs.yml index 7bf219ef7..de4adb3b2 100644 --- a/.github/workflows/publish-packs.yml +++ b/.github/workflows/publish-packs.yml @@ -1,11 +1,5 @@ name: Publish add-on packs -# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under -# content-addressed tags (pack--). The APK from that run pins each pack's -# SHA-256 and downloads it from that tag. A tag is created once and never replaced. -# Only main and release builds publish here; a PR test build attaches the packs main has not published -# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes. - on: workflow_call: inputs: diff --git a/.gitignore b/.gitignore index e5bdbfffc..bfeee7641 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ app/src/main/jniLibs/arm64-v8a/libproot.so app/src/main/jniLibs/arm64-v8a/.proot-inputs app/src/main/jniLibs/arm64-v8a/.proot-outputs app/src/main/jniLibs/arm64-v8a/libproot-loader.so +app/src/main/jniLibs/arm64-v8a/libsyncthingnative.so +app/src/main/jniLibs/arm64-v8a/.syncthing-inputs app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so app/src/main/rust/stores/target/ app/src/main/assets/droiddeck-esync/ diff --git a/.gitmodules b/.gitmodules index 299e3b068..de75c26af 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "external/MaliBridge"] path = external/MaliBridge url = https://github.com/ASEbrahim/MaliBridge.git +[submodule "tools/syncthing/syncthing-android"] + path = tools/syncthing/syncthing-android + url = https://github.com/researchxxl/syncthing-android diff --git a/app/build.gradle b/app/build.gradle index 3a9019ccc..ef07bd9e7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,7 +4,6 @@ plugins { apply plugin: 'kotlin-android' apply plugin: 'org.jetbrains.kotlin.plugin.compose' -// Machine-specific tooling and optional CI-prepared inputs stay out of version control. def localBuildOptions = new Properties() def localBuildOptionsFile = rootProject.layout.projectDirectory.file('local.properties') if (localBuildOptionsFile.asFile.exists()) { @@ -98,8 +97,6 @@ def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'butler'] def pack = file("build/packs/${name}.tar.zst") if (!pack.isFile()) { if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs") - // A source build that did not compile proot still pins a published pack, so the app - // downloads the patched binary instead of falling back to the rootfs copy. if (name == 'proot' && prootPackPin.PROOT_PACK_SHA256) { return [(name): [sha256: prootPackPin.PROOT_PACK_SHA256, size: (prootPackPin.PROOT_PACK_SIZE ?: '0') as long]] } @@ -208,7 +205,6 @@ android { } androidResources { - // aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs). ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:_*:!CVS:!thumbs.db:!picasa.ini:!*~:droiddeck-xbox:droiddeck-comet:directaudio' } @@ -237,7 +233,6 @@ android { outputs.upToDateWhen { false } outputs.doNotCacheIf('CI executes the test suite on every run') { true } } - // Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB. maxHeapSize = '2g' maxParallelForks = 2 def logTarget = providers.systemProperty('robolectric.logging').orNull @@ -254,7 +249,6 @@ android { } } -// Vendor binaries remain in the user's SDK directory; never vendor them into Git. if (vitureSdkDir) { android.sourceSets.main.jniLibs.srcDir(new File(vitureSdkDir, 'android')) def stageVitureNotices = tasks.register('stageVitureNotices', Copy) { @@ -299,7 +293,6 @@ def stageGogNotices = tasks.register('stageGogNotices', Copy) { android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices')) tasks.named('preBuild').configure { dependsOn(stageGogNotices) } -// CI publishes its packs; a local build pins packs nobody hosts (see addonPacks), so the APK carries them. if (!ciBuild) { def stageLocalPacks = tasks.register('stageLocalPacks', Sync) { from(layout.buildDirectory.dir('packs')) { include '*.tar.zst' } @@ -388,11 +381,11 @@ dependencies { implementation 'androidx.camera:camera-camera2:1.4.2' implementation 'androidx.camera:camera-lifecycle:1.4.2' implementation 'androidx.webkit:webkit:1.12.1' - // Google's WebRTC (BSD) for the native Xbox Cloud Gaming player (XboxNativePlayerActivity). implementation 'io.github.webrtc-sdk:android:125.6422.07' implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") { exclude group: 'com.google.auto.value', module: 'auto-value' } + implementation 'com.google.zxing:core:3.5.3' testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.16.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cc9fd6688..e5b17b50e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -194,6 +194,13 @@ android:resizeableActivity="true" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" /> + + + + @@ -303,6 +315,10 @@ android:name=".stores.download.StoreDownloadService" android:exported="false" android:foregroundServiceType="dataSync" /> + SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc). add_subdirectory(thirdparty/dxbc) set(DDFG_UPSTREAM "v0.3.0") @@ -37,13 +36,14 @@ target_link_libraries(sdnative log) add_library(deviceinfo SHARED deviceinfo/vkinfo.c) target_link_libraries(deviceinfo dl) +add_library(gpuclock SHARED gpuclock/kgsl_clock.c gpuclock/kgsl_busy.c) + add_library(termux SHARED termux/termux.c) add_library(ddvenc SHARED venc/venc_server.cpp) target_compile_features(ddvenc PRIVATE cxx_std_17) target_link_libraries(ddvenc mediandk log) -# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored. if(DROIDDECK_VITURE_SDK_DIR) add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp) target_compile_features(droiddeck_viture PRIVATE cxx_std_17) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 9eb73a001..8906b01f7 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -78,7 +78,6 @@ static constexpr uint8_t GAMEPAD_AXIS_COUNT = 8; static constexpr uint8_t GAMEPAD_BUTTON_COUNT = 11; static constexpr uint32_t FAKE_INPUT_RING_MAGIC = 0x46494252; static constexpr uint32_t FAKE_INPUT_RING_VERSION = 2; -// Rings hold the 64-bit input_event layout that the arm64 writers use; 32-bit guests convert on read. struct RingEvent { int64_t sec; int64_t usec; @@ -391,7 +390,6 @@ 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) { @@ -1243,7 +1241,6 @@ static constexpr int TRITON_REPORT_ID_BYTES = 1; static constexpr int TRITON_STATE_BYTES = 45; static constexpr int TRITON_REPORT_INTERVAL_US = 4000; static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500; -// The app clears output_length once it has taken a report; a writer waits this long for that before overwriting. static constexpr int TRITON_OUTPUT_TIMEOUT_MS = 50; static constexpr const char *TRITON_NAME = "Steam Controller"; static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01"; @@ -2673,9 +2670,6 @@ __attribute__((visibility("hidden"))) static int triton_get_feature(DeckHidraw & static constexpr uint32_t TRITON_HWID = 72; static constexpr uint32_t TRITON_FW_TIMESTAMP = 0x6AC686B3; -// Steam compares the controller's firmware with TRITON_FW_TS in its bundled hardwareupdater.cfg (and blocks -// with a required update below MUST_UPDATE_TRITON_FW_TS), so the emulated pad reports at least the build -// Steam ships. static uint32_t triton_fw_timestamp() { static const uint32_t timestamp = [] { std::vector roots; @@ -3679,7 +3673,6 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { Logger::log("Hooking ioctl EVIOCGRAB for event %s\n", event); return 0; } else if (type == 0x6A && !strncmp(event, "event", 5)) { - // Older SDL2 builds treat any JSIOCGNAME answer as a joydev node and skip EVIOCGID, then drop the pad as 0000:0000. Logger::log("Rejecting joydev ioctl %d for event %s\n", number, event); guard.unlock(); errno = EINVAL; diff --git a/app/src/main/cpp/framegen/ddfg/record_impl.inc b/app/src/main/cpp/framegen/ddfg/record_impl.inc index 734d94cdc..f366d9a9c 100644 --- a/app/src/main/cpp/framegen/ddfg/record_impl.inc +++ b/app/src/main/cpp/framegen/ddfg/record_impl.inc @@ -267,8 +267,6 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_); } - // Every generation of one real frame is recorded into the same command buffer, so the flow - // between prev and curr is computed once and each generation gets its own synth UBO and set. if (reuseFlow && !g_scratch.flowReady) return; if (!reuseFlow) { bool gmEngaged = updateGlobalMotion(gmSlot, extent_); diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp index 3a37f032a..f9ee32463 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp @@ -1,5 +1,4 @@ -// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). -// Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later. +// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later. #include "lsfg_dll.h" #include "lsfg_dxbc.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h index a20a6c631..81bf6781d 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h @@ -1,5 +1,4 @@ -// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). -// Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later. +// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later. #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h index 17ba1876d..2f5286b26 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h @@ -1,5 +1,4 @@ #pragma once -// DXVK's dxbc subset is zlib licensed; vendored under cpp/thirdparty/dxbc. #include #include diff --git a/app/src/main/cpp/gpuclock/kgsl_busy.c b/app/src/main/cpp/gpuclock/kgsl_busy.c new file mode 100644 index 000000000..a61114793 --- /dev/null +++ b/app/src/main/cpp/gpuclock/kgsl_busy.c @@ -0,0 +1,121 @@ +// Whole-GPU busy share without root on Adreno (KGSL) devices whose kgsl sysfs is blocked for apps: a thread +// samples the memstore ringbuffer timestamps (kgsl_busy.h) once a millisecond while running. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../adrenotools/src/hook/kgsl.h" +#include "kgsl_busy.h" + +#define SAMPLE_NS 1000000L + +struct reader { + int fd; + void *map; + size_t bytes; + int first; + uint32_t last[KGSL_BUSY_SLOTS]; + pthread_mutex_t lock; + pthread_t thread; + int thread_on; + atomic_int run; + atomic_uint_fast64_t busy, samples, moves; +}; + +static void *sample_loop(void *arg) { + struct reader *r = arg; + const struct timespec gap = {0, SAMPLE_NS}; + while (atomic_load(&r->run)) { + int moved = 0; + int busy = kgsl_busy_sample(r->map, r->first, r->last, &moved); + if (busy) atomic_fetch_add(&r->busy, 1); + if (moved) atomic_fetch_add(&r->moves, 1); + atomic_fetch_add(&r->samples, 1); + nanosleep(&gap, NULL); + } + return NULL; +} + +static void reader_run(struct reader *r, int on) { + pthread_mutex_lock(&r->lock); + if (on && !r->thread_on) { + atomic_store(&r->run, 1); + if (pthread_create(&r->thread, NULL, sample_loop, r) == 0) r->thread_on = 1; + else atomic_store(&r->run, 0); + } else if (!on && r->thread_on) { + atomic_store(&r->run, 0); + pthread_join(r->thread, NULL); + r->thread_on = 0; + } + pthread_mutex_unlock(&r->lock); +} + +static void reader_close(struct reader *r) { + if (!r) return; + reader_run(r, 0); + if (r->map && r->map != MAP_FAILED) munmap(r->map, r->bytes); + if (r->fd >= 0) close(r->fd); + pthread_mutex_destroy(&r->lock); + free(r); +} + +// Returns 0 or a negative errno; *out is set on success. +static int reader_open(struct reader **out) { + struct reader *r = calloc(1, sizeof(*r)); + if (!r) return -ENOMEM; + pthread_mutex_init(&r->lock, NULL); + r->fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC); + if (r->fd < 0) { int e = -errno; reader_close(r); return e; } + struct kgsl_shadowprop shadow; + memset(&shadow, 0, sizeof(shadow)); + struct kgsl_device_getproperty prop = {.type = KGSL_PROP_DEVICE_SHADOW, .value = &shadow, .sizebytes = sizeof(shadow)}; + if (ioctl(r->fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &prop)) { int e = -errno; reader_close(r); return e; } + r->bytes = shadow.size; + r->first = kgsl_busy_first_slot(shadow.size); + if (r->first < 0) { reader_close(r); return -ERANGE; } + r->map = mmap(NULL, r->bytes, PROT_READ, MAP_SHARED, r->fd, (off_t)shadow.gpuaddr); + if (r->map == MAP_FAILED) { int e = -errno; reader_close(r); return e; } + int moved = 0, started = 0; + kgsl_busy_sample(r->map, r->first, r->last, &moved); + for (int i = 0; i < KGSL_BUSY_SLOTS; i++) started |= r->last[i] != 0; + if (!started) { reader_close(r); return -ENODATA; } + *out = r; + return 0; +} + +#ifndef KGSL_BUSY_NO_JNI +#include + +JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeOpen(JNIEnv *env, jclass cls, jintArray error) { + struct reader *r = NULL; + int e = reader_open(&r); + if (e && error && (*env)->GetArrayLength(env, error) > 0) { + jint v = e; + (*env)->SetIntArrayRegion(env, error, 0, 1, &v); + } + return e ? 0 : (jlong)(intptr_t)r; +} + +JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeRun(JNIEnv *env, jclass cls, jlong handle, jboolean on) { + if (handle) reader_run((struct reader *)(intptr_t)handle, on ? 1 : 0); +} + +JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeRead(JNIEnv *env, jclass cls, jlong handle, jlongArray out) { + if (!handle || !out || (*env)->GetArrayLength(env, out) < 3) return JNI_FALSE; + struct reader *r = (struct reader *)(intptr_t)handle; + jlong v[3] = {(jlong)atomic_load(&r->busy), (jlong)atomic_load(&r->samples), (jlong)atomic_load(&r->moves)}; + (*env)->SetLongArrayRegion(env, out, 0, 3, v); + return JNI_TRUE; +} + +JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeClose(JNIEnv *env, jclass cls, jlong handle) { + reader_close((struct reader *)(intptr_t)handle); +} +#endif diff --git a/app/src/main/cpp/gpuclock/kgsl_busy.h b/app/src/main/cpp/gpuclock/kgsl_busy.h new file mode 100644 index 000000000..2179cf533 --- /dev/null +++ b/app/src/main/cpp/gpuclock/kgsl_busy.h @@ -0,0 +1,37 @@ +#ifndef DROIDDECK_KGSL_BUSY_H +#define DROIDDECK_KGSL_BUSY_H +// kgsl lets any client map its memstore read-only (KGSL_PROP_DEVICE_SHADOW). The CP writes a ringbuffer's +// start-of-pipe timestamp there before each submission's command buffers and its end-of-pipe timestamp after them +// (adreno_gen7_ringbuffer.c gen7_ringbuffer_addcmds), so start != end means that ringbuffer has work on the GPU. +// Ringbuffer entries start at KGSL_MEMSTORE_MAX: entries - 2 - 4 levels, or entries - 1 - 4 on kernels without the +// LPAC slot. Entries - 6 .. entries - 2 cover both; a context entry in that range follows the same start/end rule. +#include +#include + +#define KGSL_BUSY_ENTRY_WORDS 10 +#define KGSL_BUSY_SOP 0 +#define KGSL_BUSY_EOP 2 +#define KGSL_BUSY_SLOTS 5 + +static inline int kgsl_busy_first_slot(size_t bytes) { + size_t entries = bytes / (KGSL_BUSY_ENTRY_WORDS * sizeof(uint32_t)); + return entries < 8 ? -1 : (int)(entries - 6); +} + +// 1 when a watched slot has a submission between its start and end timestamps. *moved is set when a start +// timestamp differs from last[], which then holds the new ones. +static inline int kgsl_busy_sample(const volatile uint32_t *memstore, int first, uint32_t last[KGSL_BUSY_SLOTS], int *moved) { + int busy = 0; + for (int i = 0; i < KGSL_BUSY_SLOTS; i++) { + const volatile uint32_t *entry = memstore + (size_t)(first + i) * KGSL_BUSY_ENTRY_WORDS; + uint32_t sop = entry[KGSL_BUSY_SOP], eop = entry[KGSL_BUSY_EOP]; + if (sop != eop) busy = 1; + if (sop != last[i]) { + *moved = 1; + last[i] = sop; + } + } + return busy; +} + +#endif diff --git a/app/src/main/cpp/gpuclock/kgsl_clock.c b/app/src/main/cpp/gpuclock/kgsl_clock.c new file mode 100644 index 000000000..b021208b4 --- /dev/null +++ b/app/src/main/cpp/gpuclock/kgsl_clock.c @@ -0,0 +1,156 @@ +// GPU clock without root on Adreno (KGSL) devices whose kgsl sysfs is blocked for apps. +// +// kgsl lets any app reserve a performance counter (IOCTL_KGSL_PERFCOUNTER_GET) but refuses to read it back while +// /sys/class/kgsl/kgsl-3d0/perfcounter is 0, and clears the counters whenever the GPU switches to another process. +// So the counter is read by the GPU itself: a small command buffer copies CP_ALWAYS_ON_COUNTER (19.2 MHz) and the +// RBBM ALWAYS_COUNT counter (GPU core cycles) to memory, runs a block of NOP packets, and copies both again. +// clock = cycles / always-on ticks * 19.2 MHz. Both reads sit in one command buffer, so a counter clear between +// them only happens if the buffer is preempted; such samples fail the range checks and are dropped. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../adrenotools/src/hook/kgsl.h" + +#define CP_TYPE7_PKT 0x70000000u +#define CP_NOP 0x10u +#define CP_REG_TO_MEM 0x3eu +#define CP_ALWAYS_ON_COUNTER 0x980u +#define RBBM_ALWAYS_COUNT 0u +// gen7 CP protection marks registers 0x000-0x4ff readable by command buffers (adreno-gpulist.h); a counter +// kgsl places anywhere else is not read. +#define READABLE_LAST 0x4ffu +#define XO_HZ 19200000.0 +#define NOP_DWORDS 0x3fffu +#define NOP_BLOCKS 4 + +struct sampler { + int fd; + unsigned context; + unsigned counter_reg; + struct kgsl_gpumem_alloc_id ib, out; + uint32_t *ib_map; + volatile uint64_t *out_map; + size_t ib_bytes; +}; + +static unsigned odd_parity(unsigned v) { + v ^= v >> 16; + v ^= v >> 8; + v ^= v >> 4; + v &= 0xf; + return (~0x6996u >> v) & 1u; +} + +static uint32_t pkt7(unsigned op, unsigned count) { + return CP_TYPE7_PKT | count | odd_parity(count) << 15 | (op & 0x7f) << 16 | odd_parity(op) << 23; +} + +static void sampler_close(struct sampler *s) { + if (!s) return; + if (s->fd >= 0) { + if (s->ib_map && s->ib_map != MAP_FAILED) munmap(s->ib_map, s->ib.mmapsize); + if (s->out_map && s->out_map != MAP_FAILED) munmap((void *)s->out_map, s->out.mmapsize); + if (s->ib.id) { struct kgsl_gpumem_free_id f = {.id = s->ib.id}; ioctl(s->fd, IOCTL_KGSL_GPUMEM_FREE_ID, &f); } + if (s->out.id) { struct kgsl_gpumem_free_id f = {.id = s->out.id}; ioctl(s->fd, IOCTL_KGSL_GPUMEM_FREE_ID, &f); } + if (s->context) { struct kgsl_drawctxt_destroy d = {.drawctxt_id = s->context}; ioctl(s->fd, IOCTL_KGSL_DRAWCTXT_DESTROY, &d); } + if (s->counter_reg) { + struct kgsl_perfcounter_put p = {.groupid = KGSL_PERFCOUNTER_GROUP_RBBM, .countable = RBBM_ALWAYS_COUNT}; + ioctl(s->fd, IOCTL_KGSL_PERFCOUNTER_PUT, &p); + } + close(s->fd); + } + free(s); +} + +static void emit_reads(struct sampler *s, size_t *n, unsigned pass) { + const unsigned regs[2] = {CP_ALWAYS_ON_COUNTER, s->counter_reg}; + for (unsigned k = 0; k < 2; k++) { + uint64_t dst = s->out.gpuaddr + (uint64_t)(pass * 2 + k) * sizeof(uint64_t); + s->ib_map[(*n)++] = pkt7(CP_REG_TO_MEM, 3); + s->ib_map[(*n)++] = regs[k] | (2u << 18) | (1u << 30); // two dwords, 64-bit read + s->ib_map[(*n)++] = (uint32_t)dst; + s->ib_map[(*n)++] = (uint32_t)(dst >> 32); + } +} + +// Returns 0 or a negative errno; *out is set on success. +static int sampler_open(struct sampler **out) { + struct sampler *s = calloc(1, sizeof(*s)); + if (!s) return -ENOMEM; + s->fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC); + if (s->fd < 0) { int e = -errno; s->fd = -1; sampler_close(s); return e; } + struct kgsl_perfcounter_get get = {.groupid = KGSL_PERFCOUNTER_GROUP_RBBM, .countable = RBBM_ALWAYS_COUNT}; + if (ioctl(s->fd, IOCTL_KGSL_PERFCOUNTER_GET, &get)) { int e = -errno; sampler_close(s); return e; } + s->counter_reg = get.offset; + if (!get.offset || get.offset > READABLE_LAST) { sampler_close(s); return -ERANGE; } + struct kgsl_drawctxt_create ctx = {.flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE}; + if (ioctl(s->fd, IOCTL_KGSL_DRAWCTXT_CREATE, &ctx)) { int e = -errno; sampler_close(s); return e; } + s->context = ctx.drawctxt_id; + s->ib_bytes = (16 + NOP_BLOCKS * (NOP_DWORDS + 1)) * sizeof(uint32_t); + s->ib.size = (s->ib_bytes + 4095) & ~(size_t)4095; + s->out.size = 4096; + if (ioctl(s->fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &s->ib)) { int e = -errno; s->ib.id = 0; sampler_close(s); return e; } + if (ioctl(s->fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &s->out)) { int e = -errno; s->out.id = 0; sampler_close(s); return e; } + s->ib_map = mmap(NULL, s->ib.mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, s->fd, (off_t)s->ib.id << 12); + s->out_map = mmap(NULL, s->out.mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, s->fd, (off_t)s->out.id << 12); + if (s->ib_map == MAP_FAILED || s->out_map == MAP_FAILED) { int e = -errno; sampler_close(s); return e; } + size_t n = 0; + emit_reads(s, &n, 0); + for (unsigned b = 0; b < NOP_BLOCKS; b++) { + s->ib_map[n++] = pkt7(CP_NOP, NOP_DWORDS); + memset(&s->ib_map[n], 0, NOP_DWORDS * sizeof(uint32_t)); + n += NOP_DWORDS; + } + emit_reads(s, &n, 1); + s->ib_bytes = n * sizeof(uint32_t); + *out = s; + return 0; +} + +// MHz, or a negative value: -errno from kgsl, or -1000 for a sample that failed the range checks. +static int sampler_mhz(struct sampler *s) { + for (int i = 0; i < 4; i++) s->out_map[i] = 0; + struct kgsl_command_object cmd = {.gpuaddr = s->ib.gpuaddr, .size = s->ib_bytes, .flags = KGSL_CMDLIST_IB}; + struct kgsl_gpu_command submit = { + .cmdlist = (uint64_t)(uintptr_t)&cmd, .cmdsize = sizeof(cmd), .numcmds = 1, .context_id = s->context, + }; + if (ioctl(s->fd, IOCTL_KGSL_GPU_COMMAND, &submit)) return -errno; + struct kgsl_device_waittimestamp_ctxtid wait = {.context_id = s->context, .timestamp = submit.timestamp, .timeout = 250}; + if (ioctl(s->fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait)) return -errno; + uint64_t xo0 = s->out_map[0], cyc0 = s->out_map[1], xo1 = s->out_map[2], cyc1 = s->out_map[3]; + if (xo1 <= xo0 || cyc1 <= cyc0) return -1000; + uint64_t xo = xo1 - xo0, cycles = cyc1 - cyc0; + // 5 us .. 50 ms of always-on time, and a clock between 50 MHz and 2 GHz. + if (xo < 96 || xo > 960000) return -1000; + double mhz = (double)cycles / (double)xo * XO_HZ / 1e6; + if (mhz < 50 || mhz > 2000) return -1000; + return (int)(mhz + 0.5); +} + +#ifndef KGSL_CLOCK_NO_JNI +#include + +JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeOpen(JNIEnv *env, jclass cls, jintArray error) { + struct sampler *s = NULL; + int r = sampler_open(&s); + if (r && error && (*env)->GetArrayLength(env, error) > 0) { + jint e = r; + (*env)->SetIntArrayRegion(env, error, 0, 1, &e); + } + return r ? 0 : (jlong)(intptr_t)s; +} + +JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeSample(JNIEnv *env, jclass cls, jlong handle) { + return handle ? sampler_mhz((struct sampler *)(intptr_t)handle) : -EBADF; +} + +JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeClose(JNIEnv *env, jclass cls, jlong handle) { + sampler_close((struct sampler *)(intptr_t)handle); +} +#endif diff --git a/app/src/main/cpp/questxr/gpu_frames.h b/app/src/main/cpp/questxr/gpu_frames.h index dc69dbbfb..77d7a6db6 100644 --- a/app/src/main/cpp/questxr/gpu_frames.h +++ b/app/src/main/cpp/questxr/gpu_frames.h @@ -76,7 +76,6 @@ public: } 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; @@ -88,7 +87,6 @@ public: 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(); diff --git a/app/src/main/cpp/venc/venc_proto.h b/app/src/main/cpp/venc/venc_proto.h index 47fb6ef24..e69bbd822 100644 --- a/app/src/main/cpp/venc/venc_proto.h +++ b/app/src/main/cpp/venc/venc_proto.h @@ -3,10 +3,6 @@ #include -/* SOCK_SEQPACKET between the guest's Remote Play hooks and the app: the emulated /dev/video-enc0 and - * MediaCodec encoder, and remote touches. A config carries the buffer memfds (inputs first) as SCM_RIGHTS; - * every frame gets one reply, touches get none. */ - #define VENC_MAX_BUFFERS 16 enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5, VDEC_CONFIG = 6, VDEC_CAPTURE = 7, VDEC_PACKET = 8, VDEC_RESULT = 9 }; @@ -45,7 +41,6 @@ struct venc_bitrate { uint32_t bitrate; }; -/* Remote Play's touch-as-mouse turned back into a touch; x and y are fractions of the screen in 1/65535. */ struct venc_touch { uint32_t type; uint32_t action; diff --git a/app/src/main/cpp/venc/venc_server.cpp b/app/src/main/cpp/venc/venc_server.cpp index 17a5d7eed..fd3a9ad13 100644 --- a/app/src/main/cpp/venc/venc_server.cpp +++ b/app/src/main/cpp/venc/venc_server.cpp @@ -33,8 +33,8 @@ struct Mapping { constexpr int32_t kColorFormatYuv420SemiPlanar = 21; constexpr uint32_t kBufferFlagKeyFrame = 1; constexpr uint32_t kBufferFlagCodecConfig = 2; -constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt -constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32) +constexpr int kTouchWidth = 1920, kTouchHeight = 1080; +constexpr int kRemoteTouchId = 31; int32_t avc_profile(int32_t v4l2) { switch (v4l2) { @@ -549,7 +549,7 @@ void serve(int listener) { } } -} // namespace +} extern "C" JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) { diff --git a/app/src/main/cpp/viture/display_modes.h b/app/src/main/cpp/viture/display_modes.h index 3a78e03ae..b6b2288e2 100644 --- a/app/src/main/cpp/viture/display_modes.h +++ b/app/src/main/cpp/viture/display_modes.h @@ -1,20 +1,18 @@ #pragma once -// Our controller uses the standard display-mode vocabulary, even for Beast. -// Native mode reuses some numbers with different meanings; never pass them through. namespace droiddeck::viture { constexpr int to_native_mode(int standard) { switch (standard) { - case 0x31: return 0x31; // 1080p 60 - case 0x33: return 0x32; // 1080p 90 - case 0x34: return 0x33; // 1080p 120 - case 0x32: return 0x37; // 1080p SBS 60 - case 0x35: return 0x38; // 1080p SBS 90 - case 0x41: return 0x34; // 1200p 60 - case 0x43: return 0x35; // 1200p 90 - case 0x44: return 0x36; // 1200p 120 - case 0x42: return 0x3a; // 1200p SBS 60 - case 0x45: return 0x3b; // 1200p SBS 90 + case 0x31: return 0x31; + case 0x33: return 0x32; + case 0x34: return 0x33; + case 0x32: return 0x37; + case 0x35: return 0x38; + case 0x41: return 0x34; + case 0x43: return 0x35; + case 0x44: return 0x36; + case 0x42: return 0x3a; + case 0x45: return 0x3b; default: return -4; } } @@ -30,7 +28,7 @@ constexpr int from_native_mode(int native) { case 0x36: return 0x44; case 0x3a: return 0x42; case 0x3b: return 0x45; - default: return -4; // Ultrawide is not stereo. + default: return -4; } } } diff --git a/app/src/main/cpp/viture/mjpeg_payload.h b/app/src/main/cpp/viture/mjpeg_payload.h index a451a4eee..470ecf550 100644 --- a/app/src/main/cpp/viture/mjpeg_payload.h +++ b/app/src/main/cpp/viture/mjpeg_payload.h @@ -5,7 +5,6 @@ inline std::size_t mjpeg_payload_size(const uint8_t* bytes, std::size_t size) { if (!bytes || size < 4 || size > 4*1024*1024 || bytes[0] != 0xff || bytes[1] != 0xd8) return 0; std::size_t end = size; - // Luma Ultra UVC frames can pad the JPEG to an eight-byte boundary with zeros. while (end > 4 && size-end < 7 && bytes[end-1] == 0) --end; return bytes[end-2] == 0xff && bytes[end-1] == 0xd9 ? end : 0; } diff --git a/app/src/main/cpp/viture/sdk_bridge.cpp b/app/src/main/cpp/viture/sdk_bridge.cpp index 053c28335..04ee47a35 100644 --- a/app/src/main/cpp/viture/sdk_bridge.cpp +++ b/app/src/main/cpp/viture/sdk_bridge.cpp @@ -30,7 +30,6 @@ #include "stereo_frame_gate.h" namespace { -// SDK callbacks have no user-data parameter. Only one provider may exist at a time. std::mutex api_mutex; std::mutex pose_mutex; std::array latest_pose{}; @@ -64,7 +63,6 @@ void on_pose(float* data, uint64_t) { std::lock_guard lock(pose_mutex); if (!accepting_pose) return; std::copy_n(data + 3, 4, latest_pose.begin()); - // SDK device ticks are not Android's elapsed-realtime clock. latest_time = now_ns(); } @@ -234,8 +232,6 @@ int set_display(Session& s, int standard) { if (mode == 0) { int rc = xr_device_provider_set_display_mode(s.device, standard); if (rc != VITURE_GLASSES_ERROR_NOT_SUPPORTED) return rc; - // Older Beast firmware exposes fewer bypass modes. Fall back to its - // separate native table only when the SDK explicitly rejects the mode. } const int native = droiddeck::viture::to_native_mode(standard); if (native < 0) return native; diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index a4de38ca1..fd0b2f20a 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -18,6 +18,7 @@ add_library(droiddeckwayland SHARED src/wl_color_mgmt.c src/hdr_compose.c src/blend_pass.c + src/gpu_busy.c generated/color-management-v1-protocol.c generated/xdg-shell-protocol.c generated/linux-dmabuf-v1-protocol.c @@ -43,6 +44,7 @@ target_include_directories(droiddeckwayland PRIVATE prebuilt/include src ${CMAKE_CURRENT_SOURCE_DIR}/../shaders + ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/gpuload/src ) set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg) diff --git a/app/src/main/cpp/waylandcomp/src/cardboard.c b/app/src/main/cpp/waylandcomp/src/cardboard.c index 03c434316..982c3bc47 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard.c +++ b/app/src/main/cpp/waylandcomp/src/cardboard.c @@ -34,7 +34,6 @@ void cardboard_clamp(struct cardboard_params *p) { p->screen_width_m = clampf(p->screen_width_m, 0.2f, 60.0f); p->k1 = clampf(p->k1, -1.0f, 2.0f); p->k2 = clampf(p->k2, -1.0f, 2.0f); - // Optical vertical FOV on supported glasses can be as low as ~22 degrees. p->fov_deg = clampf(p->fov_deg, 20.0f, 140.0f); p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f); p->chroma = clampf(p->chroma, 0.0f, 0.05f); diff --git a/app/src/main/cpp/waylandcomp/src/cardboard_frag.h b/app/src/main/cpp/waylandcomp/src/cardboard_frag.h index f74676d6a..fb098c4a3 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard_frag.h +++ b/app/src/main/cpp/waylandcomp/src/cardboard_frag.h @@ -1,4 +1,3 @@ - // 1115.1.0 #pragma once const uint32_t cardboard_frag_code[] = { 0x07230203,0x00010000,0x0008000b,0x000001ba,0x00000000,0x00020011,0x00000001,0x0006000b, diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 68039b444..471748a6d 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -289,7 +289,6 @@ static struct wl_event_source *g_fallback_timer; static int g_fallback_armed; volatile int g_fps_limit; -/* fps limit -1 (phone output, not mirrored): replaced buffers go back to the client at once instead of once per refresh. */ volatile int g_release_unpaced; volatile int g_hide_shell; @@ -446,15 +445,10 @@ static int g_cursor_w, g_cursor_h; static int g_cursor_hidden = 1; static int g_cursor_serial; -/* The pointer image as a compositor-owned texture, for outputs that cannot show - * the phone's Compose overlay (the glasses surface owns the picture there). */ -#define CURSOR_MAX_DRAWS 2 /* one per eye when the picture is side-by-side */ +#define CURSOR_MAX_DRAWS 2 static struct vkp_image *g_cursor_img; static int g_cursor_img_w, g_cursor_img_h, g_cursor_img_serial = -1; static atomic_int g_cursor_composite; -/* Last request from the app, used only to skip repeated calls. The applied flag below is - * written by the caller and read by the render path; message 22 only wakes the event loop, so - * a queued message can never put back an older value. */ static atomic_int g_cursor_composite_want; static void cursor_publish_shown(void) { @@ -1922,8 +1916,6 @@ static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int fra } if (un && g_zero_copy) { hs->color = droiddeck_color_of(un->resource); hs->who = un; return 3; } hs->hf.tonemap = !droiddeck_color_output(); - /* The pointer draws are appended after this plan: reserve their slots (zeroed, - * so they count as SDR content above the game) instead of reading past the end. */ if (dl->n <= 0 || !(hs->is_hdr = calloc((size_t)dl->n + CURSOR_MAX_DRAWS, 1))) return 0; for (int i = 0; i < dl->n; i++) { struct surface *s = surface_for_image(dl->d[i].img); @@ -2026,12 +2018,6 @@ static void hdr_count_copied(const struct draw_list *dl, const char *reason) { } } -/* - * Stream mode (the Xbox Cloud Gaming native player, in its own process): no Wayland clients, the scene is one - * picture handed over as AHardwareBuffers (ImageReader images of the decoded video). Each buffer is imported once - * and kept while the reader cycles through its few buffers; the newest frame is the scene, so the screen effects - * and frame generation apply to it as to a game. - */ #define STREAM_SLOTS 6 static pthread_mutex_t g_stream_lock = PTHREAD_MUTEX_INITIALIZER; static atomic_int g_stream_on; @@ -2051,7 +2037,6 @@ static void stream_forget_all(void) { g_stream_img = NULL; } -/* Compositor thread: the newest handed-over buffer becomes the scene picture. */ static void stream_take(void) { pthread_mutex_lock(&g_stream_lock); AHardwareBuffer *ahb = g_stream_pending; @@ -2065,7 +2050,7 @@ static void stream_take(void) { if (g_stream_slots[i].used < g_stream_slots[oldest].used) oldest = i; } if (slot >= 0) { - AHardwareBuffer_release(ahb); /* the slot keeps its own reference */ + AHardwareBuffer_release(ahb); } else { struct vkp_image *img = vkp_image_from_ahb(ahb); if (!img) { @@ -2077,7 +2062,7 @@ static void stream_take(void) { droiddeck_log("stream", "decoded frame buffer imported (%dx%d)", vkp_image_width(img), vkp_image_height(img)); slot = oldest; if (g_stream_slots[slot].img) { - vkp_wait_idle(); /* the GPU may still read the picture being replaced */ + vkp_wait_idle(); if (g_stream_img == g_stream_slots[slot].img) g_stream_img = NULL; vkp_image_destroy(g_stream_slots[slot].img); } @@ -2123,7 +2108,6 @@ void droiddeck_stream_set(int on) { (void)n; } -/* Any thread: hands over one decoded frame (acquires its own reference). */ void droiddeck_stream_frame(AHardwareBuffer *ahb) { if (!ahb || !atomic_load(&g_stream_on) || g_input_pipe[1] < 0) return; AHardwareBuffer_acquire(ahb); @@ -2131,7 +2115,7 @@ void droiddeck_stream_frame(AHardwareBuffer *ahb) { AHardwareBuffer *dropped = g_stream_pending; g_stream_pending = ahb; pthread_mutex_unlock(&g_stream_lock); - if (dropped) { AHardwareBuffer_release(dropped); return; } /* a wake-up is already queued */ + if (dropped) { AHardwareBuffer_release(dropped); return; } struct input_msg m = { 20, 0, 0, 0, 0 }; ssize_t n = write(g_input_pipe[1], &m, sizeof(m)); (void)n; @@ -2200,11 +2184,6 @@ static void render_scene(void) { hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen); } if (hdr_route == 3) { fx_on = 0; framegen = 0; } - /* A game the compositor cannot import (an AHardwareBuffer-only surface, or HDR frames it - * has no texture for) is shown on its own display layer and is not part of the scene, so - * composing would leave the game out of the picture: the pointer stays out instead, the - * same trade-off as HDR route 3. Both are decided before the pointer is appended, because - * the appended draws change the draw list they are matched against. */ struct surface *ahb_only = ahb_layer_only_candidate(w, h); struct surface *layer_only = ahb_only ? ahb_only : hdr_unimported_fullscreen(&dl, w, h); const int cursor_on = hdr_route == 3 || layer_only ? 0 : add_cursor(&dl, w); @@ -2216,9 +2195,6 @@ static void render_scene(void) { } else if (!layer_only) { cursor_layer_said = 0; } - /* With cardboard the scene is composed anyway (the pass needs the whole picture), so the - * pointer reaches the output without switching routes: the HDR composition path would drop - * the cardboard pass, which is 8-bit only. */ static int cursor_hdr_said; if (cursor_on && hdr_route == 0 && hs.n_hdr > 0 && !cardboard) { hdr_route = 2; @@ -2257,8 +2233,6 @@ static void render_scene(void) { } if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard && !cursor_on && !ahb_only) { - /* 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; @@ -2367,8 +2341,6 @@ static int on_fallback_timer(void *data) { return 0; } -/* A new frame is drawn when it arrives instead of at the next screen tick, at most once per refresh, - while frames go straight back to the game (no compositor limit), without streaming or frame generation. */ static int commit_renders_on(void) { return g_render_on_commit && g_release_unpaced && !atomic_load(&g_stream_on) && !vkp_framegen_active(); } @@ -2427,10 +2399,6 @@ static void on_vsync(int64_t frame_time_ns) { if (vkp_effects_sync()) tick_change = 1; if (vkp_cardboard_pose_changed()) tick_change = 1; if (tick_change) g_dirty = 1; - /* A frame that arrived after this refresh was already drawn is left for the next arrival to draw; - drawing it here would block that arrival too and keep every frame waiting for the tick. A client - that draws again only after its frame callback sends no such arrival: once a held frame is still - waiting at the next tick, the tick draws every frame for a second before holding one again. */ if (g_tick_held && g_dirty) g_hold_missed_ns = now; g_tick_held = 0; if (!tick_change && commit_renders_on() && g_dirty_since_ns && now - g_dirty_since_ns < g_refresh_ns && @@ -2665,15 +2633,11 @@ static int pointer_locked_for_cursor(void) { static int cursor_scene_positions(int scene_w, int source, int ptr_x, int hx, int out[2]) { out[0] = ptr_x - hx; out[1] = -1; if (source != CARDBOARD_SOURCE_SBS || scene_w <= 1) return 1; - /* The pointer decides which eye it is in; the hotspot only shifts the image drawn there, - * so a hotspot that crosses the seam cannot put the second copy in the wrong eye. */ const int half = scene_w / 2; out[1] = (ptr_x < half ? ptr_x + half : ptr_x - half) - hx; return 2; } -/* Crops one pointer copy to the eye it belongs to (eye < 0 crops nothing). Returns 0 when no - * part of the copy falls inside that eye, so the caller skips it. */ static int cursor_clip_to_eye(int scene_w, int eye, int x0, int w, int *dx, int *sx, int *dw) { int left = x0, right = x0 + w; if (eye >= 0 && scene_w > 1) { @@ -2689,7 +2653,6 @@ static int cursor_clip_to_eye(int scene_w, int eye, int x0, int w, int *dx, int return 1; } -/* Appends the guest pointer as the topmost draw of the scene. */ static int add_cursor(struct draw_list *dl, int scene_w) { if (!atomic_load(&g_cursor_composite) || !g_ptr_known || pointer_locked_for_cursor()) return 0; @@ -2740,9 +2703,6 @@ static int add_cursor(struct draw_list *dl, int scene_w) { if (!d) return 0; dl->d = d; dl->cap = cap; } - /* The cardboard pass samples each half of the scene as one eye, so a copy that crosses the - * seam would show up as a fragment at the opposite edge of the other eye: crop each copy to - * the eye it belongs to and drop it when nothing of it lands there. */ const int sbs = source == CARDBOARD_SOURCE_SBS && scene_w > 1; const int pointer_eye = ptr_x < scene_w / 2 ? 0 : 1; int appended = 0; @@ -2765,8 +2725,6 @@ static void sync_lock_notify(void) { if (locked == last) return; last = locked; droiddeck_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y); - /* The drawn pointer follows the lock state, so a game that stops presenting after it grabs - * the pointer would keep the last composited cursor on screen without a redraw here. */ if (atomic_load(&g_cursor_composite)) schedule_render(); } @@ -2849,8 +2807,6 @@ static void constraint_activate(struct constraint *k) { g_active_constraint = k; const double was_x = g_ptr_x, was_y = g_ptr_y; if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y); - /* Confining can move the pointer while the lock state stays the same, so sync_lock_notify() - * below stays quiet; the drawn pointer follows the position, so repaint here. */ if ((g_ptr_x != was_x || g_ptr_y != was_y) && atomic_load(&g_cursor_composite)) schedule_render(); constraint_origin(k, &ox, &oy); @@ -2935,8 +2891,6 @@ static void constraints_surface_commit(struct surface *s) { if (k->active && !k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y); } } - /* A position hint or a newly committed confine region can move the pointer while the lock - * state stays the same, so nothing else would repaint the composited cursor. */ if ((g_ptr_x != was_x || g_ptr_y != was_y) && atomic_load(&g_cursor_composite)) schedule_render(); } @@ -3147,10 +3101,6 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int uint32_t t = now_ms(); pointer_focus(target->resource, fx, fy); - // A button-only input must not replay a cached absolute position. A game - // may have warped its cursor since the last motion (before taking lock, - // or while releasing it), so that replay can become an unintended pan. - // Absolute touch events still carry their position, including on clicks. int rel_consumed = send_relative_motion(client, dx, dy); if (!(k && k->is_lock) && !(relative && rel_consumed) && (!relative || dx != 0 || dy != 0)) for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy); @@ -3182,9 +3132,6 @@ static void pointer_button(uint32_t button, int 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]; @@ -3679,8 +3626,6 @@ void droiddeck_wayland_send_scene_input(int type, int a, int b) { (void)n; } -/* Any thread: draw the pointer into the compositor's own output (the glasses - * surface owns the picture, so the phone's Compose overlay never reaches it). */ void droiddeck_wayland_set_cursor_composite(int on) { const int want = on ? 1 : 0; if (atomic_exchange(&g_cursor_composite_want, want) == want) return; @@ -3871,8 +3816,6 @@ 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(); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 9de2824d7..eceb79639 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -374,16 +374,11 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, mark_failed(kind); return 0; } - /* The ring and the engine's images are new on this frame. Dispatching into them now - * null-dereferenced Turnip (vkp_framegen_run, first Steam frame, 2560x1440 to the panel). - * Show this frame as it is; generation starts once the size has settled. */ if (size_changed || ring == 2) { FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h); return 0; } - /* 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); diff --git a/app/src/main/cpp/waylandcomp/src/gpu_busy.c b/app/src/main/cpp/waylandcomp/src/gpu_busy.c new file mode 100644 index 000000000..48d565c20 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/gpu_busy.c @@ -0,0 +1,89 @@ +#include "gpu_busy.h" + +#include +#include +#include + +#include "gpuload_ring.h" + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static struct gpuload_vk g_v; +static struct gpuload_ring g_ring; +static uint32_t g_family, g_bits; +static PFN_vkQueueSubmit g_submit; +static int g_was_on; +static _Atomic int g_on, g_lost, g_timing; +static _Atomic uint64_t g_busy_ns; + +static VkResult VKAPI_CALL timed_submit(VkQueue queue, uint32_t count, const VkSubmitInfo *infos, VkFence fence) { + if (!count || !atomic_load(&g_on) || atomic_load(&g_lost)) return g_submit(queue, count, infos, fence); + pthread_mutex_lock(&g_lock); + if (!atomic_load(&g_on) || atomic_load(&g_lost)) { + pthread_mutex_unlock(&g_lock); + return g_submit(queue, count, infos, fence); + } + if (g_ring.state == 0 && gpuload_ring_setup(&g_ring, &g_v, g_family, g_bits)) atomic_store(&g_timing, 1); + VkSubmitInfo *mine = NULL; + uint32_t used = 0; + if (g_ring.state == 1) { + uint64_t ns = gpuload_ring_harvest(&g_ring, &g_v); + if (g_was_on) atomic_fetch_add(&g_busy_ns, ns); // the first harvest after a pause holds work timed before it + g_was_on = 1; + mine = gpuload_ring_wrap(&g_ring, count, infos, &used); + } + VkResult r = g_submit(queue, count, mine ? mine : infos, fence); + if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&g_ring, used); + pthread_mutex_unlock(&g_lock); + free(mine); + return r; +} + +void vkp_gpu_busy_device(VkPhysicalDevice pd, VkDevice dev, uint32_t family) { + pthread_mutex_lock(&g_lock); + memset(&g_ring, 0, sizeof(g_ring)); + g_was_on = 0; + atomic_store(&g_timing, 0); + atomic_store(&g_lost, 0); + PFN_vkGetDeviceProcAddr gdpa = g_vk.GetDeviceProcAddr; + g_v = (struct gpuload_vk){ + .device = dev, + .create_pool = g_vk.CreateCommandPool, + .destroy_pool = (PFN_vkDestroyCommandPool)gdpa(dev, "vkDestroyCommandPool"), + .alloc_cmds = g_vk.AllocateCommandBuffers, + .begin = g_vk.BeginCommandBuffer, + .end = g_vk.EndCommandBuffer, + .cmd_reset = (PFN_vkCmdResetQueryPool)gdpa(dev, "vkCmdResetQueryPool"), + .cmd_timestamp = (PFN_vkCmdWriteTimestamp)gdpa(dev, "vkCmdWriteTimestamp"), + .create_queries = (PFN_vkCreateQueryPool)gdpa(dev, "vkCreateQueryPool"), + .destroy_queries = (PFN_vkDestroyQueryPool)gdpa(dev, "vkDestroyQueryPool"), + .results = (PFN_vkGetQueryPoolResults)gdpa(dev, "vkGetQueryPoolResults"), + }; + VkPhysicalDeviceProperties props; + g_vk.GetPhysicalDeviceProperties(pd, &props); + gpuload_vk_period(&g_v, props.limits.timestampPeriod); + VkQueueFamilyProperties families[16]; + uint32_t n = 16; + g_vk.GetPhysicalDeviceQueueFamilyProperties(pd, &n, families); + g_family = family; + g_bits = family < n ? families[family].timestampValidBits : 0; + // vk_loader_load_device has just put the driver's function back. + if (g_vk.QueueSubmit != timed_submit) g_submit = g_vk.QueueSubmit; + if (g_submit) g_vk.QueueSubmit = timed_submit; + pthread_mutex_unlock(&g_lock); +} + +void vkp_gpu_busy_device_lost(void) { + atomic_store(&g_lost, 1); + atomic_store(&g_timing, 0); +} + +JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeGpuBusyNs(JNIEnv *env, jclass cls) { + return atomic_load(&g_timing) ? (jlong)atomic_load(&g_busy_ns) : -1; +} + +JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTiming(JNIEnv *env, jclass cls, jboolean on) { + pthread_mutex_lock(&g_lock); + if (!on) g_was_on = 0; + atomic_store(&g_on, on ? 1 : 0); + pthread_mutex_unlock(&g_lock); +} diff --git a/app/src/main/cpp/waylandcomp/src/gpu_busy.h b/app/src/main/cpp/waylandcomp/src/gpu_busy.h new file mode 100644 index 000000000..a477d5236 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/gpu_busy.h @@ -0,0 +1,9 @@ +#ifndef GPU_BUSY_H +#define GPU_BUSY_H +#include "vk_loader.h" + +// Times the compositor's own GPU work for the HUD (tools/gpuload/src/gpuload_ring.h); call after the device and its +// functions are loaded. Off until WaylandCompositor.nativeSetGpuTiming(true). +void vkp_gpu_busy_device(VkPhysicalDevice pd, VkDevice dev, uint32_t family); +void vkp_gpu_busy_device_lost(void); +#endif diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c index 0f8e1f61b..8ce226922 100644 --- a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c +++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c @@ -103,7 +103,6 @@ static bool prepare_pin(void) { (off_t)pin.memory << 12); if (commands == MAP_FAILED) return false; pin.commands = commands; - // A3xx/A4xx use type-3 CP_NOP; newer cores use type-7 with zero odd-parity bits. uint32_t *words = commands; words[0] = pin.nop; words[1] = 0; @@ -124,7 +123,6 @@ static void *hold_pin(void *unused) { if (errno == EINTR || errno == ETIMEDOUT) continue; break; } - // Constraints expire after idle_timer (normally 80 ms); renew with a NOP, keeping devfreq alive. usleep(25000); if (atomic_load(&running) && !submit_pin()) break; } diff --git a/app/src/main/cpp/waylandcomp/src/headpose_shm.h b/app/src/main/cpp/waylandcomp/src/headpose_shm.h index 824232b43..22088dc6b 100644 --- a/app/src/main/cpp/waylandcomp/src/headpose_shm.h +++ b/app/src/main/cpp/waylandcomp/src/headpose_shm.h @@ -24,7 +24,6 @@ struct droiddeck_headpose { float eye_aspect; uint32_t eye_w, eye_h; float display_hz; - // Uses a formerly reserved word; older publishers leave this zero. uint32_t render_height; uint32_t reserved[10]; }; diff --git a/app/src/main/cpp/waylandcomp/src/hudframes.c b/app/src/main/cpp/waylandcomp/src/hudframes.c index 5a6fc4e99..ffbb657a5 100644 --- a/app/src/main/cpp/waylandcomp/src/hudframes.c +++ b/app/src/main/cpp/waylandcomp/src/hudframes.c @@ -8,7 +8,6 @@ #include "hudframes.h" -/* Layout written by gamescope patch 0125 (tools/gamescope/patches). */ #define HUD_FRAMES_MAGIC 0x46484444u #define HUD_FRAMES_HEADER 128u #define HUD_FRAMES_ENTRY 16u @@ -69,7 +68,6 @@ int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, i memcpy(&frametimes[n], e, 8); memcpy(×[n], e + 8, 8); } - /* The writer may have lapped the ring while we copied: drop what it overwrote. */ uint64_t after = load64(h->map + 16); uint64_t valid = after > cap ? after - cap : 0; if (from < valid) { diff --git a/app/src/main/cpp/waylandcomp/src/hudframes.h b/app/src/main/cpp/waylandcomp/src/hudframes.h index d0b1ff5bd..518f7be52 100644 --- a/app/src/main/cpp/waylandcomp/src/hudframes.h +++ b/app/src/main/cpp/waylandcomp/src/hudframes.h @@ -8,9 +8,7 @@ struct hud_frames; struct hud_frames *hud_frames_open(const char *path); void hud_frames_close(struct hud_frames *h); -/* New frames since the last poll, oldest first; -1 while the file holds no valid ring. */ int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max); -/* info: pid, output width, output height, refresh mHz, Steam focused. */ int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len); #endif diff --git a/app/src/main/cpp/waylandcomp/src/quest_device.h b/app/src/main/cpp/waylandcomp/src/quest_device.h index ee9c37b10..bcdcee239 100644 --- a/app/src/main/cpp/waylandcomp/src/quest_device.h +++ b/app/src/main/cpp/waylandcomp/src/quest_device.h @@ -3,7 +3,6 @@ #include #include -/* 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") || diff --git a/app/src/main/cpp/waylandcomp/src/sc_layer.c b/app/src/main/cpp/waylandcomp/src/sc_layer.c index 299d2d04c..c760d91c2 100644 --- a/app/src/main/cpp/waylandcomp/src/sc_layer.c +++ b/app/src/main/cpp/waylandcomp/src/sc_layer.c @@ -291,7 +291,7 @@ int sc_layer_base_black(int width, int height) { 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.setZOrder(tx, g_base_sc, 0); api.setGeometry(tx, g_base_sc, &src, &dst, 0); api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW); api.txApply(tx); api.txDelete(tx); @@ -1026,7 +1026,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 */ + base_hide(1); if (api.state != 1) return; layers_init(); for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]); diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 228159ac7..895e1f6b4 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -7,6 +7,7 @@ #include "framegen_bridge.h" #include "hdr_compose.h" #include "blend_pass.h" +#include "gpu_busy.h" #include "droiddeck_color.h" #include "quest_device.h" #include @@ -70,8 +71,6 @@ 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}; @@ -480,7 +479,6 @@ static int dev_init(void) { g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, NULL); VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*exts)); g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, exts); - /* Only what the driver has: the system driver lacks the dma-buf ones, which the cloud player never needs. */ for (unsigned i = 0; i < 4; i++) { if (has_ext(exts, ne, import_exts[i])) dev_exts[n_dev_exts++] = import_exts[i]; else LOGE("present: driver MISSING %s (dmabuf import will fail)", import_exts[i]); @@ -492,7 +490,6 @@ static int dev_init(void) { } const int want_sem_fd = has_ext(exts, ne, VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME); if (want_sem_fd) dev_exts[n_dev_exts++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME; - /* The cloud player's decoded frames arrive as AHardwareBuffers (vkp_image_from_ahb). */ g_ahb_import = has_ext(exts, ne, VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME) && has_ext(exts, ne, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME); if (g_ahb_import) { @@ -521,6 +518,7 @@ static int dev_init(void) { } vk_loader_load_device(g_dev); g_vk.GetDeviceQueue(g_dev, g_qfam, 0, &g_queue); + vkp_gpu_busy_device(g_pd, g_dev, g_qfam); if (want_sem_fd) { g_import_sem_fd = (PFN_vkImportSemaphoreFdKHR)g_vk.GetDeviceProcAddr(g_dev, "vkImportSemaphoreFdKHR"); VkSemaphoreCreateInfo wsci = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; @@ -1411,6 +1409,7 @@ static void device_lost(const char *where) { g_dev_state = -2; g_base_black = 0; vkp_framegen_device_lost(); + vkp_gpu_busy_device_lost(); droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; " "restart the session", where); for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]); @@ -1798,9 +1797,6 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i VkImageLayout result_layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; if (fx) { int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f); - /* Frame generation samples every output pixel. Upscaling first made it interpolate - * the whole panel (3200x1440 on a 1440p phone) and rebuild on Steam's first frame. - * Grade at the game's size instead; the screen blit still scales to the panel. */ if (fg) { mapped_w = scene_w; mapped_h = scene_h; } vkp_effects_set_formats(scene_fmt, scene_fmt); result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh); @@ -1900,9 +1896,6 @@ 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) { @@ -1914,9 +1907,6 @@ int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) { } 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); @@ -1953,8 +1943,6 @@ 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}; diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index adc2372de..5d69098a6 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -698,7 +698,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv * jclass error = (*env)->FindClass(env, "java/lang/IllegalStateException"); if (error) (*env)->ThrowNew(env, error, strerror(saved_errno)); } - // Clear a legacy global pin when recovering its marker after an app upgrade or crash. if (on != JNI_TRUE) adrenotools_set_turbo(false); } @@ -725,7 +724,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardSpatialP vkp_cardboard_set_spatial_pose(m, p, live ? 1 : 0); } -/* The Xbox Cloud Gaming native player: its decoded frames are the whole scene (no Wayland clients). */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamMode(JNIEnv *env, jclass clazz, jboolean on) { droiddeck_stream_set(on ? 1 : 0); diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 6700bd5d9..01657d289 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -19,7 +19,6 @@ class App : Application() { com.droiddeck.launcher.ui.Themes.loadGradient(this) com.droiddeck.launcher.ui.BuildWatermark.install(this) HomeApp.trackRecents(this) - // Helper processes must not initialize the main USB provider or guest graphics context. val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName() else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager) .runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName @@ -32,12 +31,11 @@ class App : Application() { return } if (process == "$packageName:xcloudnative") { - // The native cloud player: the stores log and nothing that touches the Linux session. com.droiddeck.launcher.stores.StoresState.init(this) return } if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" || - process == "$packageName:shortcuts_sync") return + process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return CrashHandler.install(this) com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this) com.droiddeck.launcher.session.QuestExperimental.initialize(this) @@ -58,10 +56,10 @@ class App : Application() { com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend) + com.droiddeck.launcher.sync.Syncthing.init(this) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend) } - /** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */ private fun prefetchAddonPacks() { val app = this Thread({ diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 162f897ef..c1688d7e0 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -79,6 +79,7 @@ import com.droiddeck.launcher.session.SessionArtifacts import com.droiddeck.launcher.session.SessionPhase import com.droiddeck.launcher.session.SessionState import com.droiddeck.launcher.session.GameStorage +import com.droiddeck.launcher.session.SteamLibraries import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.input.SecondScreenDisplays @@ -480,6 +481,7 @@ class FrontEndActivity : ComponentActivity() { private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var steamDownloadsInBackground by mutableStateOf(false) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) + private var backHold by mutableStateOf(true) private var backActionsInverted by mutableStateOf(false) private var gameStorage by mutableStateOf("") private var slowUsb by mutableStateOf(null) @@ -497,6 +499,20 @@ class FrontEndActivity : ComponentActivity() { private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) } } + private var steamLibraries by mutableStateOf>(emptyList()) + private val pickSteamLibrary = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + val path = if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data) else null + if (path != null) Thread({ + val result = SteamLibraries.add(this, path) + ui.post { + steamLibraries = SteamLibraries.list(this) + refresh() + if (result is SteamLibraries.AddResult.Refused) { + android.widget.Toast.makeText(this, getString(R.string.main_storage_not_usable, result.reason), android.widget.Toast.LENGTH_LONG).show() + } + } + }, "steam-library-add").start() + } private var romsDir by mutableStateOf(null) private var steamGames by mutableStateOf>(emptyList()) private var steamGamesX64 by mutableStateOf(false) @@ -720,7 +736,6 @@ class FrontEndActivity : ComponentActivity() { LauncherIcon.set(this, style) launcherIcon = LauncherIcon.current(this) if (launcherIcon == before) return false - // Android finishes activities started through the alias that was just disabled. startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) .setComponent(LauncherIcon.activity(this)) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) @@ -1810,7 +1825,6 @@ class FrontEndActivity : ComponentActivity() { private fun refreshSecondScreenDisplays() { if (!::displayManager.isInitialized) return - // 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 dualScreenMode = SessionPrefs.dualScreenMode(this) secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId) @@ -2124,6 +2138,7 @@ class FrontEndActivity : ComponentActivity() { unfoldedControls = if (mode == SessionService.MODE_STEAM) unfoldedControls else null, unfoldedControlsWithController = unfoldedControlsWithController, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, + backHold = backHold, backActionsInverted = backActionsInverted, directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null, picturePosition = if (mode == SessionService.MODE_STEAM) picturePosition else null, @@ -2131,6 +2146,7 @@ class FrontEndActivity : ComponentActivity() { mic = if (mode == SessionService.MODE_STEAM) mic else null, gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null, storageOptions = storageOptions, + steamLibraries = steamLibraries, storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics, fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null, forceSsbs = forceSsbs, @@ -2176,6 +2192,7 @@ class FrontEndActivity : ComponentActivity() { SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, + onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on }, onBackActionsInverted = { inverted -> SessionPrefs.setBackActionsInverted(this, inverted) backActionsInverted = inverted @@ -2194,6 +2211,19 @@ class FrontEndActivity : ComponentActivity() { onPickGameStorageFolder = { pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null })) }, + onAddSteamLibrary = { + pickSteamLibrary.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.libraries_pick), null)) + }, + onRenameSteamLibrary = { id, label -> + SteamLibraries.rename(this, id, label) + steamLibraries = SteamLibraries.list(this) + refresh() + }, + onRemoveSteamLibrary = { id -> + SteamLibraries.remove(this, id) + steamLibraries = SteamLibraries.list(this) + refresh() + }, onStorageDiagnostics = { on -> SessionPrefs.setStorageDiagnosticsEnabled(this, on) storageDiagnostics = on @@ -2420,6 +2450,7 @@ class FrontEndActivity : ComponentActivity() { suspendPolicy = SessionPrefs.suspendPolicy(this, mode) steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) oscMode = SessionPrefs.oscMode(this) + backHold = SessionPrefs.backHold(this) backActionsInverted = SessionPrefs.backActionsInverted(this) directAudio = SessionPrefs.directAudio(this) picturePosition = SessionPrefs.picturePosition(this) @@ -2427,6 +2458,7 @@ class FrontEndActivity : ComponentActivity() { mic = SessionPrefs.micEnabled(this) refreshWifiDiscovery() gameStorage = SessionPrefs.gameStorage(this) + steamLibraries = SteamLibraries.list(this) storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this) settingsMode = mode Thread({ @@ -2504,7 +2536,6 @@ class FrontEndActivity : ComponentActivity() { !SessionPaths.currentSessionLogsRetained(this) private fun refresh() { - // Until the scan finishes, the other client's games would launch in this one. val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64" if (listX64 != steamGamesX64) { steamGamesX64 = listX64 diff --git a/app/src/main/java/com/droiddeck/launcher/HomePins.kt b/app/src/main/java/com/droiddeck/launcher/HomePins.kt index 1040995e8..38acb1813 100644 --- a/app/src/main/java/com/droiddeck/launcher/HomePins.kt +++ b/app/src/main/java/com/droiddeck/launcher/HomePins.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -/** Shortcuts other apps asked to put on the home screen. The system keeps the pinned set per launcher. */ object HomePins { class Pinned(val info: ShortcutInfo, val icon: Bitmap?, val appLabel: String, val userSerial: Long) { val key get() = "pin:${info.`package`}/${info.id}" + if (userSerial != 0L) "#$userSerial" else "" diff --git a/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt b/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt index 8f79f4354..886f5f0e8 100644 --- a/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt @@ -197,7 +197,6 @@ object HomeWidgets { if (pendingId == AppWidgetManager.INVALID_APPWIDGET_ID) add(activity, provider.info, null) } - /** Places a widget another app asked for; its own app has already configured it. */ fun addPinned(activity: Activity, request: LauncherApps.PinItemRequest): Boolean { if (pendingId != AppWidgetManager.INVALID_APPWIDGET_ID) return false val info = request.getAppWidgetProviderInfo(activity) ?: return false diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt index 8e8efc80c..5251b50da 100644 --- a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt @@ -20,7 +20,6 @@ internal class ProtonMenu( private val ui: Handler, private val idle: () -> Boolean = { true }, ) { - // Install workers and their results outlive the activity that starts them. private object Operation { val rows = mutableStateOf>(emptyList()) val busyId = mutableStateOf(null) diff --git a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt index 28270e195..98e5a0b22 100644 --- a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt @@ -71,7 +71,6 @@ class QuestXrActivity : NativeActivity() { } } 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 diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index bc0452256..1aa82d33c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -67,7 +67,6 @@ import com.droiddeck.launcher.gpu.ScreenEffectLooks import com.droiddeck.launcher.gpu.ScreenEffects import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.gpu.TurnipDriver -import com.droiddeck.launcher.input.EvdevKeys import com.droiddeck.launcher.input.HomeGuideInput import com.droiddeck.launcher.input.InputSpace import com.droiddeck.launcher.input.KeyboardHost @@ -266,7 +265,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (locked) openEvdevMouse() else closeEvdevMouse() padMouseCheckedAt = 0L if (::padMouse.isInitialized) padMouseMode() - // Touchpad deltas are measured from the middle while locked (moveLockedPointer). if (locked && ::touchpad.isInitialized) { drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) } cursorVisible = false @@ -310,6 +308,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onTap = { performBackAction(double = it) }, doubleTapMs = { backDoubleTapMs.toLong() }, onCancel = { padBridge?.cancelBackQam() }, + holds = { backHold }, ) private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN private var drawerDirectionDownTime = 0L @@ -388,6 +387,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val drawerGameProfileRefreshes = LatestRequestCoordinator() private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER) private var drawerControllerActive by mutableStateOf(false) + private var backHold by mutableStateOf(true) private var backActionsInverted by mutableStateOf(false) private var backDoubleTapMs by mutableStateOf(500) private var lowLatency by mutableStateOf(true) @@ -512,8 +512,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } - // A higher refresh rate allowed in Android's settings does not change the display while this window - // still asks for a lower mode, so the mode is chosen again when the setting changes. private val refreshSettingObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { applyOutputPacing() @@ -570,7 +568,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { finish() return } - // A cancelled install keeps its thread until the download stops; a new launch would reuse its stop flag and close itself. if (savedInstanceState == null && SessionState.installing != null && !SessionState.running) { runtimeRemovalBlocked = true android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show() @@ -581,7 +578,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM))) - // begin() moves the phase on, so the block below never runs for a new session; reset per-session flags here. SessionState.secondScreenAutoOpened = false SessionState.secondScreenProvisional = false } @@ -605,7 +601,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { fpsLimit = 0 } applyOutputPacing() - if (!SessionState.running) SessionState.followScreen = followChoice() + if (!SessionState.running) { + SessionState.followScreen = followChoice() + SessionState.followScale = followScale() + } applyFollowOrientation(resources.configuration) goFullscreen() volumeControlStream = android.media.AudioManager.STREAM_MUSIC @@ -714,8 +713,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput, ) refreshGestureConfig() - // A real touch target lets FrameLayout split fingers between the - // game surface and on-screen buttons, in either finger-down order. root.isMotionEventSplittingEnabled = true surfaceView.setOnTouchListener { _, event -> if (!event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) { @@ -921,7 +918,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)) }, ) - // Also runs when a second screen appears, so Automatic applies then with the running game known. LaunchedEffect(AgentGuest.focusRevision, secondScreenDisplays.isNotEmpty()) { val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" } @@ -1115,7 +1111,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements) } com.droiddeck.launcher.ui.AchievementNotificationOverlay() - // Auto and Native resume on their own as the session comes back; only a failed resume needs the button. val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE LaunchedEffect(SessionState.manualPause, SessionState.suspended) { if (!SessionState.manualPause && !SessionState.suspended) pauseAnimated = false @@ -1193,6 +1188,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { fexPreset = fexPreset, secondScreenMode = secondScreenMode, dashboardAvailable = bottomPages().isNotEmpty(), + desktopAvailable = com.droiddeck.launcher.session.BottomDesktop.available(), secondScreenDisplays = secondScreenDisplays, selectedSecondScreenDisplay = selectedSecondScreenDisplay, onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, @@ -1264,6 +1260,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id)) padType = id }, + backHold = backHold, + onBackHold = { on -> + SessionPrefs.setBackHold(this@SessionActivity, on) + backHold = on + }, backActionsInverted = backActionsInverted, backDoubleTapMs = backDoubleTapMs, onBackDoubleTapMs = { ms -> @@ -1470,7 +1471,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { )) } - /** The SteamVR driver switches pad sticks between hands and game on both stick clicks together. */ private fun switchVrSticks() { val chord = { down: Boolean -> padBridge?.applyTouch { state -> state.press(com.droiddeck.launcher.input.PadState.L3, down) @@ -1873,6 +1873,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) oscMode = SessionPrefs.oscMode(this) + backHold = SessionPrefs.backHold(this) backActionsInverted = SessionPrefs.backActionsInverted(this) physicalShortcuts.bindings = PhysicalShortcuts.read(this) backDoubleTapMs = SessionPrefs.backDoubleTapMs(this) @@ -2160,7 +2161,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun windowOutputSize(width: Int, height: Int): Pair? { if (isInPictureInPictureMode || width <= 0 || height <= 0) return null return if (SessionState.followScreen) - com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, controlsBelowWanted()) + com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, controlsBelowWanted(), SessionState.followScale) else if (glassesScreen == null) outputSize() else null } @@ -2175,10 +2176,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { WaylandCompositor.nativeResizeOutput(size.first, size.second) } + private fun followScale(): Float { + val mode = displayMode() + val choice = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) + return com.droiddeck.launcher.session.SessionDisplay.followScale(choice) ?: 1f + } + private fun followChoice(): Boolean { val mode = displayMode() - return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) == - com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN + return com.droiddeck.launcher.session.SessionDisplay.isFollow( + SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))) } private fun applyFollowOrientation(config: android.content.res.Configuration) { @@ -2198,8 +2205,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun surfaceDestroyed(holder: SurfaceHolder) { - // Hiding the phone surface after a handoff delivers a late destruction - // callback. It must not reset the new output's size or attachment. if (holder !== outputHolder()) return cancelTouchGestures() surfaceW = 0 @@ -2596,27 +2601,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) { val down = event.action == KeyEvent.ACTION_DOWN if (down && event.repeatCount > 0) return true - if (down || event.action == KeyEvent.ACTION_UP) { - var evdev = EvdevKeys.fromKeyCode(event.keyCode) - val ch = event.unicodeChar - val plain = if (ch > 0) EvdevKeys.unshiftedChar(ch) else 0 - if (evdev <= 0 && ch > 0) { - val code = EvdevKeys.fromKeyCode(EvdevKeys.keycodeForChar(if (plain != 0) plain else ch)) - if (code > 0) evdev = code - } - val shiftEvdev = if (evdev > 0 && plain != 0 && event.deviceId <= 0) 42 else 0 - if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode - if (evdev > 0) { - if (down) { - if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true) - sendGuestKey(evdev, true) - } else { - sendGuestKey(evdev, false) - if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false) - } - return true - } - } + if (com.droiddeck.launcher.input.GuestKeys.send(event, ::sendGuestKey)) return true } return super.dispatchKeyEvent(event) } @@ -2640,10 +2625,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (PadBridge.isControllerMotionEvent(event) && padMouseMode() && padMouse.onMotion(event)) return true if (padBridge?.onMotionEvent(event) == true) return true if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen) { - /* The app's own controls get the pointer first. With the picture on the glasses the - * phone shows its panel, and hovering or clicking a control there must reach it - * instead of moving and clicking the guest pointer on the glasses. Whatever the UI - * leaves alone — the rest of the screen — still drives the guest pointer. */ if (appUiOwnsPointer()) { if (super.dispatchGenericMotionEvent(event)) return true if (onMouse(event)) return true @@ -2657,7 +2638,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun dispatchTrackballEvent(event: MotionEvent): Boolean = onCapturedPointer(event) || super.dispatchTrackballEvent(event) - /** Whether one of the on-screen control layers takes a finger going down at this screen point. */ private fun controlTakesTouchAt(rawX: Float, rawY: Float): Boolean { val at = IntArray(2) fun takes(view: View, hit: (Float, Float) -> Boolean): Boolean { @@ -2835,9 +2815,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun prepareRelativePan() { if (pointerLocked) return val rect = drawnRect() ?: return - // Gamescope retains the last absolute position while a game can warp - // its own cursor to the center. Its first unlocked motion must start - // from that same origin, before the game switches to pointer lock. moveTo(rect.centerX(), rect.centerY()) } override fun key(code: Int, down: Boolean) { @@ -3066,7 +3043,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (secondScreenDisplays.none { it.id == target.displayId }) return SessionState.secondScreenAutoOpened = true selectedSecondScreenDisplay = target.displayId - selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty())) + selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty(), + com.droiddeck.launcher.session.BottomDesktop.available())) dualControlsProvisional = ::loading.isInitialized && loading.visible } @@ -3074,7 +3052,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!dualControlsProvisional || pipUi || !started || (::loading.isInitialized && loading.visible)) return dualControlsProvisional = false if (secondScreenMode == SecondScreenMode.NONE) return - val wanted = SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty()) + val wanted = SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty(), + com.droiddeck.launcher.session.BottomDesktop.available()) if (wanted != secondScreenMode) selectSecondScreenMode(wanted) else secondScreenPresentation?.retryTerminal() } @@ -3152,12 +3131,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onPage = { com.droiddeck.launcher.ui.SessionBottomTab.pick(SessionState.sessionId, it) }, onClose = { selectSecondScreenMode(SecondScreenMode.NONE) }, onMenu = if (dockDrawer()) ({ drawerOpen = true }) else null, + strip = { pageId -> + com.droiddeck.launcher.ui.SessionBottomStrip(pageId) { if (::hud.isInitialized) hud.liveFps else null } + }, ) } }), ) - /** Pages for the bottom-screen dashboard; the dashboard is offered only while at least one exists. */ private fun bottomPages(): List = sessionBottomPages(BottomPageActions( takeScreenshot = ::captureScreenshot, fps = { if (::hud.isInitialized) hud.liveFps else null }, @@ -3165,9 +3146,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { runningGame = { gestureGame }, )) - // A settings page opened on the bottom screen sits under the dashboard window, so the dashboard hides - // until the setting is on, or for at most SETTINGS_WATCH_CHECKS seconds when it is left off (another - // app's page closing cannot be seen); choosing Dashboard in the menu again shows it sooner. private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) { val presentation = secondScreenPresentation ?: return val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId) @@ -3179,7 +3157,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!opened) return presentation.hide() var checks = 0 - // Show throws if the display went away while the settings page was open, as in showSecondScreen. fun showAgain() { try { presentation.show() @@ -3200,9 +3177,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, SETTINGS_WATCH_MS) } - /** Copies the game frame (no overlay) at the size it is shown and saves it to Pictures/DroidDeck. */ private fun captureScreenshot(onDone: (Boolean) -> Unit) { - // With the glasses as the output the game is drawn on their surface; the phone's surface is hidden then. val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (::surfaceView.isInitialized) surfaceView else null val width = source?.width ?: 0 val height = source?.height ?: 0 @@ -3644,23 +3619,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (want) view.requestPointerCapture() else view.releasePointerCapture() } - /** Null until the first application, so the initial state is applied rather than skipped. */ private var platformPointerShown: Boolean? = null - /** - * The app's own UI is what the mouse is over, so it needs a pointer of its own: the drawer, - * keyboard, achievements and PiP controls, and — once the glasses own the picture — the whole - * phone, which then shows [GlassesPhonePanel] instead of the game. - */ private fun appUiOwnsPointer(): Boolean = drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive - /** - * Over the game neither this app nor Android draws a pointer: the compositor's cursor is the - * only one (Compose's on the phone, inside the picture on the glasses). Over the app's own UI - * the game gets no pointer events at all, so without this nothing visible follows the mouse - * while buttons still highlight and click. - */ private fun applyPlatformPointer(shown: Boolean) { if (!::sessionRoot.isInitialized || !::surfaceView.isInitialized) return if (shown == platformPointerShown) return @@ -3881,7 +3844,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) } - // The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame. private fun capturePausedFrame(manual: Boolean = false) { if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return if (SessionState.suspended && pausedFrame != null) return @@ -3926,8 +3888,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionService.pause(this) } - // Sessions that froze from picture-in-picture, or before onPause could copy a frame, have none; the - // compositor shows the frozen frame again once its output is back, so copy it from there. private fun requestPausedFrame() { if (pausedFrame != null) return pausedFrameWanted = true @@ -4164,8 +4124,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { Glasses.cancelSteamVrStart() } if (glasses.present) { - // Optional stereo starts only after RUNNING is observed. READY can - // arrive repeatedly while the launch worker is still starting VR. if (action == "install") steamVrStatus = SteamVr.Status.INSTALLING runSteamVrAction(action, ::currentRequest) { val ok = SteamVr.run(this, action) @@ -4358,8 +4316,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (holder !== outputHolder()) return glassesOutputActive = glassesScreen?.holder === holder if (CompositorHost.isStarted) WaylandCompositor.nativeSetCursorComposite(glassesOutputActive) - // Keep the phone's layout for touch/controller coordinates, but remove - // its SurfaceView so it cannot retain the last game frame under the hint. surfaceView.visibility = if (glassesOutputActive) View.INVISIBLE else View.VISIBLE Log.i(TAG, "compositor output attached: " + if (glassesOutputActive) "glasses display ${glassesScreen?.display?.displayId}" else "phone") @@ -4387,7 +4343,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val display = com.droiddeck.launcher.session.SessionDisplay val choice = SessionPrefs.resolutionChoice(this, mode, panel) val size = display.resolveChoice(panel, choice) - val fixed = choice != display.MATCH_SCREEN && choice != display.FOLLOW_SCREEN && display.presetHeight(choice) == null + val fixed = choice != display.MATCH_SCREEN && !display.isFollow(choice) && display.presetHeight(choice) == null return if (fixed) display.resolve(panel, minOf(size.first, size.second), SessionPrefs.SHAPE_AUTO) else size } @@ -4438,8 +4394,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (target != null) { val screen = GlassesScreen(this, target, Glasses.outputHz(), this, Glasses.outputResolution()) - // show() can create the surface synchronously. Select its holder - // first, otherwise surfaceCreated() rejects the glasses callback. glassesScreen = screen applyOutputPacing() surfaceW = 0 diff --git a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java index f8f1b2a3e..8d9ddcb1f 100644 --- a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java +++ b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java @@ -118,7 +118,6 @@ public class DirectAudioRelayComponent extends SessionPart { } } - // Android keeps the input stream's wake lock while a frozen helper still has it started. public boolean awaitMicReleased(long timeoutMs) { long deadline = System.currentTimeMillis() + timeoutMs; synchronized (micLock) { diff --git a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt index cc6a1007a..383dd98bb 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt @@ -13,12 +13,6 @@ import kotlinx.coroutines.launch import java.io.File import java.io.FileInputStream -/** - * A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to - * DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there - * yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build - * was made with install, whichever host serves them. - */ internal class AddonPack( val name: String, private val sha256: String, val size: Long, private val required: List, private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD, @@ -43,13 +37,11 @@ internal class AddonPack( ) private fun root(context: Context) = File(context.filesDir, "addons/$name") - /** App-private and written only after the archive matched the pinned digest. */ fun dir(context: Context) = File(root(context), version(context)) fun file(context: Context, relative: String) = File(dir(context), relative) fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile - // A build made outside CI pins no packs, so it runs the newest one a CI build already verified and installed. private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty() private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty() @@ -66,7 +58,6 @@ internal class AddonPack( .also { mutableState.value = it } } - /** Starts the download in the background unless the pack is already present or on its way. */ fun ensure(context: Context, onReady: (Context) -> Unit = {}) { val app = context.applicationContext synchronized(lock) { @@ -81,7 +72,6 @@ internal class AddonPack( } } - /** Blocks a worker thread until the pack is installed, starting the download if needed. */ @JvmOverloads fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean { if (installed(context)) return true @@ -127,7 +117,6 @@ internal class AddonPack( return true } - // A local build pins the packs it compiled, which no release hosts, so it ships them as assets. internal fun bundled(context: Context, archive: File): Boolean { val copied = runCatching { bundle(context).use { input -> archive.outputStream().use { input.copyTo(it) } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 295604411..309716d3f 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -42,7 +42,6 @@ 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" diff --git a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt index b11e3113a..d71f45120 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt @@ -126,11 +126,9 @@ object DownloadSource { 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 { - // 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) @@ -184,7 +182,6 @@ object DownloadSource { prefs(context)?.edit()?.putString(KEY_PROXY, normalizeProxy(value))?.apply() } - /** An HTTP proxy every Downloader connection uses, or null when none is configured. */ fun httpProxy(): Proxy? = proxyOf(prefs()?.getString(KEY_PROXY, "") ?: "") internal fun proxyOf(value: String): Proxy? { diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java index 31e8f45f5..19cec5acb 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -91,7 +91,6 @@ public final class Downloader { if (isCancelled(cancelled)) return null; if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i)); Fetch fetch = fetch(sources.get(i), cancelled); - // A cancelled fetch says nothing about the source, so it must not count as a failure. if (isCancelled(cancelled)) return null; if (fetch.body != null) { DownloadSource.INSTANCE.succeeded(sources.get(i)); @@ -103,7 +102,6 @@ public final class Downloader { 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; } @@ -113,13 +111,11 @@ public final class Downloader { 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 progress) { return downloadFile(url, destination, resume, expectedSha256, null, progress); } - /** {@code cancelled} is polled while bytes arrive; a cancel stops at once and leaves the partial file for the caller. */ public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256, java.util.function.BooleanSupplier cancelled, java.util.function.Consumer progress) { @@ -128,13 +124,11 @@ public final class Downloader { return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, cancelled, progress); } - /** A third-party mirror may only serve bytes that can be checked against a digest from a direct source. */ static List sources(String url, String expectedSha256) { return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url); } - // Package-private for the local multi-source HTTP test. static boolean downloadCandidates(String original, List sources, File destination, boolean resume, String expectedSha256, java.util.function.Consumer progress) { return downloadCandidates(original, sources, destination, resume, expectedSha256, null, progress); @@ -149,7 +143,6 @@ public final class Downloader { if (isCancelled(cancelled)) return false; if (i > 0) Log.i(TAG, "retrying " + original + " via " + source); Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, cancelled, progress); - // A cancel is not a source failure: no other mirror, and no DownloadSource.failed(). if (outcome == Outcome.CANCELLED) return false; if (outcome == Outcome.DONE) { if (expectedSha256 != null) { @@ -192,7 +185,6 @@ public final class Downloader { 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); @@ -213,7 +205,6 @@ public final class Downloader { 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; @@ -239,7 +230,7 @@ public final class Downloader { 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. + marker.delete(); Log.w(TAG, "cannot persist download validator", e); } } @@ -252,7 +243,6 @@ public final class Downloader { long have = resume && destination.isFile() ? destination.length() : 0; try { 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; } @@ -286,7 +276,6 @@ public final class Downloader { return attempt(url, destination, false, identity, expectedSha256, cancelled, progress); } if (!appending && have > 0) { - // If-Range may legitimately return full 200 when representation changed. if (!discardPartial(destination, identity)) return Outcome.RETRY; have = 0; } @@ -333,14 +322,12 @@ public final class Downloader { } } - /** 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); @@ -390,7 +377,6 @@ public final class Downloader { } private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException { - // An explicit Proxy routes the connection through the app setting instead of the system-wide proxy. Proxy proxy = DownloadSource.INSTANCE.httpProxy(); HttpURLConnection connection = (HttpURLConnection) (proxy != null ? new URL(url).openConnection(proxy) : new URL(url).openConnection()); diff --git a/app/src/main/java/com/droiddeck/launcher/core/GlobalSettings.kt b/app/src/main/java/com/droiddeck/launcher/core/GlobalSettings.kt index 8cbf9bf39..3e4bb5305 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GlobalSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GlobalSettings.kt @@ -5,7 +5,6 @@ import android.net.Uri import android.provider.Settings object GlobalSettings { - // Settings.Global caches values in-process and misses deletions made over adb; ask the provider directly. fun read(context: Context, name: String): String? { val uri = Uri.withAppendedPath(Settings.Global.CONTENT_URI, name) context.contentResolver.query(uri, arrayOf(Settings.NameValueTable.VALUE), null, null, null)?.use { cursor -> @@ -19,6 +18,5 @@ object GlobalSettings { fun developerOptionsOn(context: Context): Boolean = isOn(context, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED) - // Settings.Global.ADB_WIFI_ENABLED is hidden; the key is stable since Android 11. fun wirelessDebuggingOn(context: Context): Boolean = isOn(context, "adb_wifi_enabled") } diff --git a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt index f579adff0..3230e8ea4 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt @@ -11,7 +11,6 @@ object LogRedactor { size > 32 } - // Android matchers own native state, so reuse them instead of creating one for every log line. private val matchers = object : ThreadLocal() { override fun initialValue(): MatcherCache = MatcherCache() } @@ -145,7 +144,6 @@ object LogRedactor { val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } } Learned(Regex("(?i)(?, - modifier: Modifier = Modifier, - links: List = emptyList(), -) { - Column(modifier = modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items.forEach { item -> - TopTabCell( - icon = item.icon, - label = item.label, - selected = item.selected, - onClick = item.onClick, - modifier = Modifier.weight(1f), - ) - } - if (links.isNotEmpty()) { - Box( - modifier = Modifier - .padding(horizontal = 2.dp) - .width(1.dp) - .height(30.dp) - .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)), - ) - links.forEach { link -> - TopTabCell(link.icon, link.label, selected = false, onClick = link.onClick, isTab = false, modifier = Modifier.weight(1f)) - } - } - } - HorizontalDivider() - } -} - -@Composable -private fun TopTabCell( - icon: ImageVector, - label: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - isTab: Boolean = true, -) { - val tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .clip(RoundedCornerShape(9.dp)) - .background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) else Color.Transparent) - .focusGlide(RoundedCornerShape(9.dp)) - .then( - if (isTab) { - Modifier.selectable(selected = selected, role = androidx.compose.ui.semantics.Role.Tab, onClick = onClick) - } else { - Modifier.clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = onClick) - }, - ) - .padding(vertical = 6.dp, horizontal = 1.dp), - ) { - Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(20.dp)) - Spacer(Modifier.height(3.dp)) - Text( - label, - color = tint, - fontSize = 8.5.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.2.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(2.dp)) - Box( - modifier = Modifier - .height(2.dp) - .width(if (selected) 16.dp else 0.dp) - .clip(RoundedCornerShape(3.dp)) - .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent), - ) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt index 05d467ef5..6f5988fc1 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt @@ -57,7 +57,6 @@ object FileOps { return true } if (!copyWithProgress(src, dst, progress, isCancelled)) return false - // Once the destination is committed, finish deleting the source. return delete(src) } diff --git a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt index 273917b00..b19a2e987 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt @@ -63,7 +63,6 @@ object StorageRoots { } return true } - // vold-managed locations only hold removable volumes, whatever the fstype is. return path.startsWith("/storage/") || path.startsWith("/mnt/media_rw/") } @@ -92,8 +91,6 @@ object StorageRoots { .forEachIndexed { index, volume -> val uuid = volume.uuid?.takeIf { it.isNotBlank() } val dirName = volume.directory?.name?.takeIf { it.isNotBlank() } - // Unmounted volumes often report neither uuid nor directory; never drop the - // volume or a plugged-in drive stays invisible with no explanation. val descSlug = volume.getDescription(context)?.trim() ?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) } ?.replace(Regex("[^A-Za-z0-9._-]+"), "-") @@ -108,8 +105,6 @@ object StorageRoots { if (name != null) { entry.candidates += File("/storage/$name") entry.candidates += File("/mnt/media_rw/$name") - // Only strict uuid matches own an app directory; a loose match would - // attach another volume's folder to this entry. if (uuid != null) appDirs.filter { belongsTo(storageManager!!, it, volume) } .forEach { entry.seeds += it } } @@ -122,8 +117,6 @@ object StorageRoots { File("/mnt/media_rw").listFiles().orEmpty().forEach { child -> if (child.isDirectory) volumeOf(child.name).candidates += child } - // USB OTG drives do not always appear as storage volumes or under /storage for - // third-party apps, but their kernel mounts are still visible in /proc/mounts. for (mount in externalMounts()) { val entry = volumeOf(mount.name) if (entry.label == null) entry.label = mount.name diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 52806833a..0c3481e58 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -6,6 +6,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import org.json.JSONObject import com.droiddeck.launcher.session.GameStorage import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SteamLibraries import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreGameSidecar import com.droiddeck.launcher.stores.StoreInstallRoot @@ -69,6 +70,10 @@ object AddedGames { if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/") } StoreInstallRoot.externalGuestPath(context, host)?.let { return it } + for (lib in SteamLibraries.list(context)) { + if (path == lib.path) return lib.guest + if (path.startsWith("${lib.path}/")) return lib.guest + "/" + path.removePrefix("${lib.path}/") + } GameStorage.effective(context)?.let { lib -> if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/") } @@ -83,6 +88,7 @@ object AddedGames { if (!guest.startsWith("/") || guest.split('/').any { it == ".." }) return null val mapped = roots(context).map { it.host to it.guest } + StoreInstallRoot.externalRoots(context) + listOfNotNull(GameStorage.effective(context)?.let { File(it.path) to LIBRARY }) + + SteamLibraries.list(context).map { it.host to it.guest } + (android.os.Environment.getExternalStorageDirectory() to "/root/Storage") for ((host, root) in mapped.sortedByDescending { it.second.length }) { if (guest == root) return host @@ -180,7 +186,6 @@ object AddedGames { private fun entryFolders(context: Context): Set = AddedExes.list(context).map { it.folder }.toSet() - /** The card's Steam libraries: the arm64 client's steamapps and the x86-64 client's own folder beside it. */ internal fun isSteamLibraryFolder(name: String): Boolean = name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR @@ -315,7 +320,6 @@ object AddedGames { fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}, showHidden: Boolean = false): FolderResult { val app = context.applicationContext - // Binds are mounted at session start, so a new one reaches the session from the next start. if (guestPath(app, dir) == null) bindFolder(app, dir) val removed = SessionPrefs.removedAddedGames(app).toSet() val listed = listedFolders(app, removed) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/DependencyDetector.kt b/app/src/main/java/com/droiddeck/launcher/frontend/DependencyDetector.kt index 330265a0f..0983a2997 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/DependencyDetector.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/DependencyDetector.kt @@ -54,21 +54,6 @@ object DependencyDetector { "d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist", ) - fun detectForExe(exeFile: File): List { - val start = exeFile.absoluteFile.parentFile ?: return emptyList() - var d: File? = start - var depth = 0 - while (d != null && depth < 4) { - val hasRedistContainer = try { - d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true - } catch (_: Throwable) { false } - if (hasRedistContainer) return detect(d) - d = d.parentFile - depth++ - } - return detect(start) - } - fun detect(gameDir: File): List { if (!gameDir.isDirectory) return emptyList() val hits = LinkedHashMap() diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index c623e4223..723181f08 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -5,6 +5,7 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.GameStorage import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SteamLibraries import com.droiddeck.launcher.session.SecondaryLibrary import java.io.File @@ -87,13 +88,14 @@ object Library { internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List> { val root = SessionPrefs.steamRoot(context, arch) - // The session gives the x86-64 client only its card folder; the games folders stay the arm64 client's. val card = GameStorage.effective(context) - if (arch == "x86_64") return listOfNotNull(root to "internal", card?.let { File(it.path, GameStorage.X64_CARD_DIR) to it.label }) + val added = SteamLibraries.list(context).filter { it.host.isDirectory } + if (arch == "x86_64") return listOfNotNull(root to "internal", card?.let { File(it.path, GameStorage.X64_CARD_DIR) to it.label }) + + added.map { it.x64Host to it.label } return listOfNotNull( root to "internal", card?.let { File(it.path) to it.label }, - ) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label } + ) + added.map { it.host to it.label } + GameStorage.gamesFolderLibraries(context).map { it.host to it.label } } fun protonPrefix( @@ -106,7 +108,6 @@ object Library { .firstOrNull { it.isDirectory } } - /** Every steamapps/compatdata folder, in the libraries [protonPrefix] searches. */ fun compatdataDirs(context: Context, libraries: List = steamLibraries(context).map { it.first }): List = prefixRoots(context, libraries).map { File(it, "steamapps/compatdata") }.filter { it.isDirectory } @@ -179,7 +180,6 @@ object Library { } } - /** Apps the user hid (the "hidden" collection) or marked private in Steam. */ internal fun concealedSteamApps(root: File): Set { val config = AddedGames.accountConfig(root) ?: return emptySet() val hidden = runCatching { @@ -220,7 +220,6 @@ object Library { return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId } } - /** Steam's LastPlayTime for each non-Steam shortcut, by its unsigned shortcut app id. */ internal fun shortcutsLastPlayed(root: File): Map = runCatching { val config = AddedGames.accountConfig(root) ?: return emptyMap() val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap() diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt index cd30e400c..6fa35c753 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt @@ -10,7 +10,6 @@ object LibraryCache { private const val TAG = "LibraryCache" private fun file(context: Context) = File(context.filesDir, "library-cache.json") - /** The cached games and whether they are the x86-64 client's; a cache written as a bare list is arm64's. */ @Synchronized fun snapshot(context: Context): Pair, Boolean> = runCatching { val f = file(context) @@ -36,7 +35,6 @@ object LibraryCache { fun listedX64(context: Context): Boolean = snapshot(context).second - /** Without [x64] the list keeps the client already recorded. */ @Synchronized fun save(context: Context, games: List, x64: Boolean? = null) { runCatching { diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/LogoPosition.kt b/app/src/main/java/com/droiddeck/launcher/frontend/LogoPosition.kt index a047da09e..64491df82 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/LogoPosition.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/LogoPosition.kt @@ -2,7 +2,6 @@ package com.droiddeck.launcher.frontend import org.json.JSONObject -/** Where Steam pins a game's logo on its hero image and how big it draws it; the same JSON Steam keeps in `.json`. */ object LogoPosition { enum class Pin(val steam: String) { UPPER_LEFT("UpperLeft"), UPPER_CENTER("UpperCenter"), CENTER("CenterCenter"), BOTTOM_LEFT("BottomLeft"), BOTTOM_CENTER("BottomCenter"); @@ -17,7 +16,6 @@ object LogoPosition { const val MIN_PCT = 10 const val MAX_PCT = 100 - /** What Steam draws when a game has no custom position. */ val DEFAULT = Value(Pin.BOTTOM_LEFT, 50, 50) private const val VERSION = 1 @@ -25,7 +23,6 @@ object LogoPosition { fun sized(pin: Pin, size: Int): Value = size.coerceIn(MIN_PCT, MAX_PCT).let { Value(pin, it, it) } - /** `{"nVersion":1,"logoPosition":{...}}`, with whatever else [into] already held kept as it was. */ fun toJson(value: Value, into: JSONObject = JSONObject()): JSONObject = into .put("nVersion", VERSION) .put(KEY, JSONObject().put("pinnedPosition", value.pin.steam).put("nWidthPct", value.widthPct).put("nHeightPct", value.heightPct)) @@ -39,7 +36,6 @@ object LogoPosition { return Value(pin, inner.optInt("nWidthPct").coerceIn(MIN_PCT, MAX_PCT), inner.optInt("nHeightPct").coerceIn(MIN_PCT, MAX_PCT)) } - /** [existing] without the logo position; null when nothing but the version number would be left. */ fun without(existing: JSONObject): JSONObject? { existing.remove(KEY) val rest = existing.keys().asSequence().filter { it != "nVersion" }.toList() diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt index 88526f1bf..e52defb7d 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt @@ -7,11 +7,9 @@ import org.json.JSONObject import java.io.File import java.util.UUID -/** Steam keeps a game's custom art in `userdata//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") @@ -36,7 +34,6 @@ object SteamGrid { private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}" - /** Each Steam client has its own grid folder, so what was last seen there is kept per client. */ private fun sigName(context: Context, key: String) = if (SessionPrefs.activeSteamClientArch(context) == "x86_64") "grid-x86_64-$key" else "grid-$key" @@ -45,10 +42,6 @@ object SteamGrid { private const val GRID_ID = ".droiddeck-grid" private const val UNKNOWN_GRID = "?" - /** - * A random id kept in the grid folder, so a record made against another account's folder, or one wiped with the - * runtime, reads as "not seen yet" instead of "removed in Steam". "?" never matches, so nothing is dropped. - */ private fun gridId(grid: File): String = runCatching { val f = File(grid, GRID_ID) f.takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString().also { id -> @@ -83,7 +76,6 @@ object SteamGrid { 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 { @@ -116,7 +108,6 @@ object SteamGrid { private fun positionIn(file: File): LogoPosition.Value? = file.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() }?.let(LogoPosition::parse) - /** The logo position chosen here (or taken over from Steam) for a game; null when Steam's own default applies. */ fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION)) private fun positionRecorded(context: Context, grid: File, target: Target): String? = @@ -132,12 +123,10 @@ object SteamGrid { return runCatching { file.writeText(LogoPosition.toJson(value).toString()) }.isSuccess } - /** Sets (or with null, clears) a game's logo position here and in the client's grid folder, whether or not Steam is running. */ fun writePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean = synchronized(lock) { placePosition(context, target, value) && storePosition(target, value) } - /** Steam keeps the position in `.json`; anything else that file holds is left as it was. */ private fun placePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean { val grid = dir(context) ?: return true val file = positionFile(grid, target.id) @@ -160,7 +149,6 @@ object SteamGrid { return true } - /** A `logoPosition` this version cannot read (a pin it does not know, say) is left alone rather than read as "removed". */ private fun hasUnknownPosition(file: File): Boolean { val json = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return false return json.has("logoPosition") && LogoPosition.parse(json) == null @@ -176,7 +164,6 @@ object SteamGrid { when { before == null && own != null && theirs == null -> placePosition(context, target, own) before == null || now != before -> { - // Recorded only once the local side agrees, so a failed take-over is tried again on the next scan. if (theirs != null) { if (own == theirs || storePosition(target, theirs)) recordPosition(context, grid, target, now) } else { @@ -187,7 +174,6 @@ object SteamGrid { } } - /** 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): Boolean = synchronized(lock) { reconcile(context, targets) } private fun reconcile(context: Context, targets: List): Boolean { diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt index e022ebc8e..a287c2299 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt @@ -15,7 +15,6 @@ object SteamRedists { "228989" to ("vcredist2022_dll" to "VC++ 2022"), "228990" to ("d3dx9" to "DirectX (June 2010)"), ) - /** Redists a game's own install script runs on Windows that its shared depots don't name. */ val SCRIPTED = mapOf( 72850 to listOf("xact" to "DirectX XACT audio"), 489830 to listOf("xact_x64" to "DirectX XACT audio (64-bit)"), @@ -28,7 +27,6 @@ object SteamRedists { return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile } } - /** Redists the game's own install script has Steam run on Windows, from its "Run Process" entries. */ fun installScript(gameDir: File?): List> = gameDir?.listFiles { f -> f.isFile && f.length() < 1L shl 20 && f.name.lowercase().let { it.startsWith("installscript") && it.endsWith(".vdf") } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt index 8e91102f3..22044c76d 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt @@ -51,10 +51,8 @@ object WinCompSources { fun installer(path: String): List = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path) private val XBOX_KNOWN = Regex("""> { val config = folder?.listFiles().orEmpty().firstOrNull { it.isFile && it.name.equals("MicrosoftGame.config", ignoreCase = true) && it.length() < 1L shl 20 } val declared = config?.let { file -> runCatching { XBOX_KNOWN.findAll(file.readText()).map { it.groupValues[1] }.toList() }.getOrNull() }.orEmpty() diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt index fe09a7809..f14d92da2 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt @@ -563,7 +563,6 @@ object Glasses { val device = selected?.first val m = selected?.second val controllable = m?.let(::canOpenModel) == true - // XREAL uses Android's Ethernet sockets, without opening or claiming USB interfaces. val permitted = controllable && device != null && (m === XrealModel || usb.hasPermission(device)) val identity = device?.let { GlassesModeState.Device(it.productId, if (permitted) runCatching { it.serialNumber }.getOrNull() else null) @@ -677,8 +676,6 @@ object Glasses { internal fun selectGlassesDisplay(displays: List, usbPresent: Boolean): Display? { val candidates = displays .filter { it.displayId != Display.DEFAULT_DISPLAY && it.flags and Display.FLAG_PRESENTATION != 0 } - // Android can expose the glasses name on either the logical display or - // its product metadata. A generic label on one must not hide the other. return candidates.firstOrNull { display -> isGlassesDisplayName(display.name) || (Build.VERSION.SDK_INT >= 31 && display.deviceProductInfo?.let { product -> @@ -728,7 +725,6 @@ object Glasses { if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) { VitureV2Link.open(usb, device, v2Listener(generation)) } else if ((model as? VitureProtocol.Model)?.needsSdk == true) { - // Android can evict cacheDir while the SDK is running. Log.i(TAG, "opening VITURE SDK pid=0x%04X spatial=%s".format(device.productId, spatial)) VitureSdkLink.open(usb, device, File(app.noBackupFilesDir, "viture"), sdkListener(generation), (model as? VitureProtocol.Model)?.family, spatial) @@ -854,7 +850,6 @@ object Glasses { if (generation != linkGeneration) return@post closeLink() publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) } - // Let a reconnect or explicit Allow retry restart the SDK, not an endless failure loop. } } @@ -974,7 +969,6 @@ object Glasses { } if (!s.usable || s.sbs || modeState.pendingStereo) return val hz = _settings.value.refreshHz - // Native 30 Hz is selected by the Android presentation, without a vendor mode command. if (hz == 30 && GlassesRendering.native30HzAvailable(_settings.value, s)) return if (glassesMode == m.mode(hz, false, outputResolution())) return if (!force) { diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt index 5de0f03f5..3bf992cbd 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt @@ -1,6 +1,5 @@ package com.droiddeck.launcher.glasses -/** Hardware properties; display refresh is selected from Android's advertised modes. */ interface GlassesModel { val name: String val controllable: Boolean @@ -18,9 +17,6 @@ interface GlassesModel { fun modeForStep(step: Int): Int? } -/** The command IDs here are the existing glasses controller's internal vocabulary. - * Each transport translates them to its own wire protocol; unsupported controls return false. - */ internal interface GlassesLink { val hasImu: Boolean val badFrames: Long get() = 0L diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesMenuSurface.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesMenuSurface.kt index cefe3d932..501aaae1b 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesMenuSurface.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesMenuSurface.kt @@ -63,7 +63,6 @@ class GlassesMenuSurface( override fun onDetachedFromWindow() { close() super.onDetachedFromWindow() - // GLSurfaceView may exit its GL thread before queued cleanup is consumed. releaseInput() } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt index b07221556..01df9fe56 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt @@ -2,7 +2,6 @@ package com.droiddeck.launcher.glasses import com.droiddeck.launcher.gpu.Cardboard -/** Share the actual picture mode with settings so controls match the rendered view. */ internal object GlassesRendering { enum class Picture { FLAT, DUPLICATED, VIRTUAL_SCREEN, STEREO } @@ -51,11 +50,7 @@ internal object GlassesRendering { outputActive: Boolean, steamVrRunning: Boolean, ): Cardboard { - // Mirror output still uses the glasses' picture and sensors during VR. if (!usesGlasses(state, outputActive, steamVrRunning)) { - // SteamVR uses the renderer's enabled flag as headset proximity. - // A flat phone preview still needs an active HMD and one SBS eye; - // otherwise SteamVR enters standby and leaves a frozen frame. return if (steamVrRunning && !base.enabled) base.copy( enabled = true, source = Cardboard.SOURCE_SBS, diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt index c15e9c623..43f80bfc3 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt @@ -36,7 +36,6 @@ class GlassesScreen(context: Context, display: Display, refreshHz: Int, private fun prefer(hz: Int, resolution: GlassesResolution? = null) { wantHz = hz wantResolution = resolution - // A 2D/SBS transition can replace the active mode without changing Hz. applyMode() } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/HandImageBuffer.kt b/app/src/main/java/com/droiddeck/launcher/glasses/HandImageBuffer.kt index b6aa9844a..a3d94cd6a 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/HandImageBuffer.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/HandImageBuffer.kt @@ -51,7 +51,6 @@ internal class HandImageBuffer { pixels.clear() pixels.put(rgb) pixels.rewind() - // MediaPipe applies the model metadata's float normalization to these RGB bytes. return ByteBufferImageBuilder(pixels, width, height, MPImage.IMAGE_FORMAT_RGB).build() } } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/LumaRgbDiagnostic.kt b/app/src/main/java/com/droiddeck/launcher/glasses/LumaRgbDiagnostic.kt index 6a483a1a0..5f6175474 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/LumaRgbDiagnostic.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/LumaRgbDiagnostic.kt @@ -150,7 +150,6 @@ class LumaRgbCaptureService : Service() { intent?.getIntExtra("durationMs",5000) ?: 5000) } finally { stopSelf() - // SDK 2.4.0 can crash while cancelling failed USB transfers; isolate each diagnostic. android.os.Process.killProcess(android.os.Process.myPid()) } }, "luma-rgb-diagnostic").start() diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt index 8e579aee3..23ebcce81 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt @@ -40,12 +40,9 @@ internal data class RayNeoCalibration(val gyroBiasDps: Vec3, val level: Quaterni internal class RayNeoCalibrationStore(private val prefs: SharedPreferences) { companion object { - // GT and GT Max can share USB identity fields. Keep the non-Max mounting - // correction separate from existing Max profiles and their legacy keys. fun profileKey(base: String, gtUsb: Boolean, boardId: Int): String = if (gtUsb && boardId == RayNeoProtocol.BOARD_GT) "$base:gt-standard-v1" else base } - // A serial isolates physical units. Devices without one share their model profile. fun key(vid: Int, pid: Int, serial: String?, modelName: String = ""): String = "rayneo-cal-v1:$vid:$pid:${serial?.takeIf { it.isNotBlank() } ?: "model:$modelName"}" fun load(key: String): RayNeoCalibration? = RayNeoCalibration.decode(prefs.getString(key, null)) @@ -55,7 +52,6 @@ internal class RayNeoCalibrationStore(private val prefs: SharedPreferences) { } } -/** One upside-down stationary pose estimates gyro bias and tilt, never yaw or accelerometer scale. */ internal class RayNeoCalibrationCollector(private val axes: RayNeoSensorAxes, private val beganMs: Long) { var phase = CalibrationPhase.SETTLING; private set var progress = 0; private set diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt index c9770ed22..78c2275b4 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt @@ -6,7 +6,6 @@ import android.hardware.usb.UsbManager import android.os.SystemClock import android.util.Log -/** RayNeo command/response and IMU share one endpoint. Keep reads on a single worker. */ internal class RayNeoLink private constructor( private val connection: UsbDeviceConnection, private val endpoints: RayNeoEndpointSelection, @@ -52,7 +51,6 @@ internal class RayNeoLink private constructor( ready && (mode == RayNeoProtocol.CMD_SWITCH_TO_3D || mode == RayNeoProtocol.CMD_SWITCH_TO_2D) && command(mode) } - // No VITURE brightness, volume, film, rate or raw command is sent to RayNeo. else -> false } } @@ -159,7 +157,6 @@ internal class RayNeoLink private constructor( } val wxyz = filter.update(packet.sample) if (wantImu && collector == null && lastSensor >= warmupUntil) { - // Normalize basis and component order at the hardware boundary. listener.onPose(raw, RayNeoOrientationFilter.glassesPose(wxyz), SystemClock.elapsedRealtimeNanos()) } @@ -176,7 +173,6 @@ internal class RayNeoLink private constructor( override fun close() { synchronized(writeLock) { if (!running) return - // Best effort: stop the sensor before releasing the claimed interface. if (ready) runCatching { command(RayNeoProtocol.CMD_CLOSE_IMU) } running = false } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt index ccb19f744..72c88b34b 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt @@ -8,8 +8,6 @@ object RayNeoModel { fun model(vid: Int, pid: Int, name: String?): GlassesModel? { if (!isVendor(vid)) return null - // GT's identity is confirmed by verncat's SDK; do not claim other products - // from that vendor just because their name resembles a supported model. val gt = RayNeoProtocol.isGt(vid, pid) if (vid == RayNeoProtocol.GT_VENDOR_ID && !gt) return null if (!(vid == RayNeoProtocol.VENDOR_ID && pid == RayNeoProtocol.PRODUCT_ID) && @@ -23,8 +21,6 @@ object RayNeoModel { name.contains("rayneo", ignoreCase = true) || name.contains("ray neo", ignoreCase = true) || modelName.containsMatchIn(name) - // Observed on GT Max: Android exposes an unbranded EDID monitor name. - // Match its product metadata, not every display called SmartGlasses/TCL. fun isDisplayProduct(name: String?, manufacturer: String?, productId: String?): Boolean = name?.trim().equals("SmartGlasses", ignoreCase = true) && manufacturer.equals("TCL", ignoreCase = true) && productId == "980" @@ -33,7 +29,7 @@ object RayNeoModel { override val controllable = true override val brightnessMax = 0 override val volumeMax = 0 - override val diagonalFovDeg = 46f // Conservative existing default, not a calibrated model profile. + override val diagonalFovDeg = 46f override val sbs90 = false override fun mode(hz: Int, sbs: Boolean) = if (sbs) RayNeoProtocol.CMD_SWITCH_TO_3D else RayNeoProtocol.CMD_SWITCH_TO_2D diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt index 96bff8500..22a3a8965 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt @@ -1,5 +1,4 @@ -// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0). -// See docs/third-party/rayneo/. Returns scaffold quaternions in (w, x, y, z) order. +// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0); see docs/third-party/rayneo/ for license and attribution. package com.droiddeck.launcher.glasses import kotlin.math.cos @@ -123,10 +122,6 @@ internal class RayNeoOrientationFilter( private var lastRealtimeNs: Long = 0L companion object { - /** Scaffold: right +X, up +Y, back +Z. VITURE: right -Y, up +Z, back -X. - * Conjugate by HeadPose.GLASSES_AXES before entering the unchanged VITURE pose path. - * This is a basis conversion, not a projection or seated-origin adjustment. - */ fun glassesPose(wxyz: FloatArray): FloatArray = floatArrayOf(-wxyz[3], -wxyz[1], wxyz[2], wxyz[0]) } @@ -168,8 +163,6 @@ internal class RayNeoOrientationFilter( if (accelNorm > 1e-3f) { val measuredUp = accel.normalized() if (!gravityInitialized) { - // Align gravity before publishing the first pose. Starting at - // identity and settling after recenter can rotate the horizon. val axis = measuredUp.cross(worldUp) q = if (measuredUp.y < -0.9999f) { Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat()) @@ -179,7 +172,6 @@ internal class RayNeoOrientationFilter( gravityInitialized = true } val predictedUp = q.conjugate().rotate(worldUp).normalized() - // q maps body to world: measured x predicted reduces gravity error. val correction = measuredUp.cross(predictedUp) gyro += correction * accelGain } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt index 9f65b04df..781f0555d 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt @@ -40,14 +40,13 @@ data class RayNeoEndpointSelection( object RayNeoProtocol { const val VENDOR_ID = 0x1BBB const val PRODUCT_ID = 0xAF50 - // verncat/RayNeo-Air-3S-Pro-OpenVR PR #3 (MIT), tested on GT hardware. const val GT_VENDOR_ID = 0x3941 private const val GT_CONTROL_INTERFACE = 5 fun isGt(vid: Int, pid: Int) = vid == GT_VENDOR_ID && pid == PRODUCT_ID const val BOARD_AIR_4_PRO = 0x3A - const val BOARD_GT = 0x40 // Observed on the user's non-Max GT. + const val BOARD_GT = 0x40 const val PKT_MAGIC = 0x99 const val PKT_SENSOR = 0x65 @@ -131,7 +130,6 @@ object RayNeoProtocol { endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) { continue } - // Exclude small button reports from GT's protocol transport. if (gt && endpoint.maxPacketSize < 64) continue if (endpoint.direction == UsbConstants.USB_DIR_IN && inEndpoint == null) { @@ -159,8 +157,6 @@ object RayNeoProtocol { } } - // Interface numbers can change with firmware. Use a unique protocol-sized - // pair when interface 5 is absent, never an arbitrary composite interface. return if (gt) gtCandidates.singleOrNull() else fallback } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt index dbf10a6e6..3b1002eba 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt @@ -9,7 +9,6 @@ import android.hardware.usb.UsbManager import android.os.SystemClock import android.util.Log -/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */ internal class RokidLink private constructor( private val connection: UsbDeviceConnection, private val usbInterface: UsbInterface, @@ -44,7 +43,7 @@ internal class RokidLink private constructor( ?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS && control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS) - else -> false // Volume is the glasses' USB audio device; there is no film or IMU-rate control. + else -> false } } @@ -54,7 +53,6 @@ internal class RokidLink private constructor( if (!running) return null val buffer = ByteArray(64) val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS) - // Display mode is reported in the second byte, brightness in the first. val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0 if (count > offset) buffer[offset].toInt() and 0xff else null } @@ -90,7 +88,6 @@ internal class RokidLink private constructor( if (enabled && !wasTracking) fusion = ImuFusion() wasTracking = enabled if (!enabled) continue - // Max captures: already right/up/back; level gravity +Y, turn left +gyroY, look up +gyroX, roll right -gyroZ. fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let { if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos()) } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt index 35227206b..62d5ab81d 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt @@ -3,7 +3,6 @@ package com.droiddeck.launcher.glasses import java.nio.ByteBuffer import java.nio.ByteOrder -/** Rokid Air/Max share one USB identity; Max 2 has a separate product ID. */ internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel { override val controllable = true override val brightnessMax = BRIGHTNESS_STEPS @@ -36,7 +35,6 @@ internal class RokidModel private constructor(override val name: String, private const val MAX2_PRODUCT_ID = 0x2002 const val BRIGHTNESS_STEPS = 10 - // Vendor control requests, device recipient. const val REQUEST_SET_DISPLAY = 0x01 const val REQUEST_GET_DISPLAY = 0x81 const val REQUEST_SET_BRIGHTNESS = 0x02 @@ -51,7 +49,6 @@ internal class RokidModel private constructor(override val name: String, private const val MODE_SBS_1200_60 = 5 val MODES = setOf(MODE_2D_60, MODE_SBS_1080_60, MODE_2D_120, MODE_SBS_1200_90, MODE_SBS_1200_60) - // 0x04d2 is a shared vendor ID, so the Rokid product string is required too. fun model(vid: Int, pid: Int, name: String?): GlassesModel? { if (vid != VENDOR_ID || (pid != PRODUCT_ID && pid != MAX2_PRODUCT_ID)) return null val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null @@ -60,7 +57,6 @@ internal class RokidModel private constructor(override val name: String, private fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true) - /** Device brightness is 0..100; the shared slider uses ten steps. */ fun brightnessStep(raw: Int): Int = ((raw.coerceIn(0, 100) + 5) / 10).coerceIn(0, BRIGHTNESS_STEPS) fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10 } @@ -74,7 +70,6 @@ internal object RokidProtocol { private const val SENSOR_ACCEL = 1 private const val SENSOR_GYRO = 2 - /** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */ class Parser { private var accel: Vec3? = null private var accelTime = -1L @@ -87,7 +82,7 @@ internal object RokidProtocol { return when (packet[0].toInt() and 0xff) { COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff) SEPARATE -> { - val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds. + val time = b.getLong(9) * 1000 when (packet[1].toInt() and 0xff) { SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time } SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/StereoCalibration.kt b/app/src/main/java/com/droiddeck/launcher/glasses/StereoCalibration.kt index 027f0b92e..8c08316d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/StereoCalibration.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/StereoCalibration.kt @@ -26,7 +26,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh val source get() = if (bundledBase && !verified) "luma-ultra-base" else "device" private val p1 = Mat.zeros(3, 4, CvType.CV_64F) private val p2 = Mat.zeros(3, 4, CvType.CV_64F) - private val undistortionMaps = Array(2) { arrayOf(Mat(),Mat()) } init { val a = Mat.eye(3, 4, CvType.CV_64F) val b = Mat.zeros(3, 4, CvType.CV_64F) @@ -35,18 +34,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh Core.gemm(k1, a, 1.0, empty, 0.0, p1); Core.gemm(k2, b, 1.0, empty, 0.0, p2) a.release(); b.release(); empty.release() } - fun undistort(src: Mat, dst: Mat, right: Boolean) { - val k = if (right) k2 else k1; val d = if (right) d2 else d1 - val maps=undistortionMaps[if(right) 1 else 0] - if(maps[0].empty()) { - val identity=Mat.eye(3,3,CvType.CV_64F) - try { - if(fisheye) Calib3d.fisheye_initUndistortRectifyMap(k,d,identity,k,src.size(),CvType.CV_16SC2,maps[0],maps[1]) - else Calib3d.initUndistortRectifyMap(k,d,identity,k,src.size(),CvType.CV_16SC2,maps[0],maps[1]) - } finally { identity.release() } - } - Imgproc.remap(src,dst,maps[0],maps[1],Imgproc.INTER_LINEAR) - } fun triangulateRaw(left: List, right: List): List? { val points = triangulateLandmarksRaw(left,right) ?: return null return if(points.any { it == null }) null else points.filterNotNull() @@ -74,10 +61,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh return triangulatePoints(l.toList(),r.toList()) } finally { l.release(); r.release(); identity.release() } } - fun triangulate(left: List, right: List): List? { - val points = triangulatePoints(left,right) ?: return null - return if(points.any { it == null }) null else points.filterNotNull() - } private fun triangulatePoints(left: List, right: List): List? { if (left.size != right.size || left.isEmpty()) return null val a = Mat(2, left.size, CvType.CV_64F); val b = Mat(2, right.size, CvType.CV_64F); val out = Mat() @@ -105,7 +88,7 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh val tmp = File(file.parentFile,file.name+".tmp"); tmp.writeText(j.toString()) java.nio.file.Files.move(tmp.toPath(),file.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING) } - override fun close() { (listOf(k1,d1,k2,d2,r,t,p1,p2)+undistortionMaps.flatMap { it.toList() }).forEach { it.release() } } + override fun close() { listOf(k1,d1,k2,d2,r,t,p1,p2).forEach { it.release() } } companion object { fun load(file: File, identity: String, width: Int, height: Int): StereoCalibration? = runCatching { StereoCalibrationProfile.parse(file.readText(), identity, width, height)?.let(::fromProfile) }.getOrNull() @@ -286,7 +269,6 @@ internal class StereoCalibrator(private val context: Context) : AutoCloseable { Mat().also { output -> corrected.add(output); Calib3d.fisheye_undistortPoints(pixels,output,k,d,identity,k) } } try { - // OpenCV 4.13 fisheye stereo fitting assumes equal corner counts in every view. Calib3d.stereoCalibrate(objects,images(left,k1,d1),images(right,k2,d2),k1,if(fish)zero else d1,k2,if(fish)zero else d2, size,r,t,e,f,Calib3d.CALIB_FIX_INTRINSIC) } finally { (corrected+listOf(e,f,identity,zero)).forEach(Mat::release) } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt b/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt index becad63a0..78aad3f61 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt @@ -383,7 +383,6 @@ internal class StereoHandEngine(private val context: Context, private val token: message=context.getString(R.string.spatial_frame_changed) calibrationProgress = calibrationProgress.copy(collecting = false, capturing = false, captureMessage = message) } - // The callback pose's basis is undocumented; use canonical GL pose history. val observation=HandObservation(time,generation,trackingFrame) var captureHead=observation.headPose(SpatialTracking.session,metadata[6]>0) val w=metadata[0].toInt();val h=metadata[1].toInt() @@ -499,7 +498,6 @@ internal class StereoHandEngine(private val context: Context, private val token: dl=pair.first.hands;dr=pair.second.hands if(scheduled) inferenceSchedule.observe(stamp,dl.map { it.side },dr.map { it.side },pair.first.rawCount,pair.second.rawCount) val inferenceMs=(System.nanoTime()-inferenceStart)/1e6f - // VIO callbacks can arrive during inference; verify this exact camera stamp before publishing. captureHead=observation.headPose(SpatialTracking.session, metadata[6]>0 || VitureSdk.nativeCameraSynchronized(token,metadata[2])) val hands=Array(2) { SpatialHand() } @@ -631,7 +629,7 @@ internal class StereoHandEngine(private val context: Context, private val token: SpatialTracking.publish(System.nanoTime()) } } - } catch(e: InterruptedException) { /* Shutdown interrupts the idle wait. */ } + } catch(e: InterruptedException) { } catch(e: Throwable) { message=context.getString(R.string.spatial_hand_error, e.message.orEmpty()) val unavailable=HandObservationDiagnostic(HandObservationOutcome.INFERENCE_UNAVAILABLE) diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/StereoInferenceRunner.kt b/app/src/main/java/com/droiddeck/launcher/glasses/StereoInferenceRunner.kt index 351fe332e..a8c994504 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/StereoInferenceRunner.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/StereoInferenceRunner.kt @@ -31,7 +31,6 @@ internal class StereoInferenceRunner : AutoCloseable { catch (e: ExecutionException) { throw e.cause ?: e } } } finally { - // Native inference must finish before either camera's pixels or detector can be reused. if (interrupted) Thread.currentThread().interrupt() } } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/UvcRgbDiagnostic.kt b/app/src/main/java/com/droiddeck/launcher/glasses/UvcRgbDiagnostic.kt index 6bd9c6b15..ce119c044 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/UvcRgbDiagnostic.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/UvcRgbDiagnostic.kt @@ -12,8 +12,6 @@ internal object UvcRgbDiagnostic { internal data class UvcStreamHealth(val observationMs: Double, val observedFps: Double, val maximumGapMs: Double, val healthy: Boolean) { companion object { - // Health requires the full requested window, 80% of requested FPS, and no gap - // (including the final silent interval) above max(250 ms, three frame periods). fun assess(metadata: LongArray, requestedFps: Int, durationMs: Int): UvcStreamHealth { require(metadata.size == 14 && requestedFps > 0 && durationMs > 0) val first = metadata[7] diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt index 573f0f8dc..55302dfc3 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt @@ -81,7 +81,6 @@ object VitureProtocol { } } - /** SDK validates the PID before returning this market name. Never guess a new PID. */ fun sdkModel(marketName: String): Model? { val family = when (marketName) { "One", "Lite" -> Family.ONE diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt index 1b5f09e3d..bdad575c1 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt @@ -3,7 +3,6 @@ package com.droiddeck.launcher.glasses import android.util.Log import kotlin.math.sqrt -/** JNI adapter to VITURE's Android C SDK. The vendor binary is a local build input. */ internal object VitureSdk { val available: Boolean by lazy { runCatching { @@ -27,7 +26,6 @@ internal object VitureSdk { external fun nativeSpatialDiagnostics(token: Long): DoubleArray? external fun nativeTracking(token: Long, enabled: Boolean): Int external fun nativeCommand(token: Long, command: Int, value: Int): Int - /** Returns CLOCK_BOOTTIME receipt time, zero for no new pose, or a negative SDK error. */ external fun nativePose(token: Long, wxyz: FloatArray): Long external fun nativeNextState(token: Long, event: IntArray): Int external fun nativeClose(token: Long) @@ -48,12 +46,9 @@ internal object VitureSpatialIdentity { } internal object VitureSdkPose { - // Pro 2 hardware captures use OpenGL axes despite the generic Gen2 callback's - // NWU documentation. Keep that model-specific exception out of other Gen2 devices. fun usesOpenGlAxes(carina: Boolean, family: VitureProtocol.Family?) = carina || family == VitureProtocol.Family.PRO2 - /** SDK quaternions are wxyz; normalize their coordinate basis at the boundary. */ fun toGlasses(wxyz: FloatArray, openGlAxes: Boolean): FloatArray? { if (wxyz.size != 4 || wxyz.any { !it.isFinite() }) return null val length = sqrt(wxyz.sumOf { it.toDouble() * it }.toFloat()) diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt index 36ceedf6e..fe42adc71 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt @@ -7,7 +7,6 @@ import android.os.SystemClock import android.util.Log import java.io.File -/** SDK owns interface selection (including Ultra bulk endpoints) and all USB I/O. */ internal class VitureSdkLink private constructor( private val connection: UsbDeviceConnection, private val token: Long, @@ -47,7 +46,6 @@ internal class VitureSdkLink private constructor( if (rc == 0 || !wanted) tracking = wanted return result("tracking", rc) } - // Frequency is negotiated against SDK capabilities, including Pro 2's pose limit. VitureProtocol.MSG_IMU_RATE -> return tracking VitureProtocol.MSG_GET_IMU -> return true } @@ -89,7 +87,6 @@ internal class VitureSdkLink private constructor( if (VitureSdk.nativeNextState(token, event) == 1) when (event[0]) { 0 -> listener.onValue(VitureProtocol.MSG_GET_BRIGHTNESS, event[1]) 1 -> listener.onValue(VitureProtocol.MSG_GET_VOLUME, event[1]) - // A device event can use a different display-mode namespace. 2 -> send(VitureProtocol.MSG_GET_DISPLAY) 3 -> listener.onValue(VitureProtocol.MSG_FILM, event[1]) } @@ -122,7 +119,6 @@ internal class VitureSdkLink private constructor( if (!running) return running = false VitureSdk.nativeClose(token) - // Keep Java's descriptor alive until the SDK and its worker threads have stopped. connection.close() } if (Thread.currentThread() !== worker) worker.join(1000) diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt index d46c246b7..16bd1195a 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt @@ -69,7 +69,6 @@ internal class VitureV2Link internal constructor( return true } if (msgId == VitureProtocol.MSG_GET_IMU) return true - // Keep the measured 120 Hz mode; the generic controller requests 240 Hz. if (msgId == VitureProtocol.MSG_IMU_RATE) return false val query = when (msgId) { VitureProtocol.MSG_GET_BRIGHTNESS -> VitureV2Protocol.MSG_GET_BRIGHTNESS @@ -146,9 +145,7 @@ internal class VitureV2Link internal constructor( tracking(true) if (becameReady) listener.onReady() val q = pose.quaternion - // Upstream right/up/back pose converted to the shared VITURE legacy basis. val shared = floatArrayOf(-q[2], -q[0], q[1], q[3]) - // Upstream does not establish device tick units; use host monotonic receipt time. if (running && wanted && streamOn) listener.onPose(received.bytes, shared, received.timeNs) return } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt index 548ad6b91..e2e4fdbfc 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt @@ -1,5 +1,4 @@ -// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. -// Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/. +// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/. package com.droiddeck.launcher.glasses import kotlin.math.sqrt @@ -37,7 +36,6 @@ object VitureV2Protocol { val success: Boolean get() = status == 0 } - // Device ticks have no established time unit; quaternion is xyzw with no axis remapping. class Pose internal constructor( val deviceTick: Long, val quaternion: FloatArray, @@ -64,7 +62,6 @@ object VitureV2Protocol { } fun imuControl(streams: Int, rate: Rate = Rate.HZ_120): ByteArray { - // Upstream measured that stream mask 3 ACKs but produces no events on Pro 2. require(streams == STREAM_OFF || streams == STREAM_POSE || streams == STREAM_RAW) require(streams != STREAM_POSE || rate.hz <= 240) val wireRate = if (streams == STREAM_OFF) 0 else rate.wireValue diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt index 3dbd22eb3..e972cd450 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt @@ -24,7 +24,6 @@ internal interface XrealConnector : Closeable { internal class XrealNetworkMissing : IOException("XREAL USB Ethernet network is not available") -/** Bind individual sockets, never the process: Steam must retain its Internet network. */ internal class AndroidXrealConnector(context: Context) : XrealConnector { private val manager = context.getSystemService(ConnectivityManager::class.java) private val address = InetAddress.getByName(XrealProtocol.HOST) diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt index 86f93fbef..18ce636ee 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt @@ -7,7 +7,6 @@ import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference -/** One connection owns two TCP channels. All control requests are serialized off the UI thread. */ internal class XrealLink internal constructor( private val connector: XrealConnector, private val listener: Listener, @@ -47,7 +46,7 @@ internal class XrealLink internal constructor( VitureProtocol.MSG_DISPLAY -> value in 2..5 || value == XrealModel.NATIVE_60HZ VitureProtocol.MSG_BRIGHTNESS -> value in 0..9 VitureProtocol.MSG_FILM -> value in 0..1 - else -> false // No guessed volume, sensor-rate, or VITURE wire commands. + else -> false } return valid && commands.offer(Command(msgId, value)) } @@ -88,7 +87,7 @@ internal class XrealLink internal constructor( } } catch (e: Exception) { failure.set(e) - current.close() // Wake a control read immediately. + current.close() } }, "xreal-reports").apply { isDaemon = true; start() } ready = true @@ -145,12 +144,11 @@ internal class XrealLink internal constructor( tracking = false commands.clear() connection.getAndSet(null)?.close() - connector.close() // Includes sockets still inside connect(). + connector.close() worker.interrupt() } } -/** Kept independent of Android so the real framing/acknowledgement path is testable. */ internal class XrealControl(private val channels: XrealChannels, private val settleDelayMs: Long = 150) { private var transaction = 0 private fun exchange(command: Int, value: Int? = null): ByteArray { @@ -158,7 +156,7 @@ internal class XrealControl(private val channels: XrealChannels, private val set channels.controlOutput.write(XrealProtocol.request(command, transaction, value)) channels.controlOutput.flush() val deadline = System.nanoTime() + 3_000_000_000L - repeat(32) { // Bound unsolicited button/status packets and stale responses. + repeat(32) { if (System.nanoTime() > deadline) throw IOException("XREAL control timed out") XrealProtocol.response(XrealProtocol.read(channels.controlInput), command, transaction)?.let { return it } } @@ -172,9 +170,6 @@ internal class XrealControl(private val channels: XrealChannels, private val set fun changeDisplay(mode: Int): Int { val stereo = mode == 5 set(XrealProtocol.SET_INPUT, if (stereo) 1 else 0) - // On 1S, SET_INPUT itself changes EDID. Sending SET_DISPLAY immediately - // receives error 5001 while that change is pending. GET_DISPLAY returns - // an empty property during the transition; wait for a real mode report. val current = awaitDisplay { (it == 5) == stereo } if (current == mode) return current set(XrealProtocol.SET_DISPLAY, mode) diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt index 19cd2097c..2de2236c7 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt @@ -1,10 +1,9 @@ package com.droiddeck.launcher.glasses -/** First One-family target. Recovery and Air USB products must not use this transport. */ object XrealModel : GlassesModel { const val VENDOR_ID = 0x3318 const val PRODUCT_1S = 0x043e - const val NATIVE_60HZ = 29 // Captured on 1S: 1920 x 1200 at 60 Hz. + const val NATIVE_60HZ = 29 override val name = "XREAL 1S" override val controllable = true override val brightnessMax = 9 @@ -16,7 +15,6 @@ object XrealModel : GlassesModel { fun matches(vid: Int, pid: Int) = vid == VENDOR_ID && pid == PRODUCT_1S fun isDisplay(name: String) = name.contains("XREAL", ignoreCase = true) - // One-family EDID configuration values, not VITURE's display mode IDs. override fun mode(hz: Int, sbs: Boolean) = when { sbs -> 5 hz >= 120 -> 4 diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt index e8d34f44a..c10bb9473 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt @@ -2,19 +2,14 @@ package com.droiddeck.launcher.glasses import kotlin.math.abs -/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */ internal class XrealOrientationFilter { private val fusion = ImuFusion() - // 1S reports right/down/forward. Rotate 180 degrees about X into the - // shared right/up/back basis before either gravity alignment or fusion. - // Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ. fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs, Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z), Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z)) } -/** Gyro integration with gravity correction in the shared right/up/back basis, rad/s and device ns. */ internal class ImuFusion { private var previous = 0L private var rotation = Quaternion.IDENTITY @@ -27,7 +22,7 @@ internal class ImuFusion { previous = timeNs val norm = accel.length() val gravityValid = abs(norm - 9.81f) < 1.5f - if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion. + if (dt > 0.25f) initialized = false if (!initialized) { if (!gravityValid) return null val up = accel.normalized() @@ -47,7 +42,6 @@ internal class ImuFusion { } rotation = rotation.integrateBodyRate(rate, dt).normalized() } - // Same right/up/back -> shared glasses basis used by the RayNeo adapter. return RayNeoOrientationFilter.glassesPose(floatArrayOf(rotation.w, rotation.x, rotation.y, rotation.z)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt index d18ca1a94..09bed0179 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt @@ -6,7 +6,6 @@ import java.io.InputStream import java.nio.ByteBuffer import java.nio.ByteOrder -/** Independent implementation of the One-family wire format; see xreal-investigation.md. */ internal object XrealProtocol { const val HOST = "169.254.2.1" const val REPORT_PORT = 52998 @@ -46,7 +45,7 @@ internal object XrealProtocol { fun sample(frame: Frame): Sample? { if (frame.command != REPORT || frame.body.size != 128) return null val b = ByteBuffer.wrap(frame.body).order(ByteOrder.LITTLE_ENDIAN) - if (b.getInt(24) != 11) return null // Magnetometer reports are a different type. + if (b.getInt(24) != 11) return null val time = b.getLong(8) val gyro = Vec3(b.getFloat(28), b.getFloat(32), b.getFloat(36)) val accel = Vec3(b.getFloat(40), b.getFloat(44), b.getFloat(48)) @@ -64,7 +63,6 @@ internal object XrealProtocol { .putInt(4 + payload.size).putInt(transaction or Int.MIN_VALUE).put(payload).array() } - /** No acknowledgement is inferred from a write; match both command and transaction. */ fun response(frame: Frame, command: Int, transaction: Int): ByteArray? { if (frame.command != command || frame.body.size < 4 || ByteBuffer.wrap(frame.body).int != transaction) return null diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt b/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt index a63a80249..fc55247a9 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt @@ -48,10 +48,6 @@ object AutoDriverTrial { fun bad(context: Context): Set = prefs(context).getStringSet("bad", emptySet()).orEmpty() - /** - * Counted before the compositor loads the trial display driver, so a driver that kills the process during - * Vulkan init still counts towards a rollback; sessionStarting runs too late for that. - */ fun displayStarting(context: Context, display: String) { val p = prefs(context) if (p.getString("release", null) == null || p.getString("display", "") != display) return diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt b/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt index 76b6dddc9..6216ccf7d 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt @@ -29,9 +29,10 @@ object AutoPicks { val BUILT_IN = listOf( Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)), Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)), - Rule(setOf(Family.A7XX_LOW), prefer = listOf(DriverPairs.BANNER_710)), + Rule(setOf(Family.A7XX_LOW), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_710)), Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)), - Rule(setOf(Family.A7XX, Family.A6XX), prefer = listOf(DriverPairs.BANNER)), + Rule(setOf(Family.A7XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)), + Rule(setOf(Family.A6XX), prefer = listOf(DriverPairs.BANNER)), Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)), Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)), ) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 3d493c606..c31cd348e 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -52,7 +52,6 @@ object FrameGen { fun apply(context: Context, refreshHz: Float): String? = apply(context, mode(context), refreshHz) - /** Arms [mode] in this process's compositor; the cloud player passes its own mode and frame-rate cap. */ fun apply(context: Context, mode: Mode, refreshHz: Float): String? { when (mode.engine) { ENGINE_LSFG -> { diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriver.java b/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriver.java index d74b5a3f1..42dce3d64 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriver.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriver.java @@ -25,14 +25,6 @@ public final class LinuxVulkanDriver { return values; } - public static String optionLabel(Context context, String value) { - if (value == null || value.isEmpty()) return "Runtime default (built into the runtime)"; - LinuxVulkanDriverManager m = new LinuxVulkanDriverManager(context); - if (!m.isInstalled(value)) return value + " (imported, missing - uses the runtime default)"; - String ver = m.getDriverVersion(value); - return m.getDriverName(value) + (ver.isEmpty() ? "" : " " + ver) + " (imported)"; - } - public static String resolveIcdPath(Context context, String value) { if (value == null || value.isEmpty()) return null; LinuxVulkanDriverManager m = new LinuxVulkanDriverManager(context); diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt index 4d94b6628..8709c3a74 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt @@ -6,7 +6,6 @@ import java.io.RandomAccessFile import java.nio.file.Files import java.nio.file.attribute.BasicFileAttributes -/** Only new compositor output is evidence of a fault in the current session. */ internal class SteamVrDeviceLossLog(private val file: File) { private var identity: Any? = identity() private var position = file.length() @@ -34,7 +33,6 @@ internal class SteamVrDeviceLossLog(private val file: File) { } identity = current prefix = head - // Fault loops can produce megabytes per second. Bound work and keep the newest output. if (input.length() - position > MAX_READ) { position = input.length() - MAX_READ partial = "" diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt index 8c045b68e..d8a546eb5 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt @@ -11,7 +11,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.SessionEvents import java.io.File -/** Process-wide cursor survives Activity recreation; a new session ignores older log errors. */ internal object SteamVrHealth { private var session: String? = null private var log: SteamVrDeviceLossLog? = null @@ -49,7 +48,6 @@ internal class SteamVrRecovery(private val activity: Activity, private val foreg .create() prompt.setOnDismissListener { countdown?.let(handler::removeCallbacks); countdown = null; dialog = null } dialog = prompt - // A touch meant for the game must not silently dismiss the recovery controls. prompt.setCanceledOnTouchOutside(false) prompt.show() if (automatic) { @@ -66,7 +64,6 @@ internal class SteamVrRecovery(private val activity: Activity, private val foreg private fun restart() { if (!foreground()) return - // Commit before crossing processes so a repeated fault cannot create a restart loop. if (!activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() .putLong(LAST_ATTEMPT, System.currentTimeMillis()).commit()) return SessionEvents.record("steamvr.recovery_requested") diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt index c05cc2129..63efb3c87 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt @@ -5,13 +5,11 @@ import org.json.JSONObject import java.io.File internal object SteamVrRecoverySettings { - /** After a graphics crash SteamVR can quarantine our HMD, leaving a server with no VR output. */ fun unblockHeadset(file: File): Boolean { if (!file.isFile) return false val settings = JSONObject(file.readText()) val driver = settings.optJSONObject("driver_droiddeck") ?: return false if (!driver.optBoolean("blocked_by_safe_mode")) return false - // Preserve every other setting, including safe mode and blocks on other drivers. driver.put("blocked_by_safe_mode", false) val atomic = AtomicFile(file) val output = atomic.startWrite() diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt index 671132c4f..0cf01a9d6 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt @@ -14,7 +14,6 @@ import com.droiddeck.launcher.SessionActivity import com.droiddeck.launcher.session.SessionService import org.json.JSONObject -/** A visible, private process survives destruction of the app's native graphics context. */ class SteamVrRestartActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -41,7 +40,6 @@ class SteamVrRestartActivity : Activity() { }, SystemClock::elapsedRealtime, SystemClock::sleep) if (shutdown != SteamVrRecoveryPolicy.Shutdown.COMPLETE) android.util.Log.w("SteamVrRecovery", "Restarting before the session finished stopping: $shutdown") - // Only the verified main process, never a caller-supplied PID or an external app. Process.killProcess(main.pid) val exited = SystemClock.elapsedRealtime() + 5000 while (java.io.File("/proc/${main.pid}").exists() && SystemClock.elapsedRealtime() < exited) SystemClock.sleep(50) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt index 893d569a6..0ac66004b 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt @@ -13,9 +13,6 @@ object TpUbwcHint { @Volatile var activeMode: String? = null private set - // 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. A mismatch corrupts Android UI. - // Samsung's One UI firmware sets it whatever blob version it reports. internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false, mode: String = SessionPrefs.TP_UBWC_AUTO): String? { if (model != 740) return null diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt index 85a1e3844..d58a5a881 100644 --- a/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt @@ -8,7 +8,6 @@ import java.io.FileInputStream import java.nio.ByteBuffer import java.nio.channels.FileChannel -/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */ internal object TrackingPack { const val HAND = "hand_landmarker.task" const val HAND_LITE = "hand_landmarker_lite.task" @@ -32,10 +31,6 @@ internal object TrackingPack { fun model(context: Context, name: String): ByteBuffer = FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) } - /** - * Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the - * already-loaded library by soname, so this must run before any MediaPipe task class initializes. - */ fun loadNative(context: Context): Boolean { if (nativeLoaded) return true synchronized(lock) { @@ -50,7 +45,6 @@ internal object TrackingPack { } } - /** Blocks the calling worker thread until the pack is usable, starting the download if needed. */ fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean = pack.await(context, running, onState) && loadNative(context) } diff --git a/app/src/main/java/com/droiddeck/launcher/hud/BottomHudPresence.kt b/app/src/main/java/com/droiddeck/launcher/hud/BottomHudPresence.kt new file mode 100644 index 000000000..7df9f10fd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/BottomHudPresence.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.hud + +import android.os.SystemClock + +internal object BottomHudPresence { + private const val STALE_MS = 2_500L + + @Volatile private var seenAt = 0L + + fun beat() { + seenAt = SystemClock.elapsedRealtime() + } + + val showing: Boolean get() = seenAt != 0L && SystemClock.elapsedRealtime() - seenAt < STALE_MS +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt index e9c8f8be4..86e9f8f39 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt @@ -3,14 +3,9 @@ package com.droiddeck.launcher.hud import android.content.Context import com.droiddeck.launcher.session.SessionPrefs -/** DroidDeck HUD's settings as one "key=value,…" string (DroidDeckHudView.applyConfig), stored in SessionPrefs. */ internal object DroidDeckHudConfig { - /** - * Bannerlator's defaults (DeckOverlaySettings), with the FPS graph from the old DroidDeck HUD. The HUD starts - * locked, so like the old HUD it lets touches through until it is unlocked to move it. - */ val DEFAULTS: Map = linkedMapOf( - "hudSize" to "minimal", "hudLocked" to "1", "hudScale" to "100", "hudDock" to "none", "hudOpacity" to "80", "hudColor" to "mid", + "hudSize" to "minimal", "hudLocked" to "1", "hudSnap" to "1", "hudScale" to "100", "hudDock" to "none", "hudOpacity" to "80", "hudColor" to "mid", "hudOutline" to "40", "hudOutlineAccent" to "1", "showFPS" to "1", "showFPSGraph" to "1", "showCPUUsage" to "1", "showCPULoad" to "1", "showGPULoad" to "1", "showVram" to "1", "showRAM" to "1", "showPower" to "1", "showTemp" to "1", "showBatteryTemp" to "1", @@ -20,7 +15,6 @@ internal object DroidDeckHudConfig { "tempUnit" to "c", "tempBands" to "1", "tempAuto" to "1", "tempRedCpu" to "90", "tempRedGpu" to "90", "tempRedBat" to "48", ) - /** Switches Bannerlator sets together (DeckOverlaySettings twins). */ private val TWINS = mapOf("showCPUUsage" to "showCPULoad", "showTemp" to "showBatteryTemp", "showEngine" to "showRenderer") fun load(context: Context): MutableMap { diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt index ee2c9f71b..47dba3945 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Environment import android.os.Handler import android.os.Looper +import android.animation.ValueAnimator import android.view.Choreographer import android.view.View import android.view.animation.PathInterpolator @@ -21,10 +22,6 @@ import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import kotlin.math.roundToInt -/** - * DroidDeck HUD on the session screen, in place of the old text HUD: the view, the frame feed from gamescope - * (MangoHud's frame times) and MangoHud's readings, started with the session's first frame. - */ class DroidDeckHudHost(context: Context) { private val context = context.applicationContext private val handler = Handler(Looper.getMainLooper()) @@ -38,6 +35,11 @@ class DroidDeckHudHost(context: Context) { private var polling = false private var wantX = Float.NaN private var wantY = Float.NaN + private var anchorX = Float.NaN + private var snaps = true + private var snapping = false + private var previewFade: ValueAnimator? = null + private var flingX = 0f private var engine = "" private var gpuName = "" private var protonPid = 0 @@ -46,13 +48,10 @@ class DroidDeckHudHost(context: Context) { var onPresentingWindowChanged: (() -> Unit)? = null - /** The frame rate the HUD shows; null while the HUD is off and not counting frames. */ val liveFps: Float? get() = if (polling) feed.stats.fps else null - /** The HUD itself changed a setting: a tap cycles the size, a long press locks it. */ var onConfigChanged: (() -> Unit)? = null - /** Screen point → whether an on-screen control takes a touch there; a locked HUD lets those through. */ var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null private val listener = object : WaylandCompositor.GameListener { @@ -84,7 +83,6 @@ class DroidDeckHudHost(context: Context) { get() = SessionPrefs.hudEnabled(context) && !File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists() - /** Adds the HUD to [root] at [index] (above the game surface, under the session overlay). */ fun attach(root: FrameLayout, index: Int) { if (view != null) return val hud = DroidDeckHudView(root.context) @@ -92,12 +90,29 @@ class DroidDeckHudHost(context: Context) { hud.setMangoReadings(readings) hud.setGpuModel(cleanGpuName(GpuInfo.detect().displayName(context))) hud.onRefresh = Runnable { refreshLabels(hud) } + hud.setOnDraggedListener { rawX, rawY -> + anchorX = Float.NaN; snapping = false + previewFade?.cancel(); previewFade = null + stopMotion(hud) + shrinkPastEdge(hud, rawX, rawY) + } + hud.setOnFlungListener { vx, _ -> flingX = vx } + hud.setOnTabDraggedListener { x, y -> dragTab(hud, x, y) } + hud.setOnTabDroppedListener { dropTab(hud) } + hud.setOnTabDragCancelledListener { snapping = false; keepOnScreen(hud) } hud.setOnMovedListener { x, y -> wantX = x; wantY = y - if (!hud.isStrip) dockEdgeFor(hud)?.let { edge -> setDock(hud, edge); return@setOnMovedListener } + val vx = flingX + flingX = 0f + dockEdgeFor(hud)?.let { edge -> setDock(hud, edge); return@setOnMovedListener } + stopMotion(hud) + fadeDockPreview(hud) + if (snaps && !hud.isStrip) { snapToAnchor(hud, vx); return@setOnMovedListener } + val fromX = hud.x; val fromY = hud.y keepOnScreen(hud) wantX = hud.x; wantY = hud.y SessionPrefs.setDroidDeckHudPosition(context, hud.x, hud.y) + glideFrom(hud, fromX, fromY) } hud.setOnSizeCycledListener { size -> DroidDeckHudConfig.set(context, "hudSize", size); onConfigChanged?.invoke() } hud.setOnDockChangedListener { token -> setDock(hud, DroidDeckHudDock.from(token)) } @@ -124,7 +139,10 @@ class DroidDeckHudHost(context: Context) { } fun applyConfig() { - view?.applyConfig(DroidDeckHudConfig.encode(DroidDeckHudConfig.load(context))) + val config = DroidDeckHudConfig.load(context) + snaps = config["hudSnap"] != "0" + if (!snaps) anchorX = Float.NaN + view?.applyConfig(DroidDeckHudConfig.encode(config)) } fun refresh() { @@ -149,13 +167,11 @@ class DroidDeckHudHost(context: Context) { view?.visibility = View.GONE } - /** Picture-in-picture and similar: keep running, draw nothing. */ fun setHidden(hide: Boolean) { hidden = hide applyShown() } - /** The session screen is stopped (another app in front): draw nothing and stop reading sensors. */ fun setBackgrounded(stopped: Boolean) { backgrounded = stopped applyShown() @@ -166,7 +182,6 @@ class DroidDeckHudHost(context: Context) { val show = !hidden && !backgrounded && SessionState.mode != SessionService.MODE_DESKTOP view?.visibility = if (show) View.VISIBLE else View.GONE showing = show - // Frames are read once per display frame only while the HUD is drawn. if (show != polling) { polling = show if (show) Choreographer.getInstance().postFrameCallback(frameCallback) @@ -174,14 +189,11 @@ class DroidDeckHudHost(context: Context) { } } - /** A sensor report (DroidDeck HUD's diagnostics) in Downloads/DroidDeck; null when it can't be written. */ fun exportDiagnostics(): File? = runCatching { val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck") dir.mkdirs() val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US).format(java.util.Date()) val file = File(dir, "HUD-diagnostics-$stamp.txt") - // Its own reader: the HUD's CPU load is the change since its previous sample, which an export must - // not move. Two samples half a second apart, MangoHud's sampling period. val metrics = HudMetrics(context) metrics.setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) metrics.snapshot() @@ -191,6 +203,7 @@ class DroidDeckHudHost(context: Context) { "CPU ${mango.cpuPercent}% ${mango.cpuTempC}°C, GPU ${mango.gpuPercent}% ${mango.gpuClockMhz}MHz ${mango.gpuTempC}°C, " + "RAM ${mango.ramUsedText()}/${mango.ramTotalText()}, VRAM ${mango.vramText()}, battery ${mango.battery.percent}% " + "${mango.battery.watts}W charging=${mango.battery.charging}\n" + + "GPU feeds: ${com.droiddeck.launcher.session.GpuStatsComponent.sources.ifEmpty { "none (no session, or kgsl absent)" }}\n" + "CPU temperature zones: ${LinuxRuntime.cpuTempZones().joinToString { it.name }}\n" + "Frames: ${if (feed.engine.isNotEmpty() || feed.pid > 0) "gamescope (pid ${feed.pid}, ${feed.engine})" else "compositor"}, " + "fps ${feed.stats.fps}\n") @@ -228,35 +241,136 @@ class DroidDeckHudHost(context: Context) { return if (stats.size > 3 && stats[3] > 1f) stats[3] else 0f } - /** The edge the HUD was dropped over, once a good part of it hangs past that edge; null when it stays. */ private fun dockEdgeFor(v: View): DroidDeckHudDock? { - val p = v.parent as? View ?: return null - if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return null - val over = mapOf( - DroidDeckHudDock.LEFT to -v.x / v.width, - DroidDeckHudDock.RIGHT to (v.x + v.width - p.width) / v.width, - DroidDeckHudDock.TOP to -v.y / v.height, - DroidDeckHudDock.BOTTOM to (v.y + v.height - p.height) / v.height, - ).maxByOrNull { it.value } ?: return null + val over = overhang(v) ?: return null return over.key.takeIf { over.value >= DOCK_OVERHANG } } - /** Tucks the HUD against [edge] as its FPS tab, or with [DroidDeckHudDock.NONE] brings it back out from where it was docked. */ + private fun overhang(v: View): Map.Entry? { + val p = v.parent as? View ?: return null + if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return null + val strip = (v as? DroidDeckHudView)?.isStrip == true + val density = v.resources.displayMetrics.density + val w = if (strip) minOf(v.width.toFloat(), STRIP_PULL_ACROSS_DP * density) else v.width.toFloat() + val h = if (strip) maxOf(v.height.toFloat(), STRIP_PULL_OFF_DP * density) else v.height.toFloat() + return mapOf( + DroidDeckHudDock.LEFT to -v.x / w, + DroidDeckHudDock.RIGHT to (v.x + v.width - p.width) / w, + DroidDeckHudDock.TOP to -v.y / h, + DroidDeckHudDock.BOTTOM to (v.y + v.height - p.height) / h, + ).maxByOrNull { it.value } + } + + private fun dragTab(hud: DroidDeckHudView, x: Float, y: Float) { + val p = hud.parent as? View ?: return + stopMotion(hud) + snapping = false + hud.x = x.coerceIn(0f, (p.width - hud.width).toFloat().coerceAtLeast(0f)) + hud.y = y.coerceIn(0f, (p.height - hud.height).toFloat().coerceAtLeast(0f)) + } + + private fun dropTab(hud: DroidDeckHudView) { + val p = hud.parent as? View ?: return + val maxX = (p.width - hud.width).toFloat().coerceAtLeast(0f) + val maxY = (p.height - hud.height).toFloat().coerceAtLeast(0f) + val pulled = when (hud.dock) { + DroidDeckHudDock.LEFT -> hud.x / hud.width + DroidDeckHudDock.RIGHT -> (maxX - hud.x) / hud.width + DroidDeckHudDock.TOP -> hud.y / hud.height + DroidDeckHudDock.BOTTOM -> (maxY - hud.y) / hud.height + DroidDeckHudDock.NONE -> 0f + } + if (pulled >= 1f) { setDock(hud, DroidDeckHudDock.NONE); return } + wantX = hud.x; wantY = hud.y + val (x, y) = settled(p, hud.dock, hud.width.toFloat(), hud.height.toFloat()) + wantX = x; wantY = y + SessionPrefs.setDroidDeckHudPosition(context, x, y) + if (Motion.scale == 0f) { hud.x = x; hud.y = y; return } + snapping = true + hud.animate().x(x).y(y).setDuration(Motion.ms(220).toLong()).setInterpolator(EASE) + .withEndAction { snapping = false }.start() + } + + private fun stopMotion(hud: DroidDeckHudView) { + hud.animate().setUpdateListener(null) + hud.animate().cancel() + } + + private fun glideFrom(hud: DroidDeckHudView, fromX: Float, fromY: Float) { + if (Motion.scale == 0f || (fromX == hud.x && fromY == hud.y && hud.scaleX == 1f && hud.scaleY == 1f)) { + hud.scaleX = 1f; hud.scaleY = 1f + return + } + val toX = hud.x; val toY = hud.y + hud.x = fromX; hud.y = fromY + snapping = true + hud.animate().x(toX).y(toY).scaleX(1f).scaleY(1f).setDuration(Motion.ms(220).toLong()).setInterpolator(EASE) + .withEndAction { snapping = false; keepOnScreen(hud) }.start() + } + + private fun fadeDockPreview(hud: DroidDeckHudView) { + val from = hud.dockPreviewBlend + if (from <= 0f) return + val edge = overhang(hud)?.key ?: DroidDeckHudDock.NONE + if (Motion.scale == 0f) { hud.setDockPreview(DroidDeckHudDock.NONE, 0f); return } + previewFade?.cancel() + previewFade = ValueAnimator.ofFloat(from, 0f).apply { + duration = Motion.ms(200).toLong() + addUpdateListener { hud.setDockPreview(edge, it.animatedValue as Float) } + start() + } + } + + private fun snapToAnchor(hud: DroidDeckHudView, vx: Float) { + val p = hud.parent as? View ?: return + val maxX = (p.width - hud.width).toFloat().coerceAtLeast(0f) + val maxY = (p.height - hud.height).toFloat().coerceAtLeast(0f) + anchorX = if (hud.x + vx * FLING_REACH_S < maxX / 2f) 0f else 1f + wantX = anchorX * maxX + wantY = hud.y.coerceIn(0f, maxY) + SessionPrefs.setDroidDeckHudPosition(context, wantX, wantY) + stopMotion(hud) + if (Motion.scale == 0f) { + hud.scaleX = 1f; hud.scaleY = 1f + hud.x = wantX; hud.y = wantY + return + } + snapping = true + hud.animate().x(wantX).y(wantY).scaleX(1f).scaleY(1f).setDuration(Motion.ms(260).toLong()).setInterpolator(EASE) + .withEndAction { snapping = false; keepOnScreen(hud) }.start() + } + + private fun shrinkPastEdge(hud: DroidDeckHudView, rawX: Float, rawY: Float) { + if (hud.dock != DroidDeckHudDock.NONE) return + val p = hud.parent as? View ?: return + val edge = overhang(hud) ?: return + val over = edge.value + val t = (over / DOCK_OVERHANG).coerceIn(0f, 1f) + val fade = ((t - PREVIEW_FROM) / (1f - PREVIEW_FROM)).coerceIn(0f, 1f) + hud.setDockPreview(edge.key, fade * fade * (3f - 2f * fade)) + val (tabW, tabH) = hud.dockTabSize() + val e = t * t * (3f - 2f * t) + val at = IntArray(2).also { p.getLocationOnScreen(it) } + hud.pivotX = rawX - at[0] - hud.x + hud.pivotY = rawY - at[1] - hud.y + if (hud.isStrip) { + hud.scaleX = 1f + (tabW / hud.width - 1f) * e + hud.scaleY = 1f + return + } + val tabScale = maxOf(tabW / hud.width, tabH / hud.height).coerceAtMost(1f) + val s = 1f + (tabScale - 1f) * e + hud.scaleX = s; hud.scaleY = s + } + private fun setDock(hud: DroidDeckHudView, edge: DroidDeckHudDock) { if (edge == hud.dock) return animateDock(hud, edge) { commitDock(hud, edge) } } private fun commitDock(hud: DroidDeckHudView, edge: DroidDeckHudDock) { - if (edge == DroidDeckHudDock.NONE) { - when (hud.dock) { - DroidDeckHudDock.LEFT -> wantX = 0f - DroidDeckHudDock.RIGHT -> wantX = Float.MAX_VALUE - DroidDeckHudDock.TOP -> wantY = 0f - DroidDeckHudDock.BOTTOM -> wantY = Float.MAX_VALUE - DroidDeckHudDock.NONE -> {} - } - } else { + val tabX = hud.x; val tabY = hud.y + if (edge != DroidDeckHudDock.NONE) { val p = hud.parent as? View if (p != null) { wantX = wantX.coerceIn(0f, p.width.toFloat()) @@ -266,35 +380,55 @@ class DroidDeckHudHost(context: Context) { } DroidDeckHudConfig.set(context, "hudDock", edge.token) applyConfig() + if (edge == DroidDeckHudDock.NONE) settleOutOfTab(hud, tabX, tabY) hud.post { keepOnScreen(hud) } onConfigChanged?.invoke() } - /** - * One shape contracts into the tab, and the tab expands back out to the HUD: the view is scaled onto the - * tab's exact rectangle, and the layouts swap at that same size and place. Timing and curve are the - * launcher's own ([Motion]), and off when animations are off. - */ - private fun animateDock(hud: DroidDeckHudView, edge: DroidDeckHudDock, commit: () -> Unit) { - hud.animate().cancel() - hud.scaleX = 1f; hud.scaleY = 1f; hud.alpha = 1f + private fun settleOutOfTab(hud: DroidDeckHudView, tabX: Float, tabY: Float) { val p = hud.parent as? View - if (Motion.scale == 0f || p == null || !hud.isLaidOut || hud.width <= 0) { commit(); return } + val maxX = if (p == null) Float.MAX_VALUE else (p.width - hud.contentWidth).coerceAtLeast(0f) + val maxY = if (p == null) Float.MAX_VALUE else (p.height - hud.contentHeight).coerceAtLeast(0f) + wantX = tabX.coerceIn(0f, maxX); wantY = tabY.coerceIn(0f, maxY) + anchorX = if (snaps && !hud.isStrip && p != null) (if (wantX < maxX / 2f) 0f else 1f) else Float.NaN + if (!anchorX.isNaN()) wantX = anchorX * maxX + SessionPrefs.setDroidDeckHudPosition(context, wantX, wantY) + } + + private fun animateDock(hud: DroidDeckHudView, edge: DroidDeckHudDock, commit: () -> Unit) { + stopMotion(hud) + previewFade?.cancel(); previewFade = null + snapping = false + hud.alpha = 1f + val p = hud.parent as? View + if (Motion.scale == 0f || p == null || !hud.isLaidOut || hud.width <= 0) { + hud.scaleX = 1f; hud.scaleY = 1f + hud.setDockPreview(DroidDeckHudDock.NONE, 0f) + commit(); return + } if (edge != DroidDeckHudDock.NONE) { + val left = hud.x + hud.pivotX * (1f - hud.scaleX); val top = hud.y + hud.pivotY * (1f - hud.scaleY) + val shownW = hud.width * hud.scaleX; val shownH = hud.height * hud.scaleY + hud.pivotX = 0f; hud.pivotY = 0f + hud.x = left; hud.y = top val (tabW, tabH) = hud.dockTabSize() + wantX = left + (shownW - tabW) / 2f + wantY = top + (shownH - tabH) / 2f val (tabX, tabY) = settled(p, edge, tabW, tabH) val sx = tabW / hud.width; val sy = tabH / hud.height - hud.pivotX = pivotFor(hud.x, tabX, sx) - hud.x - hud.pivotY = pivotFor(hud.y, tabY, sy) - hud.y - hud.animate().scaleX(sx).scaleY(sy).setDuration(Motion.ms(300).toLong()).setInterpolator(EASE) + val from = hud.dockPreviewBlend + hud.animate().x(tabX).y(tabY).scaleX(sx).scaleY(sy).setDuration(Motion.ms(220).toLong()).setInterpolator(EASE) + .setUpdateListener { hud.setDockPreview(edge, from + (1f - from) * it.animatedFraction) } .withEndAction { + hud.animate().setUpdateListener(null) commit() + hud.setDockPreview(DroidDeckHudDock.NONE, 0f) hud.scaleX = 1f; hud.scaleY = 1f hud.x = tabX; hud.y = tabY - hud.alpha = 0.5f - hud.animate().alpha(1f).setDuration(Motion.ms(120).toLong()).start() }.start() } else { + hud.scaleX = 1f; hud.scaleY = 1f + val was = hud.dock val tabX = hud.x; val tabY = hud.y val tabW = hud.width.toFloat(); val tabH = hud.height.toFloat() commit() @@ -305,15 +439,20 @@ class DroidDeckHudHost(context: Context) { hud.pivotX = pivotFor(x, tabX, sx) - x hud.pivotY = pivotFor(y, tabY, sy) - y hud.scaleX = sx; hud.scaleY = sy - hud.animate().scaleX(1f).scaleY(1f).setDuration(Motion.ms(360).toLong()).setInterpolator(EASE).start() + hud.setDockPreview(was, 1f) + hud.animate().scaleX(1f).scaleY(1f).setDuration(Motion.ms(360).toLong()).setInterpolator(EASE) + .setUpdateListener { + val f = (1f - it.animatedFraction / EXPAND_FADE).coerceIn(0f, 1f) + hud.setDockPreview(was, f * f * (3f - 2f * f)) + } + .withEndAction { hud.animate().setUpdateListener(null); hud.setDockPreview(DroidDeckHudDock.NONE, 0f) } + .start() } } - /** The pivot, in parent coordinates, that scaling by [scale] takes a span starting at [from] onto one starting at [to]. */ private fun pivotFor(from: Float, to: Float, scale: Float): Float = if (scale > 0.999f) from else (to - from * scale) / (1f - scale) - /** Where a [w] x [h] HUD or tab sits once [keepOnScreen] has settled it against [dock]. */ private fun settled(p: View, dock: DroidDeckHudDock, w: Float, h: Float): Pair { val maxX = (p.width - w).coerceAtLeast(0f); val maxY = (p.height - h).coerceAtLeast(0f) var x = wantX.coerceIn(0f, maxX); var y = wantY.coerceIn(0f, maxY) @@ -327,15 +466,18 @@ class DroidDeckHudHost(context: Context) { return Pair(x, y) } - // The HUD is clamped from where the user left it (wantX/wantY, set on a drop or restore), not from where - // it is drawn: a window that shrinks and grows back (picture-in-picture, a fold) must not leave it in the - // corner the small window pushed it to. private fun keepOnScreen(v: View) { + if (snapping) return val p = v.parent as? View ?: return if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return if (wantX.isNaN()) { wantX = v.x; wantY = v.y } val maxX = (p.width - v.width).toFloat().coerceAtLeast(0f) val maxY = (p.height - v.height).toFloat().coerceAtLeast(0f) + val anchored = (v as? DroidDeckHudView)?.let { it.dock == DroidDeckHudDock.NONE && !it.isStrip } == true + if (snaps && anchored && anchorX.isNaN()) { + anchorX = if (wantX.coerceIn(0f, maxX) < maxX / 2f) 0f else 1f + } + if (snaps && anchored && !anchorX.isNaN()) wantX = anchorX * maxX var x = wantX.coerceIn(0f, maxX) var y = wantY.coerceIn(0f, maxY) if ((v as? DroidDeckHudView)?.isStrip == true) y = if (y + v.height / 2f < p.height / 2f) 0f else maxY @@ -351,18 +493,19 @@ class DroidDeckHudHost(context: Context) { } companion object { - /** How much of the HUD, as a share of its own size, must hang past a screen edge for it to dock there. */ private const val DOCK_OVERHANG = 0.4f + private const val FLING_REACH_S = 0.18f + private const val PREVIEW_FROM = 0.55f + private const val STRIP_PULL_ACROSS_DP = 600f + private const val STRIP_PULL_OFF_DP = 150f + private const val EXPAND_FADE = 0.5f private val EASE = PathInterpolator(0.2f, 0.8f, 0.2f, 1f) - /** True while the HUD is on screen; DroidDeck keeps sampling VRAM for it (GpuMemComponent). */ @Volatile @JvmStatic var showing = false private set - /** "Adreno (TM) 750" as the driver names it, "Adreno 750" as DroidDeck does elsewhere. */ internal fun cleanGpuName(name: String): String = name.replace("(TM)", "").replace(Regex("\\s+"), " ").trim() - /** The Proton build of a game process: the first STEAM_COMPAT_TOOL_PATHS entry's folder. */ internal fun protonName(pid: Int): String? = runCatching { val environ = File("/proc/$pid/environ").readBytes().toString(Charsets.UTF_8).split('\u0000') val paths = environ.firstOrNull { it.startsWith("STEAM_COMPAT_TOOL_PATHS=") }?.substringAfter('=') ?: return null diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudModels.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudModels.kt index ab61d670b..9f59512e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudModels.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudModels.kt @@ -8,16 +8,11 @@ * link to https://github.com/The412Banner/FusionHUD in the project documentation * AND in the in-app credits/about screen. * - * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck. */ package com.droiddeck.launcher.hud -/** - * Size modes for the DroidDeck HUD (the 4th selectable in-game overlay style). One shared color-coded - * visual language, four amounts of detail — see the approved mockup. The config value is the lower-case - * token stored under the `hudSize` key; the label is the user-facing string. - */ enum class DroidDeckHudSize(val token: String, val label: String) { FULL("full", "Full"), TILES("tiles", "Tiles"), @@ -26,18 +21,11 @@ enum class DroidDeckHudSize(val token: String, val label: String) { MEGA("mega", "Mega"), STRIP("strip", "Strip"); - /** Next size in the tap-cycle: Full → Tiles → Pill → Minimal → Mega → Strip → Full. */ fun next(): DroidDeckHudSize { val all = values() return all[(ordinal + 1) % all.size] } - /** - * The metric toggle chips this size actually renders — the SINGLE source of truth queried by BOTH - * config surfaces ({@code FpsCounterConfigDialog} + in-game {@code XServerDrawer} HudContent) so the - * chip list can't drift from what the view draws. Chip ids are the chip LABELS used in the UI. - * (Hiding a chip is purely UI; buildConfig() still emits every key — strip-invariant.) - */ fun supportedChips(): Set = when (this) { MINIMAL -> MINIMAL_CHIPS PILL -> PILL_CHIPS @@ -49,11 +37,6 @@ enum class DroidDeckHudSize(val token: String, val label: String) { fun from(token: String?): DroidDeckHudSize = values().firstOrNull { it.token.equals(token, ignoreCase = true) } ?: FULL - // Chip-label ids (must match the labels the config UIs build). "Clock" is a small subtle - // corner readout available in EVERY size, so it's in all sets. - // The API/engine label ("Engine" chip) shows on the FPS row in EVERY size, so it's in all sets. - // "Wrapper" and "DX ver" are Mega-only: the wrapper appears solely below Mega's frametime graph, - // and "DX ver" gates the DX-version suffix on Mega's FPS-row engine label. val MINIMAL_CHIPS = setOf("FPS", "FPS graph", "0.01% low", "FPS .1", "Engine", "Clock") val PILL_CHIPS = setOf("FPS", "FPS graph", "GPU", "CPU", "VRAM", "RAM", "Battery", "GPU model", "FPS .1", "Engine", "Clock") val STANDARD_CHIPS = setOf( @@ -67,7 +50,6 @@ enum class DroidDeckHudSize(val token: String, val label: String) { } } -/** The screen edge the HUD is tucked against, where only its FPS tab shows ([NONE] = floating free). */ enum class DroidDeckHudDock(val token: String) { NONE("none"), LEFT("left"), RIGHT("right"), TOP("top"), BOTTOM("bottom"); @@ -76,21 +58,12 @@ enum class DroidDeckHudDock(val token: String) { } } -/** - * The Wayland HDR state the DroidDeck HUD shows on its own line, directly under the latency · display-server - * line (the host sets it with [DroidDeckHudView.setHdrState]). [NONE] = no line at all: every session whose - * HDR gate is closed (X11, SDR screens, HDR off) keeps the HUD exactly as it was. - */ object DroidDeckHudHdr { const val NONE = 0 - const val ON = 1 // HDR frames on screen with HDR headroom - const val NO_HEADROOM = 2 // HDR frames on screen, but the display gives them no headroom (5 s+) - const val OFF = 3 // the drawer's HDR output switch is off (tone-mapped to SDR) - const val READY = 4 // HDR open for the session, no HDR frames on screen right now - const val TONEMAPPED = 5 // HDR output on, but the frames are shown tone-mapped (frame generation - // on a screen that offers no HDR10 swapchain) - const val NOT_ON_THIS_SCREEN = 6 // HDR open for the session, but the screen the game is on NOW - // reports no HDR10 (the TV was unplugged mid-game). The offer to the - // game cannot be withdrawn once made, so the gate stays open for the - // session — but "ready" would be a lie on a panel that cannot show it. + const val ON = 1 + const val NO_HEADROOM = 2 + const val OFF = 3 + const val READY = 4 + const val TONEMAPPED = 5 + const val NOT_ON_THIS_SCREEN = 6 } diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt index 664cb7f65..1ab46aa87 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt @@ -8,7 +8,7 @@ * link to https://github.com/The412Banner/FusionHUD in the project documentation * AND in the in-app credits/about screen. * - * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck. */ package com.droiddeck.launcher.hud @@ -24,9 +24,9 @@ import android.graphics.RectF import android.graphics.Typeface import android.os.SystemClock import android.text.format.DateFormat -import android.view.GestureDetector import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import androidx.annotation.VisibleForTesting import androidx.compose.ui.graphics.toArgb import com.droiddeck.launcher.session.SessionPrefs @@ -53,28 +53,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -/** - * DroidDeck HUD — the 4th selectable in-game overlay. One color-coded visual language rendered at four - * SIZE modes (Full / Tiles / Pill / Minimal / Mega), all driven by [FrameStats] (gamescope's frame times - * read by MangoHud's rules) + a single cached [HudMetrics.Snapshot]. Pure Canvas drawing. - * - * DroidDeck: the readouts refresh every 500 ms (MangoHud's sampling period) on a Main-dispatcher - * coroutine, collecting the snapshot on Dispatchers.IO; the frame graph is redrawn whenever new frames - * arrive ([onFrames]), as MangoHud draws it. - * - * Gestures go through [HudLockController]: long-press toggles the position lock (with a lock/unlock - * badge fade), a tap cycles the size (Full→Tiles→Pill→Minimal→Full), and a drag repositions — tap + - * drag are frozen while locked. A locked HUD lets a touch through where an on-screen control under it - * takes it ([passesTouchAt]); everywhere else it keeps the touch, so a long press there unlocks it. - */ class DroidDeckHudView(context: Context) : View(context) { private var frames: FrameStats? = null internal fun setFrameStats(stats: FrameStats?) { frames = stats } internal fun setMangoReadings(readings: MangoReadings?) { metrics.setMangoReadings(readings) } - // Frames per second actually reaching the panel while DroidDeck's frame generation adds frames the - // game never made; 0 = nothing is adding frames, and the readout is the game's rate alone. private var presentedFps = 0f fun setPresentedFps(fps: Float) { presentedFps = fps } @@ -85,7 +69,6 @@ class DroidDeckHudView(context: Context) : View(context) { private val density = resources.displayMetrics.density - // ---- Config ----------------------------------------------------------- private var size = DroidDeckHudSize.FULL private var scale = 1f private var bgOpacity = 0.8f @@ -109,8 +92,7 @@ class DroidDeckHudView(context: Context) : View(context) { private var showGraph = false private var showLow001 = true private var fpsDecimal = true - private var showClockTime = true // subtle corner/footer clock, available in EVERY size - // Mega-only rows + private var showClockTime = true private var showPerCore = true private var showSwap = true private var showNet = true @@ -121,21 +103,15 @@ class DroidDeckHudView(context: Context) : View(context) { private var showSession = true private var engineLabel = "" - private var displayServer = "" // "X11" / "Wayland": which display server the game runs on - private var hdrState = DroidDeckHudHdr.NONE // Wayland HDR, on its own line under latency · display server + private var displayServer = "" + private var hdrState = DroidDeckHudHdr.NONE private var gpuModel = "" - // The GPU name a Wayland session reports to games instead of the real adapter ("" = none). It has - // to travel separately from gpuModel: Wayland hands the spoof to DXVK and leaves the Vulkan device - // alone, so everything the host reads back is still the real chip. On X11 the driver wrapper renames - // the device itself, so there gpuModel already IS the spoofed name and this stays empty. private var gpuSpoofName = "" - // Stack-layer version strings (Mega bottom band + DX version on the engine row); fed by the host. - private var wineVersion = "" // "Proton 10.0-4" - private var graphicsWrapper = "" // graphics-driver wrapper package, e.g. "GameNative", "bcn_layer 20260719" - private var dxvkVersion = "" // DXVK version, appended to a DXVK engine row - private var vkd3dVersion = "" // VKD3D version, appended to a VKD3D engine row + private var wineVersion = "" + private var graphicsWrapper = "" + private var dxvkVersion = "" + private var vkd3dVersion = "" - // ---- Live data (updated on the refresh coroutine, read on draw) -------- private var snap: HudMetrics.Snapshot? = null private var fpsNow = 0f private var fpsAvg = 0f @@ -146,25 +122,17 @@ class DroidDeckHudView(context: Context) : View(context) { private val graphPath = Path() private var resolution = "" - // DroidDeck look: the theme's main colour for the FPS label, graph and accent outline, the theme's - // surface behind the readouts, a DroidDeck header (wordmark) on Full/Tiles/Minimal/Mega and the logo - // chip on the Pill. private var accent = 0xFF1A9FFF.toInt() private var surfaceRgb = 0x121417 private var headerMark: RectF? = null private var chipRect: RectF? = null - // Pushed past a screen edge, the HUD shrinks to a tab with the FPS that stays at that edge. var dock = DroidDeckHudDock.NONE private set private var tabRect: RectF? = null - // The Strip is a bar across the whole screen, kept against the top or bottom edge. private var stripRect: RectF? = null private val tabPath = Path() - // Every size sits in the DroidDeck mark's D: a straight left side, round on the - // right. Its fixed layers (fill, header band, watermark, glowing outline) go into a bitmap, because - // Android before 9 skips glow on shapes drawn with hardware acceleration. private var dPanel: RectF? = null private var dRadius = 0f private var dDividerTop = -1f @@ -172,7 +140,6 @@ class DroidDeckHudView(context: Context) : View(context) { private var dFrameKey = "" private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG) - // ---- Colors (mockup) -------------------------------------------------- private val colGpu = 0xFF5EE08A.toInt() private val colCpu = 0xFF58A6FF.toInt() private val colVram = 0xFFC98BFF.toInt() @@ -183,10 +150,9 @@ class DroidDeckHudView(context: Context) : View(context) { private val colValue = 0xFFF2F5F9.toInt() private val colDim = 0xFF9AA4B2.toInt() private val colLo = 0xFFE4E8EE.toInt() - private val colDisp = 0xFF4DD0E1.toInt() // display server (X11 / Wayland) - private val colHdr = 0xFFFFD54F.toInt() // HDR on screen + private val colDisp = 0xFF4DD0E1.toInt() + private val colHdr = 0xFFFFD54F.toInt() - // ---- Paints ----------------------------------------------------------- private val measurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) } @@ -205,16 +171,10 @@ class DroidDeckHudView(context: Context) : View(context) { typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) } - // ---- Built geometry --------------------------------------------------- private class Glyph(val x: Float, val baseline: Float, val text: String, val color: Int, val sizePx: Float, val sans: Boolean = false, val glow: Boolean = false) { fun moved(dx: Float, dy: Float) = Glyph(x + dx, baseline + dy, text, color, sizePx, sans, glow) } private class Span(val text: String, val color: Int, val sizePx: Float, val glow: Boolean = false) - /** - * A "label + value-run" row for the aligned grid sizes (Full / Mega). When [inline] is true the row - * opts OUT of the shared label column — its value run is placed directly after the label — so a long - * label (the graphics-wrapper on the FPS row) can't widen the column and shove every value right. - */ private class HudRow(val label: Span, val vals: List, val inline: Boolean = false) private val glyphs = ArrayList() private val tileRects = ArrayList() @@ -222,7 +182,6 @@ class DroidDeckHudView(context: Context) : View(context) { private var contentW = 0f private var contentH = 0f - // ---- Listeners -------------------------------------------------------- private var onMovedListener: BiConsumer? = null private var onSizeCycledListener: Consumer? = null private var onLockChangedListener: Consumer? = null @@ -231,13 +190,18 @@ class DroidDeckHudView(context: Context) : View(context) { fun setOnLockChangedListener(l: Consumer?) { onLockChangedListener = l } private var onDockChangedListener: Consumer? = null private var onScaledListener: IntConsumer? = null + private var onDraggedListener: BiConsumer? = null + fun setOnDraggedListener(l: BiConsumer?) { onDraggedListener = l } + private var onFlungListener: BiConsumer? = null + fun setOnFlungListener(l: BiConsumer?) { onFlungListener = l } fun setOnDockChangedListener(l: Consumer?) { onDockChangedListener = l } - /** A pinch settled on this size, in percent. */ fun setOnScaledListener(l: IntConsumer?) { onScaledListener = l } private val lockController = HudLockController(context, this, object : HudLockController.Callbacks { override fun onTap() { cycleSize() } override fun onMoved(x: Float, y: Float) { onMovedListener?.accept(x, y) } + override fun onDragged(rawX: Float, rawY: Float) { onDraggedListener?.accept(rawX, rawY) } + override fun onFlung(velocityX: Float, velocityY: Float) { onFlungListener?.accept(velocityX, velocityY) } override fun onLockChanged(locked: Boolean) { onLockChangedListener?.accept(locked) } override fun onPinch(factor: Float) { val next = (scale * factor).coerceIn(MIN_SCALE, MAX_SCALE) @@ -248,50 +212,62 @@ class DroidDeckHudView(context: Context) : View(context) { override fun onPinchEnd() { onScaledListener?.accept((scale * 100f).roundToInt()) } }) - // A docked tab always takes touches, locked or not: a tap or a pull away from the edge brings the HUD back. - private val dockDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(e: MotionEvent): Boolean = true - override fun onSingleTapUp(e: MotionEvent): Boolean { onDockChangedListener?.accept(DroidDeckHudDock.NONE.token); return true } - override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { - if (e1 == null) return false - val inward = when (dock) { - DroidDeckHudDock.LEFT -> e2.rawX - e1.rawX - DroidDeckHudDock.RIGHT -> e1.rawX - e2.rawX - DroidDeckHudDock.TOP -> e2.rawY - e1.rawY - DroidDeckHudDock.BOTTOM -> e1.rawY - e2.rawY - DroidDeckHudDock.NONE -> 0f + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var tabDownRawX = 0f + private var tabDownRawY = 0f + private var tabStartX = 0f + private var tabStartY = 0f + private var tabDragging = false + private var onTabDraggedListener: BiConsumer? = null + private var onTabDroppedListener: Runnable? = null + private var onTabDragCancelledListener: Runnable? = null + fun setOnTabDraggedListener(l: BiConsumer?) { onTabDraggedListener = l } + fun setOnTabDroppedListener(l: Runnable?) { onTabDroppedListener = l } + fun setOnTabDragCancelledListener(l: Runnable?) { onTabDragCancelledListener = l } + + private fun onTabTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + tabDownRawX = event.rawX; tabDownRawY = event.rawY + tabStartX = x; tabStartY = y + tabDragging = false } - if (inward > 12f * density) onDockChangedListener?.accept(DroidDeckHudDock.NONE.token) - return true + MotionEvent.ACTION_MOVE -> { + val dx = event.rawX - tabDownRawX; val dy = event.rawY - tabDownRawY + if (!tabDragging && dx * dx + dy * dy > touchSlop * touchSlop) tabDragging = true + if (tabDragging) onTabDraggedListener?.accept(tabStartX + dx, tabStartY + dy) + } + MotionEvent.ACTION_UP -> { + if (tabDragging) { tabDragging = false; onTabDroppedListener?.run() } + else onDockChangedListener?.accept(DroidDeckHudDock.NONE.token) + } + MotionEvent.ACTION_CANCEL -> if (tabDragging) { + tabDragging = false + onTabDragCancelledListener?.run() + } } - }) + return true + } + + private var previewEdge = DroidDeckHudDock.NONE + private var previewBlend = 0f + internal fun setDockPreview(edge: DroidDeckHudDock, blend: Float) { + val b = blend.coerceIn(0f, 1f) + if (edge == previewEdge && b == previewBlend) return + previewEdge = edge; previewBlend = b + invalidate() + } + internal val dockPreviewBlend: Float get() = previewBlend - // ---- Public surface (symmetric with the other overlays) --------------- fun setEngineLabel(s: String?) { if ((s ?: "") != engineLabel) { engineLabel = s ?: ""; post { rebuildAndInvalidate() } } } fun setGpuModel(s: String?) { if ((s ?: "") != gpuModel) { gpuModel = s ?: ""; post { rebuildAndInvalidate() } } } - /** This session's Wayland GPU-name spoof (null / empty = none, the default): the GPU row names it - * in place of the real model, marked so it can't be read as the chip that is really rendering. */ - fun setGpuSpoofName(s: String?) { gpuSpoofName = s ?: ""; post { rebuildAndInvalidate() } } - fun setDisplayServer(s: String?) { displayServer = s ?: ""; post { rebuildAndInvalidate() } } - /** [DroidDeckHudHdr] code; [DroidDeckHudHdr.NONE] (the default) draws no HDR line anywhere. Any thread. */ - fun setHdrState(state: Int) { - val v = if (state in DroidDeckHudHdr.ON..DroidDeckHudHdr.NOT_ON_THIS_SCREEN) state else DroidDeckHudHdr.NONE - post { if (v != hdrState) { hdrState = v; rebuildAndInvalidate() } } - } fun setWineVersion(s: String?) { if ((s ?: "") != wineVersion) { wineVersion = s ?: ""; post { rebuildAndInvalidate() } } } - fun setGraphicsWrapper(s: String?) { graphicsWrapper = s ?: ""; post { rebuildAndInvalidate() } } - fun setDxWrapper(dxvk: String?, vkd3d: String?) { - dxvkVersion = dxvk ?: ""; vkd3dVersion = vkd3d ?: ""; post { rebuildAndInvalidate() } - } - /** The game's output from gamescope ("1280x720 @ 144Hz"), as MangoHud shows it; "" = this display. */ fun setResolution(s: String?) { resolution = s ?: "" } private val periodDone = Channel(Channel.CONFLATED) private var seenFpsUpdates = 0L - /** New frames arrived: redraw the graph (MangoHud redraws it every frame), and the readouts when a - * sampling period completed, at the moment MangoHud redraws its own. */ fun onFrames() { val updates = frames?.fpsUpdates ?: 0L if (updates != seenFpsUpdates) { @@ -301,10 +277,6 @@ class DroidDeckHudView(context: Context) : View(context) { if (graphRect != null) invalidate() } - /** The HUD has no orientation (tap cycles size instead); kept for a symmetric host surface. */ - fun setVertical(vertical: Boolean) { /* no-op */ } - fun isVertical(): Boolean = false - private fun cycleSize() { size = size.next() onSizeCycledListener?.accept(size.token) @@ -331,7 +303,7 @@ class DroidDeckHudView(context: Context) : View(context) { showGraph = cfg.get("showFPSGraph", "0") == "1" showLow001 = cfg.get("showLow001", "1") == "1" fpsDecimal = cfg.get("fpsDecimal", "1") == "1" - showClockTime = cfg.get("showClock", "1") == "1" // the clock defaults ON + showClockTime = cfg.get("showClock", "1") == "1" showPerCore = cfg.get("showPerCore", "1") == "1" showSwap = cfg.get("showSwap", "1") == "1" showNet = cfg.get("showNet", "1") == "1" @@ -356,7 +328,6 @@ class DroidDeckHudView(context: Context) : View(context) { rebuildAndInvalidate() } - // ---- Lifecycle: self-refresh ------------------------------------------ override fun onAttachedToWindow() { super.onAttachedToWindow() if (isShown) startUpdates() @@ -367,8 +338,6 @@ class DroidDeckHudView(context: Context) : View(context) { super.onDetachedFromWindow() } - // The host hides the HUD with GONE (HUD off, picture-in-picture) and keeps it attached: read the - // sensors only while it shows. override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) if (isAttachedToWindow && isShown) startUpdates() else stopUpdates() @@ -397,13 +366,11 @@ class DroidDeckHudView(context: Context) : View(context) { graphMaxMs = stats?.graphMaxMs() ?: 0f onRefresh?.run() rebuildAndInvalidate() - // Without frames (a still screen) MangoHud redraws nothing; the HUD keeps refreshing. withTimeoutOrNull(REFRESH_MS) { periodDone.receive() } } } } - /** Runs on the main thread just before each readout refresh, for the host's labels. */ var onRefresh: Runnable? = null private fun applyTheme() { @@ -418,10 +385,6 @@ class DroidDeckHudView(context: Context) : View(context) { colGraph = lighten(accent, 0.3f) } - /** - * Inner Gamer turns the colour wheel inside the app's Compose UI, which is not running while the drawer is - * closed, so the HUD turns it here and shares the hue so the drawer picks up where the HUD is. - */ private fun startHueCycle() { if (hueJob?.isActive == true) return if (SessionPrefs.theme(context) != Themes.INNER_GAMER) return @@ -463,7 +426,6 @@ class DroidDeckHudView(context: Context) : View(context) { invalidate() } - // ---- Text helpers ----------------------------------------------------- private fun blend(color: Int): Int { val i = colorIntensity.coerceIn(0f, 1f) if (i >= 0.999f) return color @@ -496,27 +458,19 @@ class DroidDeckHudView(context: Context) : View(context) { private fun one(v: Float): String = if (fpsDecimal) String.format(Locale.US, "%.1f", v) else v.roundToInt().toString() - // Shows "30->118" while something downstream of the game is adding frames. - // The game's own rate stays visible next to the panel rate, because the gap - // between the two is precisely what frame generation is doing. private fun fpsText(v: Float): String = if (differs(v)) "${one(v)}\u2192${one(presentedFps)}" else one(v) - // Either direction: up = frames added (LSFG Native), down = frames lost - // between the guest and the panel, which is the failure win-fg can hit - // and which a single number hides completely. private fun differs(v: Float): Boolean = presentedFps > 0f && kotlin.math.abs(presentedFps - v) > maxOf(1f, v * 0.10f) private fun fmt1(v: Float): String = String.format(Locale.US, "%.1f", v) private fun lowText(v: Float): String? = if (v <= 0f) null else fmt1(v) - // number (white, or "—" muted when null) + unit (muted, smaller) private fun numUnit(num: String?, unit: String, numPx: Float, unitPx: Float): List = if (num == null) listOf(Span("—", colDim, numPx), Span(unit, colDim, unitPx)) else listOf(Span(num, colValue, numPx), Span(unit, colDim, unitPx)) - // "0.2GiB"/"75%"/"1938MHz" → white number + muted suffix private fun valueUnit(text: String, numPx: Float, unitPx: Float): List { val i = text.indexOfFirst { !(it.isDigit() || it == '.' || it == '-') } return if (i <= 0) listOf(Span(text, colValue, numPx)) @@ -525,7 +479,7 @@ class DroidDeckHudView(context: Context) : View(context) { private fun tempSpans(c: Int?, sensor: HudMetrics.TempSensor, numPx: Float, unitPx: Float): List { if (c == null) return emptyList() - val txt = HudMetrics.formatTemp(c.toFloat(), tempDisplay, false) // "81°C" + val txt = HudMetrics.formatTemp(c.toFloat(), tempDisplay, false) val col = HudMetrics.tempColor( c.toFloat(), metrics.resolveThresholds(sensor, tempDisplay), tempDisplay, colValue) val i = txt.indexOf('°') @@ -535,13 +489,9 @@ class DroidDeckHudView(context: Context) : View(context) { private fun gap(unitPx: Float) = Span(" ", colDim, unitPx) - /** The frame-rate number in neon, as on Minimal. */ private fun glowing(spans: List): List = spans.mapIndexed { i, s -> if (i == 0) Span(s.text, s.color, s.sizePx, glow = true) else s } - // ---- Wayland HDR state (one line of its own; nothing at all for DroidDeckHudHdr.NONE) ---- - /** The state as a full line: "HDR", "HDR (no headroom)", "HDR off", "HDR ready", "HDR tone-mapped", - * "HDR not on this screen". */ private fun hdrLine(px: Float): List = when (hdrState) { DroidDeckHudHdr.ON -> listOf(Span("HDR", colHdr, px)) DroidDeckHudHdr.NO_HEADROOM -> listOf(Span("HDR", colHdr, px), Span(" (no headroom)", colBat, px)) @@ -551,8 +501,6 @@ class DroidDeckHudView(context: Context) : View(context) { DroidDeckHudHdr.NOT_ON_THIS_SCREEN -> listOf(Span("HDR", colDim, px), Span(" not on this screen", colBat, px)) else -> emptyList() } - /** The value after an "HDR" label (Full / Mega): "on", "no headroom", "off", "ready", "tone-mapped", - * "not on this screen". */ private fun hdrValue(px: Float): List = when (hdrState) { DroidDeckHudHdr.ON -> listOf(Span("on", colHdr, px)) DroidDeckHudHdr.NO_HEADROOM -> listOf(Span("no headroom", colBat, px)) @@ -564,12 +512,7 @@ class DroidDeckHudView(context: Context) : View(context) { } private fun hdrText(): String = hdrLine(1f).joinToString("") { it.text } - // ---- GPU model (with the Wayland GPU-name spoof) ---- - /** What the GPU row names: the spoofed GPU when this session reports one, else the real model. */ private fun gpuNameText(): String = gpuSpoofName.ifBlank { gpuModel } - /** The GPU row's value run. A spoofed name carries a marker in the same warning colour the HDR line - * qualifies its state with, so the row can't be read as the chip that is really rendering: - * "GeForce GTX 1080 (spoof)", and where the line is tightest (the pill) "GeForce GTX 1080 spoof". */ private fun gpuModelSpans(px: Float, color: Int, compact: Boolean = false): List { val name = Span(gpuNameText(), color, px) if (gpuSpoofName.isBlank()) return listOf(name) @@ -578,7 +521,6 @@ class DroidDeckHudView(context: Context) : View(context) { private val inkRect = android.graphics.Rect() - // ---- Layout builders -------------------------------------------------- private fun rebuild() { glyphs.clear(); tileRects.clear(); graphRect = null headerMark = null; chipRect = null; dPanel = null; dDividerTop = -1f; tabRect = null; stripRect = null @@ -595,27 +537,25 @@ class DroidDeckHudView(context: Context) : View(context) { } } - /** The docked tab: the FPS alone, in a box square on the edge it sits against. */ private fun buildDockTab() { - val numPx = sp(20f) + val numPx = sp(TAB_NUM_SP) val spans = tabSpans() - val padX = sp(9f); val padY = sp(7f) + val padX = sp(TAB_PAD_X_SP); val padY = sp(TAB_PAD_Y_SP) placeRun(padX, padY + capHeight(numPx), spans) val (w, h) = dockTabSize() contentW = w; contentH = h tabRect = RectF(0f, 0f, contentW, contentH) } - private fun tabSpans() = listOf(Span(one(fpsNow), colValue, sp(20f)), Span("fps", colDim, sp(8.5f))) + private fun tabSpans() = listOf(Span(one(fpsNow), colValue, sp(TAB_NUM_SP)), Span("fps", colDim, sp(TAB_LABEL_SP))) - /** The size the docked tab has, so the HUD can contract to exactly that shape before the tab replaces it. */ internal fun dockTabSize(): Pair = - Pair(runWidth(tabSpans()) + 2f * sp(9f), capHeight(sp(20f)) + 2f * sp(7f)) + Pair(runWidth(tabSpans()) + 2f * sp(TAB_PAD_X_SP), capHeight(sp(TAB_NUM_SP)) + 2f * sp(TAB_PAD_Y_SP)) - private fun drawTab(canvas: Canvas, rect: RectF) { - val r = sp(8f); val stroke = max(outlineWidth(), sp(1.5f)); val h = stroke / 2f + private fun drawTab(canvas: Canvas, rect: RectF, edgeOf: DroidDeckHudDock = dock) { + val r = sp(8f); val stroke = outlineWidth(); val h = stroke / 2f val round = floatArrayOf(r, r, r, r, r, r, r, r) - val edge = when (dock) { + val edge = when (edgeOf) { DroidDeckHudDock.LEFT -> intArrayOf(0, 3) DroidDeckHudDock.RIGHT -> intArrayOf(1, 2) DroidDeckHudDock.TOP -> intArrayOf(0, 1) @@ -628,23 +568,20 @@ class DroidDeckHudView(context: Context) : View(context) { headerPaint.shader = null headerPaint.color = withAlpha(panelColor(), max(Color.alpha(panelColor()), 200)) canvas.drawPath(tabPath, headerPaint) + if (stroke <= 0f) return strokePaint.strokeWidth = stroke strokePaint.color = outlineColor() canvas.drawPath(tabPath, strokePaint) } - // ---- DroidDeck panel, header and chip ----------------------------------- - /** The clock sits in the header on every size but the Pill, which keeps it under the FPS. */ private fun footerClock(): Boolean = showClockTime && size == DroidDeckHudSize.PILL private fun measureSans(text: String, sizePx: Float): Float { wordmarkPaint.textSize = sizePx; return wordmarkPaint.measureText(text) } - private fun panelInset() = sp(8f) // room for the glow around the panel - private fun panelLeft() = sp(13f) // the outline's glow sticks out of the left side + private fun panelInset() = sp(8f) + private fun panelLeft() = sp(13f) private fun contentLeft() = panelLeft() + sp(16f) - /** The DroidDeck mark, "DroidDeck" and the clock across the top, starting on [left], with the same - * space above and below the mark. Returns the header's bottom edge and its right end. */ private fun addPanelHeader(left: Float, inner: Float): Pair { val wordPx = sp(10f); val clockPx = sp(9.5f); val gapH = sp(7f) wordmarkPaint.textSize = wordPx @@ -672,11 +609,6 @@ class DroidDeckHudView(context: Context) : View(context) { return Pair(dDividerTop + max(1f, density), x) } - /** - * Moves a size's content, laid out from [pad] at the origin, into the panel: under the header, or after - * the logo chip on the Pill. Lines that start on the content's left edge are lined up by their ink, so - * the logo, labels and numbers share one edge. - */ private fun intoPanel(pad: Float, header: Boolean) { if (contentW <= 0f || contentH <= 0f) return for (i in glyphs.indices) { @@ -706,8 +638,6 @@ class DroidDeckHudView(context: Context) : View(context) { finishPanel(panel, halfRound = !header) } - /** Closes the panel around everything placed so far, widened until no line, tile or graph reaches the - * round side: a half circle on the short sizes (Pill, Minimal), large round corners on the tall ones. */ private fun finishPanel(panel: RectF, halfRound: Boolean) { val r = if (halfRound) panel.height() / 2f else min(panel.height() / 2f, sp(56f)) val margin = outlineWidth() + sp(4f) @@ -731,7 +661,6 @@ class DroidDeckHudView(context: Context) : View(context) { contentH = panel.bottom + panelInset() } - // ---- Shared small-info helpers ---------------------------------------- private fun clockTimeString(): String = DateFormat.getTimeFormat(context).format(Date()) private fun elapsedString(): String { @@ -741,31 +670,21 @@ class DroidDeckHudView(context: Context) : View(context) { else String.format(Locale.US, "%02d:%02d", m, s) } - /** - * The FPS row's API/engine label — the DX→Vulkan translator ([engineLabel]: "DXVK"/"VKD3D"/"Zink"), - * gated by the "Engine" chip. This is short and shows in EVERY size. Falls back to a bare "FPS" when - * the toggle is off or no engine label is set. (The graphics WRAPPER is separate — Mega-only, drawn - * below the frametime graph — not on the FPS row.) - */ private fun apiLabel(): String = if (showEngine && engineLabel.isNotBlank()) engineLabel else "FPS" - /** The DX-wrapper version WITH its engine name (e.g. "DXVK 2.4.1", "VKD3D 2.14"), or "" if none. - * Used for the Mega stack line below the graph. */ private fun dxVersionLabeled(): String = when { engineLabel.contains("VKD3D", true) && vkd3dVersion.isNotBlank() -> "VKD3D $vkd3dVersion" engineLabel.contains("DXVK", true) && dxvkVersion.isNotBlank() -> "DXVK $dxvkVersion" else -> "" } - /** The DX-wrapper version for the current engine, or "" (used for the Tiles "API" tile sub-line). */ private fun dxVersion(): String = when { engineLabel.contains("VKD3D", true) -> vkd3dVersion engineLabel.contains("DXVK", true) -> dxvkVersion else -> "" } - /** Resolution + refresh, e.g. "1280x720 @ 120Hz". Read from the current display (Main thread). */ private fun resolutionString(): String { if (resolution.isNotBlank()) return resolution val dm = resources.displayMetrics @@ -774,15 +693,13 @@ class DroidDeckHudView(context: Context) : View(context) { return if (hz >= 1f) "$base @ ${hz.roundToInt()}Hz" else base } - /** A subtle, low-emphasis time-of-day tucked into the bottom-right of any size. Grows contentH slightly. */ private fun addSubtleClock(pad: Float, leftAligned: Boolean = false) { if (!footerClock()) return val px = sp(9.5f) val t = clockTimeString() val w = measure(t, px) - val footerTop = contentH - pad // reuse the bottom padding band as the footer line + val footerTop = contentH - pad val baseline = footerTop - ascent(px) - // Right-aligned footer; on the Pill it sits on the left edge, under the FPS. val x = if (leftAligned) pad else (contentW - pad - w).coerceAtLeast(pad) glyphs.add(Glyph(x, baseline, t, blend(colDim), px)) contentH = footerTop + lineH(px) + pad * 0.5f @@ -839,7 +756,6 @@ class DroidDeckHudView(context: Context) : View(context) { val v = ArrayList() v += glowing(numUnit(fpsText(fpsNow), "FPS", rowPx, unitPx)) v += gap(unitPx); v += numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", rowPx, unitPx) - // Short API/engine label ("DXVK"/"VKD3D"/"Zink", else "FPS") — a normal aligned row. rows.add(HudRow(Span(apiLabel(), colFps, rowPx), v)) rows.add(HudRow(Span("AVG", colLo, rowPx), numUnit(fmt1(fpsAvg), "FPS", rowPx, unitPx))) rows.add(HudRow(Span("1%", colLo, rowPx), numUnit(lowText(lows.low1Fps), "FPS", rowPx, unitPx))) @@ -863,9 +779,6 @@ class DroidDeckHudView(context: Context) : View(context) { } if (showFPS && showGraph) { - // Frametime min/max line + green graph. "Frametime" is far wider than the short-label grid - // column, so place the stat AFTER the ACTUAL label width (not labelCol) — otherwise the two - // overlap and render as "Frametimemin:.. max:..". val ftPx = unitPx * 1.15f val baseline = y - asc placeRun(pad, baseline, listOf(Span("Frametime", colFps, ftPx))) @@ -907,8 +820,6 @@ class DroidDeckHudView(context: Context) : View(context) { if (showVram && s.vramText() != null) tiles.add(Tile("VRAM", colVram, valueUnit(s.vramText()!!, valPx, unitPx), null, false)) if (showRAM) tiles.add(Tile("RAM", colRam, numUnit(s.ramPercent.roundToInt().toString(), "%", valPx, unitPx), "${s.ramUsedText()} / ${s.ramTotalText()}", false)) - // API/engine tile — short translator name (DXVK/VKD3D/Zink) with the DX version on the sub-line; - // a normal half-width tile that naturally fills the empty slot beside RAM. Gated by "Engine". if (showEngine && engineLabel.isNotBlank()) tiles.add(Tile("API", colFps, listOf(Span(engineLabel, colValue, valPx)), dxVersion().ifBlank { null }, false)) @@ -932,7 +843,7 @@ class DroidDeckHudView(context: Context) : View(context) { var normalW = 0f for (t in tiles) if (!t.wide) normalW = max(normalW, tileW(t)) if (normalW == 0f) for (t in tiles) normalW = max(normalW, tileW(t) / 2f) - val tileH = innerPad * 2 + keyH + lineGap + valH + lineGap + subH // uniform (room for a sub line) + val tileH = innerPad * 2 + keyH + lineGap + valH + lineGap + subH val fullW = normalW * 2 + tileGap var x = pad; var y = pad; var col = 0 @@ -973,20 +884,12 @@ class DroidDeckHudView(context: Context) : View(context) { val pad = sp(10f); val midGap = sp(12f); val stkLineGap = sp(3f) val left = ArrayList() - // "30->118" is roughly three times the width of "30", and the pill's - // number is set at 30sp precisely because it is the one big thing on a - // small overlay. Shrink it while it carries both figures so the pill - // does not balloon across the screen; it is still the largest element. val generating = differs(fpsNow) val fpsPx = if (generating) bigPx * 0.55f else bigPx left += Span(fpsText(fpsNow), colValue, fpsPx, glow = true) left += Span("fps", colDim, if (generating) bigUnitPx * 0.8f else bigUnitPx) val stack = ArrayList>() - // The GPU name normally heads the stack. A name wider than every stat under it (a Wayland spoof - // such as "Radeon RX 6800/6800 XT / 6900 XT spoof") would set the stack's width and stretch the - // whole capsule around empty space, so that name gets its own line across the top of the pill - // instead, starting over the API caption (see nameOnTop below). val gpuName = if (showGpuModel && gpuNameText().isNotBlank()) gpuModelSpans(stkPx, colDim, compact = true) else null run { val l = ArrayList() @@ -994,11 +897,7 @@ class DroidDeckHudView(context: Context) : View(context) { if (showCPU) { if (l.isNotEmpty()) l += Span(" · ", colDim, stkPx); l += Span("CPU ${s.cpuPercent ?: "—"}%", colCpu, stkPx) } if (l.isNotEmpty()) stack.add(l) } - // RAM % (system-wide) takes the slot the latency row used to occupy, matching the GPU/CPU idiom - // on the row above it. colRam keeps RAM's identity colour consistent with the Full/Tiles sizes. if (showRAM) stack.add(listOf(Span("RAM ${s.ramPercent.roundToInt()}%", colRam, stkPx))) - // One battery row, as on the other sizes: each part shows on its own switch, so the battery - // temperature no longer goes with the RAM switch and watts show without the battery level. if (showBattery || showPower || showBatteryTemp) { val l = ArrayList() fun part(spans: List) { @@ -1011,9 +910,6 @@ class DroidDeckHudView(context: Context) : View(context) { if (showPower && s.battery.watts > 0f) part(listOf(Span("${fmt1(s.battery.watts)}W", colDim, stkPx))) if (l.isNotEmpty()) stack.add(l) } - // Latency (+ the display server, + optional VRAM) drops to the bottom of the stack, under the - // battery row. The display server sits here, not on the GPU-model line: that line is the - // pill's widest and ran into the capsule's rounded edge. run { val l = ArrayList() l += Span("${fmt1(1000f / max(fpsNow, 1f))}ms", colDim, stkPx) @@ -1021,8 +917,6 @@ class DroidDeckHudView(context: Context) : View(context) { if (showVram && s.vramText() != null) { l += Span(" · ", colDim, stkPx); l += Span("${s.vramText()} vram", colDim, stkPx) } stack.add(l) } - // Wayland HDR state on its OWN line directly under the latency · display-server line (it ran off - // the capsule's right edge as "40.3ms · Wayland · HDR (no headroom)"). Only in HDR sessions. if (hdrState != DroidDeckHudHdr.NONE) stack.add(hdrLine(stkPx)) var statsW = 0f @@ -1030,8 +924,6 @@ class DroidDeckHudView(context: Context) : View(context) { val nameOnTop = gpuName != null && runWidth(gpuName) > statsW if (gpuName != null && !nameOnTop) stack.add(0, gpuName) - // Left column: a small API/engine caption (DXVK/VKD3D/Zink) above the big FPS and the clock below - // it, all on the left edge, so the left reads API · FPS · clock top-to-bottom. val apiStr = apiLabel() val hasApi = apiStr != "FPS" val apiH = if (hasApi) lineH(stkPx) else 0f @@ -1050,14 +942,12 @@ class DroidDeckHudView(context: Context) : View(context) { contentW = pad + leftBlockW + midGap + stackW + pad contentH = pad + topLineH + innerH + pad - // left column (API caption + big FPS), vertically centered as a block var ly = pad + topLineH + (innerH - leftColH) / 2f if (hasApi) { placeRun(pad, ly - ascent(stkPx), listOf(Span(apiStr, colFps, stkPx))) ly += apiH + apiGap } placeRun(pad, ly - ascent(bigPx), left) - // stack, vertically centered var sy = pad + topLineH + (innerH - stackTotalH) / 2f for (l in stack) { placeRun(pad + leftBlockW + midGap, sy - ascent(stkPx), l) @@ -1065,22 +955,17 @@ class DroidDeckHudView(context: Context) : View(context) { } addSubtleClock(pad, leftAligned = true) if (nameOnTop && gpuName != null) { - // Left-aligned over the API caption; the pill only widens if the name is wider than the - // API/FPS column + stats. val end = placeRun(pad, pad - ascent(stkPx), gpuName) contentW = max(contentW, end + pad) } } - // One column as wide as the widest row: the logo, FPS, lows, graph and engine line start on its left - // edge (ink, not advance), and the header keeps the same space above and below the logo. private fun buildMinimal(s: HudMetrics.Snapshot) { val bigPx = sp(34f); val bigUnitPx = bigPx * 0.32f; val subPx = sp(11.5f); val footPx = sp(9.5f) val stroke = outlineWidth() val left = contentLeft() val inner = panelInset() + stroke - // Same reasoning as the pill: 34sp is sized for one number, not two. val minGenerating = differs(fpsNow) val big = listOf( Span(fpsText(fpsNow), colValue, if (minGenerating) bigPx * 0.55f else bigPx), @@ -1126,11 +1011,6 @@ class DroidDeckHudView(context: Context) : View(context) { finishPanel(panel, halfRound = true) } - /** - * Full's readouts on one line across the screen: the mark, the FPS, the graph, the lows, then GPU, CPU, - * memory, battery, display server and GPU model, with the clock on the right end. Groups that no longer - * fit before the clock are left off, from the end. - */ private fun buildStrip(s: HudMetrics.Snapshot) { val rowPx = sp(11.5f); val unitPx = rowPx * 0.8f; val fpsPx = sp(15f) val padX = sp(10f); val groupGap = sp(13f) @@ -1227,8 +1107,6 @@ class DroidDeckHudView(context: Context) : View(context) { return inkRect.left.toFloat() } - /** Places a column of "label + value" rows; returns (bottomY, rightX). Appends glyphs. Rows flagged - * [HudRow.inline] opt out of the shared label column (value follows the label directly). */ private fun layoutColumn(rows: List, x: Float, y0: Float, rowPx: Float, lineGap: Float, lvGap: Float): Pair { if (rows.isEmpty()) return Pair(y0, x) @@ -1248,7 +1126,6 @@ class DroidDeckHudView(context: Context) : View(context) { return Pair(y - lineGap, right) } - /** Places wrapping " · "-separated info fragments within maxWidth; returns (bottomY, rightX). */ private fun layoutWrap(fragments: List>, x: Float, y0: Float, maxWidth: Float, px: Float, lineGap: Float): Pair { if (fragments.isEmpty()) return Pair(y0, x) @@ -1269,7 +1146,6 @@ class DroidDeckHudView(context: Context) : View(context) { val rowPx = sp(11.5f); val unitPx = rowPx * 0.62f; val bandPx = sp(9.5f) val pad = sp(10f); val lineGap = sp(3.5f); val lvGap = sp(7f); val gutter = sp(16f) - // ---- LEFT column: GPU, aggregate CPU, then per-core rows ---- val left = ArrayList() if (showGpuModel && gpuNameText().isNotBlank()) left.add(HudRow(Span("GPU", colGpu, rowPx), gpuModelSpans(rowPx, colValue))) @@ -1301,7 +1177,6 @@ class DroidDeckHudView(context: Context) : View(context) { } } - // ---- RIGHT column: VRAM, RAM, SWP, NET, BAT, engine, lows ---- val right = ArrayList() if (showVram && s.vramText() != null) right.add(HudRow(Span("VRAM", colVram, rowPx), valueUnit(s.vramText()!!, rowPx, unitPx))) @@ -1339,8 +1214,6 @@ class DroidDeckHudView(context: Context) : View(context) { val v = ArrayList() v += glowing(numUnit(fpsText(fpsNow), "FPS", rowPx, unitPx)) v += gap(unitPx); v += numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", rowPx, unitPx) - // FPS/engine row: just the short API label. The DX version moved to the stack lines - // below the frametime graph (see below), so this row stays compact. right.add(HudRow(Span(apiLabel(), colFps, rowPx), v)) right.add(HudRow(Span("AVG", colLo, rowPx), numUnit(fmt1(fpsAvg), "FPS", rowPx, unitPx))) right.add(HudRow(Span("1%", colLo, rowPx), numUnit(lowText(lows.low1Fps), "FPS", rowPx, unitPx))) @@ -1349,15 +1222,12 @@ class DroidDeckHudView(context: Context) : View(context) { right.add(HudRow(Span("0.01%", colLo, rowPx), numUnit(lowText(lows.low001Fps), "FPS", rowPx, unitPx))) } - // ---- Two columns side by side (each reflows independently) ---- val (leftBottom, leftRight) = layoutColumn(left, pad, pad, rowPx, lineGap, lvGap) val rightX = (if (left.isEmpty()) pad else leftRight + gutter) val (rightBottom, rightRight) = layoutColumn(right, rightX, pad, rowPx, lineGap, lvGap) var y = max(leftBottom, rightBottom) var maxRight = max(leftRight, rightRight) - // ---- Bottom band spanning both columns: res · Proton · elapsed ---- - // (The graphics wrapper moved to the FPS-row label above, so it's no longer in this band.) val band = ArrayList>() if (showResolution) band.add(listOf(Span("RES ", colDim, bandPx), Span(resolutionString(), colValue, bandPx))) if (displayServer.isNotBlank()) band.add(listOf(Span("DISP ", colDim, bandPx), Span(displayServer, colDisp, bandPx))) @@ -1370,7 +1240,6 @@ class DroidDeckHudView(context: Context) : View(context) { y = bBottom; maxRight = max(maxRight, bRight) } - // ---- Full-width frametime graph ---- if (showGraph) { y += sp(3f) val gh = sp(22f) @@ -1380,9 +1249,6 @@ class DroidDeckHudView(context: Context) : View(context) { y += gh } - // ---- Stack-layer lines BELOW the frametime graph, subtle (same size/style as the bottom - // band): the DX-wrapper version first (e.g. "DXVK 2.4.1-…", gated by "DX ver"), then the - // graphics wrapper beneath it (e.g. "Original", gated by "Wrapper"). ---- val dxLine = dxVersionLabeled() val hasDx = showDxVer && dxLine.isNotBlank() if (hasDx) { @@ -1398,12 +1264,10 @@ class DroidDeckHudView(context: Context) : View(context) { contentW = maxRight + pad contentH = y + pad - addSubtleClock(pad) // subtle time-of-day, consistent with the other sizes + addSubtleClock(pad) } - // ---- Measure / draw --------------------------------------------------- override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // The Strip spans the parent, past the side margin the floating sizes keep. if (stripRect != null) { setMeasuredDimension(contentW.roundToInt(), resolveSize(contentH.roundToInt(), heightMeasureSpec)); return } setMeasuredDimension( resolveSize(contentW.roundToInt(), widthMeasureSpec), @@ -1413,10 +1277,41 @@ class DroidDeckHudView(context: Context) : View(context) { override fun onDraw(canvas: Canvas) { if (contentW <= 0f || contentH <= 0f) { lockController.drawBadge(canvas); return } + val blend = if (dock == DroidDeckHudDock.NONE && previewEdge != DroidDeckHudDock.NONE) previewBlend else 0f + if (blend <= 0f) { drawContent(canvas); return } + if (blend < 1f) { + canvas.saveLayerAlpha(0f, 0f, width.toFloat(), height.toFloat(), ((1f - blend) * 255f).roundToInt()) + drawContent(canvas) + canvas.restore() + } + drawTabPreview(canvas, blend) + } + + private fun drawTabPreview(canvas: Canvas, blend: Float) { + val (w, h) = dockTabSize() + if (scaleX <= 0f || scaleY <= 0f) return + canvas.save() + canvas.translate(width / 2f, height / 2f) + canvas.scale(1f / scaleX, 1f / scaleY) + canvas.translate(-w / 2f, -h / 2f) + canvas.saveLayerAlpha(0f, 0f, w, h, (blend * 255f).roundToInt()) + drawTab(canvas, RectF(0f, 0f, w, h), previewEdge) + var x = sp(TAB_PAD_X_SP) + val baseline = sp(TAB_PAD_Y_SP) + capHeight(sp(TAB_NUM_SP)) + for (span in tabSpans()) { + drawPaint.textSize = span.sizePx + drawPaint.color = blend(span.color) + canvas.drawText(span.text, x, baseline, drawPaint) + x += measure(span.text, span.sizePx) + } + canvas.restore() + canvas.restore() + } + + private fun drawContent(canvas: Canvas) { dPanel?.let { panel -> panelFrame(panel)?.let { canvas.drawBitmap(it, 0f, 0f, null) } } - // Tile backings if (tileRects.isNotEmpty()) { tilePaint.color = Color.argb((14 * bgOpacity).roundToInt().coerceIn(8, 40), 255, 255, 255) val tr = sp(6f) @@ -1427,7 +1322,6 @@ class DroidDeckHudView(context: Context) : View(context) { tabRect?.let { drawTab(canvas, it) } stripRect?.let { drawStrip(canvas, it) } - // Text for (g in glyphs) { val paint = if (g.sans) wordmarkPaint else drawPaint paint.textSize = g.sizePx @@ -1438,14 +1332,11 @@ class DroidDeckHudView(context: Context) : View(context) { } headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) } - // Frametime graph graphRect?.let { drawGraph(canvas, it) } lockController.drawBadge(canvas) } - // MangoHud's frametime graph: the last 200 frames on a fixed 0-50 ms scale, longer frames clipped - // to 49.9 ms (hud_elements.cpp TransformForward_Custom). private fun drawGraph(canvas: Canvas, rect: RectF) { val stats = frames ?: return stats.graph(graphBuf) @@ -1457,7 +1348,6 @@ class DroidDeckHudView(context: Context) : View(context) { if (i == 0) graphPath.moveTo(px, py) else graphPath.lineTo(px, py) } if (dPanel != null) { - // Neon without a blur: a blur would be redrawn with every frame. graphPaint.strokeWidth = sp(4.5f) graphPaint.color = withAlpha(blend(colGraph), 56) canvas.drawPath(graphPath, graphPaint) @@ -1467,7 +1357,6 @@ class DroidDeckHudView(context: Context) : View(context) { canvas.drawPath(graphPath, graphPaint) } - /** The bar, with the accent line on the side that faces the game (under it at the top, over it at the bottom). */ private fun drawStrip(canvas: Canvas, rect: RectF) { headerPaint.shader = null headerPaint.color = panelColor() @@ -1489,8 +1378,6 @@ class DroidDeckHudView(context: Context) : View(context) { private fun withAlpha(color: Int, alpha: Int): Int = Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) - /** The D: straight left side with small corners, round on the right with radius [rad] (a half circle - * when it is half the height). */ private fun dShape(l: Float, t: Float, r: Float, b: Float, rad: Float, corner: Float): Path { return Path().apply { moveTo(l, t + corner) @@ -1504,7 +1391,6 @@ class DroidDeckHudView(context: Context) : View(context) { } } - /** Where the inside of the outline's round side is at height [y]. */ private fun roundEdgeX(p: RectF, y: Float, stroke: Float): Float { val r = dRadius - stroke val cx = p.right - dRadius @@ -1534,7 +1420,6 @@ class DroidDeckHudView(context: Context) : View(context) { headerPaint.color = withAlpha(accent, 90) c.drawRect(panel.left, dDividerTop, roundEdgeX(panel, dDividerTop, stroke), dividerBottom, headerPaint) } - // The watermark fills the half circle on the short sizes and sits on the right of the tall ones. val halfRound = dRadius >= panel.height() / 2f - 0.5f val wmH = if (halfRound) panel.height() * 0.92f else min(panel.height(), panel.width()) * 0.55f DroidDeckMark.draw(c, panel.right - DroidDeckMark.widthFor(wmH) - (if (halfRound) panel.height() * 0.02f else sp(10f)), @@ -1545,7 +1430,6 @@ class DroidDeckHudView(context: Context) : View(context) { val outline = dShape(panel.left + h, panel.top + h, panel.right - h, panel.bottom - h, dRadius - h, corner) val blur = BlurMaskFilter((sp(3.2f) - 0.5f).coerceAtLeast(0.5f) / 0.57735f, BlurMaskFilter.Blur.NORMAL) glowPaint.strokeWidth = stroke - // Two blurred passes under the sharp one, as the 4-in-1 preview's neon. for (pass in 0..2) { glowPaint.maskFilter = if (pass < 2) blur else null glowPaint.style = Paint.Style.STROKE; glowPaint.color = outlineColor() @@ -1586,12 +1470,10 @@ class DroidDeckHudView(context: Context) : View(context) { DroidDeckMark.draw(canvas, rect.centerX() - markW / 2f, rect.centerY() - markH / 2f, markH, blend(colValue), accent) } - /** Screen point → whether an on-screen control under the HUD takes a touch there. */ internal var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null - // Declining the down event hands the whole gesture to the views under the HUD. override fun onTouchEvent(event: MotionEvent): Boolean { - if (dock != DroidDeckHudDock.NONE) return dockDetector.onTouchEvent(event) + if (dock != DroidDeckHudDock.NONE) return onTabTouch(event) if (event.actionMasked == MotionEvent.ACTION_DOWN && lockController.isLocked() && passesTouchAt?.invoke(event.rawX, event.rawY) == true) return false return lockController.onTouchEvent(event) @@ -1602,9 +1484,13 @@ class DroidDeckHudView(context: Context) : View(context) { internal val contentWidth: Float get() = contentW internal val contentHeight: Float get() = contentH - internal val isDragging: Boolean get() = lockController.isDragging() + internal val isDragging: Boolean get() = lockController.isDragging() || tabDragging private companion object { + const val TAB_NUM_SP = 10f + const val TAB_LABEL_SP = 6f + const val TAB_PAD_X_SP = 5f + const val TAB_PAD_Y_SP = 4f const val REFRESH_MS = 500L private const val HUE_IDLE_MS = 500L const val MIN_SCALE = 0.5f diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt index dd21683f0..621f6dc1f 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt @@ -5,7 +5,6 @@ import android.graphics.Matrix import android.graphics.Paint import android.graphics.Path -/** artwork/droiddeck-mark.svg: the split D with its circular cut-out and the blue orb. */ internal object DroidDeckMark { private const val VIEW_X = -55.98f const val VIEW_W = 239.92f @@ -36,7 +35,6 @@ internal object DroidDeckMark { fun widthFor(height: Float): Float = height * VIEW_W / VIEW_H - /** Where the drawn mark starts and ends inside [widthFor]: the artwork has empty space on both sides. */ fun inkLeftFor(height: Float): Float = (-31.98f - VIEW_X) * height / VIEW_H fun inkRightFor(height: Float): Float = (175.93f - VIEW_X) * height / VIEW_H diff --git a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt index adf3e067e..98dc35074 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt @@ -3,13 +3,6 @@ package com.droiddeck.launcher.hud import java.io.File import kotlin.math.roundToInt -/** - * Frame statistics by MangoHud's rules, so the HUD and Steam's performance overlay agree: MangoHud - * overlay.cpp update_hud_info_with_frametime and hud_elements.cpp fps() / frame_timing(). The FPS is the - * frames counted over a sampling period of at least 500 ms, the frame time next to it is 1000 / FPS, and - * the graph holds the last 200 frame times (zeros until filled) on a fixed 0-50 ms scale, min and max - * taken over those 200. Average and lows are DroidDeck HUD's own readouts. - */ internal class FrameStats { private val graph = FloatArray(GRAPH_FRAMES) private var graphHead = 0 @@ -26,14 +19,16 @@ internal class FrameStats { private var sessionStartNs = 0L private var sessionFrames = 0L + private val slowTimes = LongArray(SLOW_FRAMES) + private var slowHead = 0 + private var slowSize = 0 + @Volatile var fps = 0f private set - /** Completed sampling periods; MangoHud redraws its readouts when one completes. */ @Volatile var fpsUpdates = 0L private set - /** Frames added so far; the HUD redraws its graph when this moves. */ @Volatile var count = 0L private set @@ -60,6 +55,11 @@ internal class FrameStats { if (lowsSize < LOWS_FRAMES) lowsSize++ if (sessionStartNs == 0L) sessionStartNs = timeNs - frametimeNs sessionFrames++ + if (frametimeNs > SLOW_NS) { + slowTimes[slowHead] = timeNs + slowHead = (slowHead + 1) % SLOW_FRAMES + if (slowSize < SLOW_FRAMES) slowSize++ + } } count++ } @@ -77,14 +77,14 @@ internal class FrameStats { cachedLowsNs = 0L sessionStartNs = 0L sessionFrames = 0L + slowHead = 0 + slowSize = 0 fps = 0f count++ } - /** MangoHud's frame time readout: 1000 / FPS. */ fun frametimeMs(): Float = 1000f / maxOf(fps, 1f) - /** The graph, oldest first, into [out] (GRAPH_FRAMES long). */ @Synchronized fun graph(out: FloatArray) { for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES] @@ -96,7 +96,6 @@ internal class FrameStats { @Synchronized fun graphMaxMs(): Float = graph.max() - /** Average over the frames this session, from the first frame to [nowNs]'s last frame. */ @Synchronized fun averageFps(): Float { if (sessionFrames < 2 || lastPresentNs <= sessionStartNs) return 0f @@ -106,13 +105,22 @@ internal class FrameStats { @Synchronized fun sessionSeconds(nowNs: Long): Float = if (sessionStartNs == 0L) 0f else (nowNs - sessionStartNs) / 1e9f + @Synchronized + fun slowFrames(nowNs: Long, windowNs: Long): Int { + var n = 0 + for (i in 0 until slowSize) { + if (nowNs - slowTimes[(slowHead - 1 - i + SLOW_FRAMES) % SLOW_FRAMES] > windowNs) break + n++ + } + return n + } + class Lows(val low1Fps: Float, val low01Fps: Float, val low001Fps: Float) { companion object { val EMPTY = Lows(0f, 0f, 0f) } } - /** FPS at the 99th, 99.9th and 99.99th percentile frame time of the last LOWS_FRAMES frames. */ fun lows(nowNs: Long): Lows { val copy: LongArray synchronized(this) { @@ -141,15 +149,12 @@ internal class FrameStats { const val FPS_PERIOD_NS = 500_000_000L const val LOWS_FRAMES = 8192 private const val PAUSE_NS = 1_000_000_000L + const val SLOW_NS = 33_333_334L + private const val SLOW_FRAMES = 2048 private const val LOWS_RECOMPUTE_NS = 250_000_000L } } -/** - * Feeds [stats] with the frame times gamescope shares (the ones MangoHud shows). Sessions without the - * shared file, or where gamescope stopped sending, fall back to the frames DroidDeck's compositor shows, - * which stop at the screen's refresh rate. - */ internal class GameFrameFeed(private val file: File, val stats: FrameStats = FrameStats()) { private var handle = 0L private var nextOpenNs = 0L @@ -160,7 +165,7 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra private val info = IntArray(5) @Volatile private var fromGamescope = false private var lastCompositorNs = 0L - @Volatile private var compositorFrames = 0L // written by the compositor thread only + @Volatile private var compositorFrames = 0L private var seenCompositorFrames = 0L @Volatile var engine = "" @@ -176,7 +181,8 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra @Volatile var refreshMilliHz = 0 private set - /** Main thread, once per display frame. True when new frames arrived. */ + val live: Boolean get() = fromGamescope + fun poll(nowNs: Long): Boolean { val compositorAdded = compositorFrames != seenCompositorFrames seenCompositorFrames = compositorFrames @@ -185,7 +191,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra nextOpenNs = nowNs + REOPEN_NS handle = runCatching { HudFrames.nativeOpen(file.path) }.getOrDefault(0L) if (handle == 0L) return compositorAdded - // Frames already in the file were written before this reader started. HudFrames.nativePoll(handle, frametimes, times) } val n = HudFrames.nativePoll(handle, frametimes, times) @@ -199,7 +204,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra } else if (lastRingFrameNs != 0L && nowNs - lastRingFrameNs > LIVE_NS) { fromGamescope = false } - // A new session writes the file again from the start; reopen when gamescope has gone quiet. if (n <= 0 && nowNs - maxOf(lastRingFrameNs, nextOpenNs - REOPEN_NS) > STALE_NS) close() if (nowNs - lastInfoNs >= INFO_NS) { lastInfoNs = nowNs @@ -208,7 +212,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra return n > 0 || compositorAdded } - /** Compositor thread: one frame shown on the panel. */ fun onCompositorFrame(nowNs: Long) { if (fromGamescope) { lastCompositorNs = 0L @@ -247,8 +250,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra private const val STALE_NS = 5_000_000_000L private const val INFO_NS = 500_000_000L - /** DroidDeck's mangoapp's names for gamescope's engine field (Valve's 20260902.1 build, app/main.cpp - * engine_type_from_str and overlay.h engines); gamescope sends "gamescope" when it knows none. */ fun engineLabel(name: String): String = when (name) { "DXVK" -> "DXVK" "vkd3d" -> "VKD3D" diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt index 63a9a7945..ca13e7a3e 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt @@ -1,12 +1,10 @@ package com.droiddeck.launcher.hud -/** Reader for the frame-times file gamescope patch 0125 writes (hudframes.c). */ internal object HudFrames { init { System.loadLibrary("droiddeckwayland") } - /** Under the Linux root: the session exports /run/droiddeck-hud/frames to gamescope. */ const val PATH = "run/droiddeck-hud/frames" const val CAPACITY = 1024 diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java b/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java index 2e3dc0d72..98c34df7c 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java +++ b/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java @@ -8,7 +8,7 @@ * link to https://github.com/The412Banner/FusionHUD in the project documentation * AND in the in-app credits/about screen. * - * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck. */ package com.droiddeck.launcher.hud; @@ -28,44 +28,18 @@ import android.view.View; import java.util.function.BiConsumer; import java.util.function.Consumer; -/** - * Shared touch + lock behaviour for every in-game performance HUD (classic FrameRating(+Horizontal), - * GameHub PerfHudView, GameNative PerformanceHudView, and the DroidDeck HUD). One place so the four - * overlays can't drift: - * - *
    - *
  • tap → {@link Callbacks#onTap()} (orientation-toggle for the first three; size-cycle for - * the DroidDeck HUD) — a no-op that only re-flashes the lock badge while locked.
  • - *
  • drag → moves the host view and, on release, {@link Callbacks#onMoved} to persist the - * position — disabled (frozen) while locked.
  • - *
  • long-press → toggles {@code hudLocked}, persisted via {@link Callbacks#onLockChanged} - * and kept in sync with the "Lock in place" menu chip. Works locked or unlocked (so a long-press - * always unlocks); a locked {@code DroidDeckHudView} passes touches on only over on-screen controls.
  • - *
- * - *

On every lock/unlock (and on a blocked gesture while locked) a small padlock badge fades in → - * holds → fades out in a corner of the panel. The fade is driven by a self-reposting Runnable that - * invalidates only while visible — nothing runs on the present path, nothing animates at rest. - * - *

All methods are Main-thread only (touch + draw), matching the epoll-thread rule: the views feed - * this from {@code onTouchEvent}/{@code dispatchDraw}, never from a present tick. - */ public class HudLockController { public interface Callbacks { - /** Unlocked single tap: orientation-toggle (classic/gamehub/gamenative) or size-cycle (DroidDeck HUD). */ void onTap(); - /** Drag released (unlocked): persist the new (x, y). */ void onMoved(float x, float y); - /** Lock toggled by long-press: persist {@code hudLocked} + sync the menu chip. */ + default void onDragged(float rawX, float rawY) {} + default void onFlung(float velocityX, float velocityY) {} void onLockChanged(boolean locked); - /** Two-finger pinch (unlocked): the change since the previous event, above 1 when the fingers spread. */ default void onPinch(float factor) {} - /** The fingers came off a pinch: persist the new size. */ default void onPinchEnd() {} } - // Fade timing (ms): in → hold → out. Lock/unlock hold ~1.5 s; a blocked-gesture re-flash is shorter. private static final long FADE_IN_MS = 160; private static final long HOLD_TOGGLE_MS = 1500; private static final long HOLD_REFLASH_MS = 750; @@ -79,13 +53,10 @@ public class HudLockController { private boolean locked = false; - // Drag state private float downRawX, downRawY, startX, startY; private boolean dragging = false; - // A pinch ends the gesture's drag and tap: the finger left down after it must not move the HUD. private boolean pinched = false; - // Fade state private long fadeStart = 0; private long holdMs = HOLD_TOGGLE_MS; private boolean fadeShowLocked = false; @@ -106,14 +77,12 @@ public class HudLockController { iconStroke.setStrokeJoin(Paint.Join.ROUND); } - /** Set lock state from config (applyConfig) WITHOUT firing the persist callback. */ public void setLocked(boolean locked) { this.locked = locked; } public boolean isLocked() { return locked; } public boolean isDragging() { return dragging; } - /** Feed from the host view's onTouchEvent. Always consumes (mirrors the legacy manual handlers). */ public boolean onTouchEvent(MotionEvent event) { scaleDetector.onTouchEvent(event); detector.onTouchEvent(event); @@ -139,7 +108,6 @@ public class HudLockController { @Override public void onLongPress(MotionEvent e) { - // Long-press always toggles — this is the only gesture that still works while locked. locked = !locked; cb.onLockChanged(locked); flash(locked, HOLD_TOGGLE_MS); @@ -149,24 +117,31 @@ public class HudLockController { public boolean onSingleTapUp(MotionEvent e) { if (pinched) return true; if (locked) { - flash(true, HOLD_REFLASH_MS); // intentionally locked — remind the user + flash(true, HOLD_REFLASH_MS); } else { cb.onTap(); } return true; } + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (dragging && !locked && !pinched) cb.onFlung(velocityX, velocityY); + return true; + } + @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { if (pinched) return true; if (locked) { if (!dragging) flash(true, HOLD_REFLASH_MS); - dragging = true; // mark so the blocked drag doesn't fall through to a tap + dragging = true; return true; } view.setX(startX + (e2.getRawX() - downRawX)); view.setY(startY + (e2.getRawY() - downRawY)); dragging = true; + cb.onDragged(e2.getRawX(), e2.getRawY()); return true; } } @@ -192,7 +167,6 @@ public class HudLockController { } } - // ---- Fade badge ------------------------------------------------------- private void flash(boolean showLocked, long hold) { fadeShowLocked = showLocked; holdMs = hold; @@ -225,14 +199,6 @@ public class HudLockController { return Color.argb(Math.round(255 * a), Color.red(color), Color.green(color), Color.blue(color)); } - /** - * Draw the padlock badge in the top-right of the panel if a fade is in progress. Call from the - * host's onDraw (plain View) or dispatchDraw (ViewGroup), after the content. - * - *

No background box: the glyph is drawn twice — a slightly expanded, thicker semi-transparent - * black "halo" pass for legibility on any game frame, then the white glyph on top — so it reads as - * a floating icon, not an opaque tile. - */ public void drawBadge(Canvas canvas) { float a = currentAlpha(); if (a <= 0f) return; @@ -251,14 +217,12 @@ public class HudLockController { float r = size * 0.2f; float shackleTop = top + size * 0.08f; - // Pass 1: dark halo (expanded body, thicker shackle) for contrast; Pass 2: white glyph. drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d, Color.argb(Math.round(150 * a), 0, 0, 0), 3.0f * d, 0.9f * d, false); drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d, withAlpha(0xFFFFFFFF, a), 1.7f * d, 0f, true); } - /** One padlock pass. {@code halo} inflates the body/shackle in dark; the white pass punches the keyhole. */ private void drawLock(Canvas canvas, float cx, float bodyTop, float bodyLeft, float bodyRight, float bottom, float r, float shackleTop, float d, int color, float strokeW, float expand, boolean punchKeyhole) { @@ -266,7 +230,6 @@ public class HudLockController { iconStroke.setColor(color); iconStroke.setStrokeWidth(strokeW); - // Body canvas.drawRoundRect(bodyLeft - expand, bodyTop - expand, bodyRight + expand, bottom + expand, 2.4f * d, 2.4f * d, iconFill); if (punchKeyhole) { @@ -278,19 +241,16 @@ public class HudLockController { RectF arc = new RectF(cx - r, shackleTop, cx + r, shackleTop + 2f * r); if (fadeShowLocked) { - // Closed: both legs run straight down into the body. Path p = new Path(); p.addArc(arc, 180f, 180f); p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop); p.moveTo(cx + r, shackleTop + r); p.lineTo(cx + r, bodyTop); canvas.drawPath(p, iconStroke); } else { - // Open, clearly to the LEFT: the RIGHT leg stays seated in the body; the arc + LEFT leg - // hinge up-and-left off the right attachment so the left leg lifts well clear of the body. - canvas.drawLine(cx + r, shackleTop + r, cx + r, bodyTop, iconStroke); // fixed right leg + canvas.drawLine(cx + r, shackleTop + r, cx + r, bodyTop, iconStroke); Path p = new Path(); p.addArc(arc, 180f, 180f); - p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop); // full-length left leg + p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop); canvas.save(); canvas.rotate(-42f, cx + r, shackleTop + r); canvas.drawPath(p, iconStroke); diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java b/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java index a98aa8535..759e01351 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java +++ b/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java @@ -8,7 +8,8 @@ * link to https://github.com/The412Banner/FusionHUD in the project documentation * AND in the in-app credits/about screen. * - * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck. + * Metric readers from @xXJSONDeruloXx's work. */ package com.droiddeck.launcher.hud; @@ -32,50 +33,17 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; -/** - * Shared live-metric collector for every performance HUD overlay. This is the single, device-complete - * implementation of the sysfs / Android readers — no overlay should re-read sysfs itself, so the GPU - * discovery / thermal / battery logic never diverges. - * - *

Ported and hardened from GameNative's {@code PerformanceHudView} readers, which are far more - * device-complete than the two-fixed-path versions previously inlined in the classic overlays: - *

    - *
  • GPU load — ~10 static candidates plus a dynamic {@code /sys/class/devfreq} + - * {@code /sys/devices/virtual/devfreq} walk for gpu/mali/g3d/kgsl nodes; handles gpubusy - * (busy/total), Mali gpuinfo (delta-ms/wall-ms, needs state between calls) and generic percent - * nodes. Discovery cached.
  • - *
  • CPU usage — {@code /proc/stat} aggregate delta with a scaling-frequency fallback for devices - * where {@code /proc/stat} is restricted.
  • - *
  • Thermal — prioritized CPU zone discovery (cpu-silicon > cpu-0 > cpu > soc > - * s5p-tmu > cputop > tsens > cluster > big/little) and a separate prioritized GPU zone - * discovery plus kgsl/mali direct paths. milli-°C normalization + 1..150 sanity clamp.
  • - *
  • Battery — % capacity, power W, runtime-left estimate (charge_counter / current_now, smoothed), - * battery temperature, plus the dual-battery current-sum for the classic overlays.
  • - *
- * - *

Every getter is null / absent-safe: nullable getters return {@code null} when a metric cannot be - * read (the overlay hides that row, matching GameNative behavior); the legacy non-null getters return - * 0 in that case. Not thread-safe; call from a single refresh thread per instance. - */ public class HudMetrics { private final Context context; public HudMetrics(Context context) { this.context = context; } - // DroidDeck: the readings Steam's performance overlay shows come from MangoReadings, so both agree. private MangoReadings mango; public void setMangoReadings(MangoReadings readings) { mango = readings; } - // ======================================================================= - // CPU usage - // ======================================================================= private Long lastCpuTotal = null; private Long lastCpuIdle = null; - /** - * Overall device CPU usage 0..100 computed from the /proc/stat delta since the last call, with a - * scaling-frequency fallback. Returns null only when neither source is readable. - */ public Integer getCpuUsagePercent() { String line = readFirstLine("/proc/stat"); if (line != null) { @@ -104,8 +72,6 @@ public class HudMetrics { return clampPercent((int) usage); } } - // First sample seeds the delta — fall through to the frequency estimate so the - // very first read still returns something. } } } @@ -127,20 +93,10 @@ public class HudMetrics { return clampPercent((int) ((currentTotal * 100L) / maxTotal)); } - /** Legacy non-null accessor (0 when unavailable). Kept for {@code PerfHudView}. */ - public float getCPUUsage() { - Integer p = getCpuUsagePercent(); - return p == null ? 0f : p; - } - - // ======================================================================= - // GPU load - // ======================================================================= private List gpuUsagePathsCache = null; private Long lastMaliGpuInfoMs = null; private long lastMaliGpuInfoWallMs = 0L; - /** GPU utilisation 0..100, or null when no readable source is found on this device. */ public Integer getGpuUsagePercent() { for (String path : discoverGpuUsagePaths()) { Integer p = readGpuUsageSample(path); @@ -149,12 +105,6 @@ public class HudMetrics { return null; } - /** Legacy non-null accessor (0 when unavailable). Kept for {@code PerfHudView}. */ - public int getGPULoad() { - Integer p = getGpuUsagePercent(); - return p == null ? 0 : p; - } - private List discoverGpuUsagePaths() { if (gpuUsagePathsCache != null) return gpuUsagePathsCache; LinkedHashSet candidates = new LinkedHashSet<>(); @@ -163,8 +113,6 @@ public class HudMetrics { if (new File(p).canRead()) candidates.add(p); } - // Vendor platform nodes with hashed/addressed names we can't hard-code (e.g. - // /sys/devices/platform/13000000.mali/utilisation, .../gpusysfs/gpu_busy, panfrost, sgpu). File platformDir = new File("/sys/devices/platform"); File[] platformNodes = platformDir.listFiles(File::isDirectory); if (platformNodes != null) { @@ -211,9 +159,6 @@ public class HudMetrics { return gpuUsagePathsCache; } - // Ordered, world-readable-when-present GPU utilisation nodes across the vendors we've seen: - // Adreno KGSL, Mali (misc + Exynos vendor), PowerVR, and amdgpu-style sysfs (Samsung Xclipse / - // "sgpu", AMD-on-Exynos). readGpuUsageSample() knows how to parse each filename shape. private static final String[] GPU_USAGE_STATIC_PATHS = { "/sys/class/kgsl/kgsl-3d0/gpubusy", "/sys/class/kgsl/kgsl-3d0/gpu_busy_percentage", @@ -223,23 +168,19 @@ public class HudMetrics { "/sys/class/misc/mali0/device/gpuinfo", "/sys/devices/platform/mali/utilization", "/sys/kernel/gpu/gpu_busy", - "/sys/devices/platform/gpusysfs/gpu_busy", // Exynos Mali vendor node + "/sys/devices/platform/gpusysfs/gpu_busy", "/sys/class/misc/pvrsrvkm/device/utilisation", - "/sys/class/pvr/utilisation", // PowerVR (Rogue) sysfs + "/sys/class/pvr/utilisation", "/sys/class/pvr/gpu_utilisation", - "/sys/class/drm/card0/device/gpu_busy_percent", // amdgpu / Xclipse (sgpu), 0..100 + "/sys/class/drm/card0/device/gpu_busy_percent", "/sys/class/devfreq/gpu/load", - // ── Additive cross-vendor fallbacks (appended LAST: tried only after every path above - // misses, so a device that already resolves — e.g. Adreno kgsl — never reaches these). ── - "/sys/kernel/ged/hal/gpu_utilization", // MediaTek GED: " " → loading% - "/sys/module/ged/parameters/gpu_loading", // MediaTek GED module param, plain percent + "/sys/kernel/ged/hal/gpu_utilization", + "/sys/module/ged/parameters/gpu_loading", }; - // Percent-bearing filenames scanned under each devfreq / platform GPU node. private static final String[] GPU_USAGE_FILES = { "gpu_busy_percentage", "gpu_busy_percent", "gpu_load", "utilisation", "utilization", "load", "gpu_busy", "gpuinfo", }; - // Substrings that mark a devfreq / platform node as the GPU, across vendors. private static final String[] GPU_NODE_TOKENS = { "gpu", "mali", "g3d", "kgsl", "panfrost", "pvr", "powervr", "xclipse", "sgpu", }; @@ -258,8 +199,6 @@ public class HudMetrics { return clampPercent((int) ((busy * 100L) / total)); } case "gpuinfo": { - // Mali multi-line node: the GPU busy time (ms) is the last token of line 1. Utilisation - // is the delta over wall-clock delta between two reads, so it needs state. String line = readNthLine(path, 1); if (line == null) return null; String[] toks = line.trim().split("\\s+"); @@ -294,46 +233,12 @@ public class HudMetrics { return null; } - // ======================================================================= - // RAM - // ======================================================================= - public float getRAMPercent() { - ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo(); - am.getMemoryInfo(mi); - if (mi.totalMem <= 0) return 0; - return (mi.totalMem - mi.availMem) * 100f / mi.totalMem; - } + private List thermalZonesCache = null; - /** Used RAM as "x.xGB" (or "yMB" below 1 GB), matching GameNative's HUD text. */ - public String getUsedRamText() { - ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - if (am == null) return "—"; - ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo(); - am.getMemoryInfo(mi); - long usedBytes = Math.max(0L, mi.totalMem - mi.availMem); - double usedGb = usedBytes / (1024.0 * 1024.0 * 1024.0); - if (usedGb >= 1.0) return String.format(Locale.US, "%.1fGB", usedGb); - return (usedBytes / (1024L * 1024L)) + "MB"; - } - - // ======================================================================= - // Temperature - // ======================================================================= - private List thermalZonesCache = null; // each entry: {type, tempPath} - - /** Legacy non-null CPU-temperature accessor in °C (0 when unavailable). Kept for {@code PerfHudView}. */ - public float getTemperature() { - Integer t = getCpuTempC(); - return t == null ? 0f : t; - } - - /** Hottest representative CPU temperature in °C, or null when no CPU zone is readable. */ public Integer getCpuTempC() { return readTemperatureC(discoverPrioritizedCpuTempPaths()); } - /** GPU temperature in °C, or null when no GPU sensor is readable. */ public Integer getGpuTempC() { ArrayList paths = new ArrayList<>(); paths.add("/sys/class/kgsl/kgsl-3d0/temp"); @@ -346,16 +251,16 @@ public class HudMetrics { private List discoverPrioritizedCpuTempPaths() { return prioritizePaths(type -> { - if (type.contains("gpu")) return null; // never mistake a GPU zone for CPU + if (type.contains("gpu")) return null; if (type.contains("cpu-silicon")) return 0; if (type.contains("cpu-0")) return 1; - if (type.contains("cpuss")) return 2; // Qualcomm cpuss composite - if (type.contains("mtktscpu")) return 2; // MediaTek CPU thermal - if (type.contains("cpu")) return 2; // cpu, cpu-*-usr, cpu_thermal, cpu_center - if (type.contains("s5p-tmu")) return 3; // Exynos TMU + if (type.contains("cpuss")) return 2; + if (type.contains("mtktscpu")) return 2; + if (type.contains("cpu")) return 2; + if (type.contains("s5p-tmu")) return 3; if (type.contains("soc")) return 4; if (type.contains("cputop")) return 5; - if (type.contains("tsens")) return 6; // Qualcomm tsens aggregate + if (type.contains("tsens")) return 6; if (type.contains("cluster")) return 7; if (type.contains("big") || type.contains("little")) return 8; return null; @@ -365,11 +270,11 @@ public class HudMetrics { private List discoverPrioritizedGpuTempPaths() { return prioritizePaths(type -> { if (type.contains("gpu-silicon")) return 0; - if (type.contains("gpuss")) return 1; // Qualcomm gpuss - if (type.contains("mtktsgpu")) return 1; // MediaTek GPU thermal + if (type.contains("gpuss")) return 1; + if (type.contains("mtktsgpu")) return 1; if (type.contains("gpu-virt")) return 2; if (type.contains("gpu")) return 2; - if (type.contains("g3d")) return 3; // Exynos Mali (g3d) + if (type.contains("g3d")) return 3; if (type.contains("kgsl")) return 4; if (type.contains("mali")) return 5; if (type.contains("xclipse") || type.contains("sgpu")) return 6; @@ -381,12 +286,11 @@ public class HudMetrics { private List prioritizePaths(Ranker ranker) { List zones = discoverAllThermalZones(); - ArrayList order = new ArrayList<>(); // {index, rank} + ArrayList order = new ArrayList<>(); for (int i = 0; i < zones.size(); i++) { Integer r = ranker.rank(zones.get(i)[0]); if (r != null) order.add(new int[]{i, r}); } - // Sort by rank, then by path for determinism. order.sort((a, b) -> { if (a[1] != b[1]) return Integer.compare(a[1], b[1]); return zones.get(a[0])[1].compareTo(zones.get(b[0])[1]); @@ -426,14 +330,12 @@ public class HudMetrics { if (!seen.add(path)) continue; Integer raw = readIntFromLine(path); if (raw == null) continue; - // Round to nearest degree for milli-°C sources; sanity-clamp to reject offline sensors. int celsius = raw > 1000 ? (raw + 500) / 1000 : raw; if (celsius >= 1 && celsius <= 150) return celsius; } return null; } - /** The first path in {@code paths} that yields a plausible reading, or null. */ private String resolveTempPath(List paths) { LinkedHashSet seen = new LinkedHashSet<>(); for (String path : paths) { @@ -446,13 +348,8 @@ public class HudMetrics { return null; } - // ======================================================================= - // Temperature danger bands (green / amber / red) - // ======================================================================= - public enum TempSensor { CPU, GPU, BATTERY } - /** Amber and red onset in °C. Always °C internally — conversion happens at format time only. */ public static final class Thresholds { public final int amberC; public final int redC; @@ -463,19 +360,14 @@ public class HudMetrics { public static final int TEMP_COLOR_AMBER = 0xFFFFB74D; public static final int TEMP_COLOR_RED = 0xFFEF5350; - // Tier-2 fallbacks, used when the device declares no usable trip points. Battery is the only one - // with a well-grounded number (Li-ion); CPU/GPU vary enough between parts that these are a - // deliberately conservative guess — which is exactly why trip points are preferred. private static final int DEFAULT_RED_CPU = 90; private static final int DEFAULT_RED_GPU = 90; private static final int DEFAULT_RED_BAT = 48; - /** Amber sits a proportional step below red, which scales sensibly across both CPU/GPU and battery ranges. */ public static int amberForRed(int redC) { return Math.round(redC * 0.88f); } - /** Parsed temperature display options, shared by every overlay so they can't disagree. */ public static final class TempDisplay { public final boolean fahrenheit; public final boolean colorBands; @@ -518,17 +410,11 @@ public class HudMetrics { } } - // Trip points are a device constant — resolve once per sensor and keep it. private final java.util.EnumMap autoThresholdCache = new java.util.EnumMap<>(TempSensor.class); private final java.util.EnumSet autoThresholdResolved = java.util.EnumSet.noneOf(TempSensor.class); - /** - * Bands for a sensor. Manual mode uses the user's red point; Auto prefers the device's OWN - * declared thermal trip points (per-device truth — a handheld with active cooling and a phone on - * the same SoC throttle at very different points) and falls back to {@link #DEFAULT_RED_CPU} etc. - */ public Thresholds resolveThresholds(TempSensor sensor, TempDisplay display) { if (display != null && !display.auto) { int red = display.manualRedFor(sensor); @@ -547,15 +433,6 @@ public class HudMetrics { return new Thresholds(amberForRed(red), red); } - /** - * Bands from the kernel's declared trip points for the zone we actually read this sensor from: - * amber ← the lowest sane {@code passive} trip, red ← the lowest sane {@code hot}/{@code critical}. - * - *

Returns null unless a usable red point is found. Two real traps this guards against: some - * zones aren't temperature sensors at all (a Snapdragon's {@code pm8550-bcl-lvl0} battery-current - * limiter reports {@code trip_point_0_temp = 1} with type {@code passive}), and the GPU is often - * read from a non-thermal_zone node ({@code /sys/class/kgsl/...}) that has no trip points. - */ private Thresholds readTripPointThresholds(TempSensor sensor) { String tempPath; switch (sensor) { @@ -563,7 +440,7 @@ public class HudMetrics { tempPath = resolveTempPath(discoverPrioritizedGpuTempPaths()); break; case BATTERY: - return null; // battery temp comes from BatteryManager, not a thermal zone + return null; default: tempPath = resolveTempPath(discoverPrioritizedCpuTempPaths()); } @@ -580,8 +457,6 @@ public class HudMetrics { Integer raw = readIntFromLine(tripTemp.getPath()); if (raw == null) continue; int celsius = raw > 1000 ? (raw + 500) / 1000 : raw; - // Reject anything that isn't plausibly a thermal trip — this is what filters out the - // battery-current-limiter zones whose "trip point" is a level index, not a temperature. if (celsius < 20 || celsius > 120) continue; String typePath = tripTemp.getPath().replaceAll("_temp$", "_type"); @@ -600,7 +475,6 @@ public class HudMetrics { return new Thresholds(amber, red); } - /** Band colour for a reading, or {@code fallbackColor} when banding is off or the value is unusable. */ public static int tempColor(Float celsius, Thresholds t, TempDisplay display, int fallbackColor) { if (celsius == null || t == null || display == null || !display.colorBands) return fallbackColor; if (celsius >= t.redC) return TEMP_COLOR_RED; @@ -608,7 +482,6 @@ public class HudMetrics { return TEMP_COLOR_OK; } - /** Formats a °C reading in the user's unit. Thresholds stay °C; only display converts. */ public static String formatTemp(float celsius, TempDisplay display, boolean oneDecimal) { boolean f = display != null && display.fahrenheit; float value = f ? celsius * 9f / 5f + 32f : celsius; @@ -618,15 +491,12 @@ public class HudMetrics { : String.format(Locale.ENGLISH, "%d%s", Math.round(value), unit); } - // ======================================================================= - // Battery - // ======================================================================= public static final class Battery { public final float watts; public final boolean charging; - public final Integer percent; // 0..100 or null - public final Integer tempC; // rounded °C or null - public final String runtimeText; // "LEFT 2h 5m" / "LEFT CHG" / null + public final Integer percent; + public final Integer tempC; + public final String runtimeText; Battery(float watts, boolean charging) { this(watts, charging, null, null, null); @@ -640,29 +510,23 @@ public class HudMetrics { } } - /** power_supply current_now channels (µA) for the dual-battery sum. */ private static final String[] CURRENT_CHANNELS = { "/sys/class/power_supply/battery/current_now", "/sys/class/power_supply/bms/current_now", "/sys/class/power_supply/main/current_now", }; - /** power_supply voltage_now channels (µV) — fallback when {@code EXTRA_VOLTAGE} reads 0 on some - * OEM firmwares (e.g. HONOR Magic), which otherwise leaves wattage stuck at 0.0W. */ private static final String[] VOLTAGE_CHANNELS = { "/sys/class/power_supply/battery/voltage_now", "/sys/class/power_supply/bms/voltage_now", "/sys/class/power_supply/main/voltage_now", }; - /** power_supply power_now channels (µW) — last-resort direct power reading when neither the - * BatteryManager voltage nor {@code voltage_now} is available. */ private static final String[] POWER_CHANNELS = { "/sys/class/power_supply/battery/power_now", "/sys/class/power_supply/bms/power_now", "/sys/class/power_supply/main/power_now", }; - /** First readable long across a channel list, or null. */ private static Long readLongFromChannels(String[] paths) { for (String p : paths) { Long v = readLongFromLine(p); @@ -671,15 +535,6 @@ public class HudMetrics { return null; } - /** - * ADDITIVE charging fallback from {@code /sys/class/power_supply/*}, consulted ONLY when the - * framework gave no usable charge state ({@code EXTRA_STATUS == UNKNOWN} or a null battery intent). - * A charger/input supply reporting {@code online == 1} — or the battery's own {@code status} reading - * "Charging"/"Full" — means charging. Returns TRUE when charging is positively detected, FALSE when a - * status/online node is readable but not charging, or null when nothing usable exists (so the caller - * keeps its own default). Never reached on devices whose BatteryManager reports a real status - * (e.g. Adreno phones/handhelds), so it cannot change their reading. - */ private static Boolean readChargingFromPowerSupply() { File[] supplies = new File("/sys/class/power_supply").listFiles(); if (supplies == null || supplies.length == 0) return null; @@ -690,14 +545,12 @@ public class HudMetrics { type = type == null ? "" : type.trim().toLowerCase(Locale.US); boolean isBattery = type.equals("battery") || name.equals("battery") || name.equals("bms"); if (isBattery) { - // The battery's own status string is the most direct charge-state signal. String st = readFirstLine(new File(supply, "status").getPath()); if (st == null) continue; st = st.trim().toLowerCase(Locale.US); - sawSignal = Boolean.FALSE; // "discharging"/"not charging" both land here + sawSignal = Boolean.FALSE; if (st.equals("charging") || st.equals("full")) return Boolean.TRUE; } else { - // A charger / power-input supply (ac/usb/wireless/mains/dc): online==1 → external power in. Long online = readLongFromLine(new File(supply, "online").getPath()); if (online == null) continue; sawSignal = Boolean.FALSE; @@ -712,55 +565,6 @@ public class HudMetrics { private static final double RUNTIME_SMOOTHING_OLD_WEIGHT = 0.65; private static final double RUNTIME_SMOOTHING_NEW_WEIGHT = 0.35; - /** - * Legacy discharge-only power reading used by the classic overlays + {@code PerfHudView}: - * watts are reported only while discharging (0 when charging). When {@code dualBattery} is set the - * per-cell current channels are summed to correct devices that report only one cell's current. - */ - public Battery getBattery(boolean dualBattery) { - Intent status = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); - boolean charging = false; - int voltageMv = 0; - if (status != null) { - charging = status.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0; - voltageMv = status.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0); - } else { - // ADDITIVE: no battery intent → best-effort power_supply charge probe (default stays false). - // Never reached on devices that deliver ACTION_BATTERY_CHANGED (e.g. Adreno). - Boolean sysCharging = readChargingFromPowerSupply(); - if (sysCharging != null) charging = sysCharging; - } - long microAmps; - if (dualBattery) { - long sum = 0; int n = 0; - for (String path : CURRENT_CHANNELS) { - Long v = readLongFromLine(path); - if (v != null) { sum += Math.abs(v); n++; } - } - microAmps = n > 0 ? -sum : readCurrentNowFallback(); - } else { - microAmps = readCurrentNowFallback(); - } - // current_now's SIGN convention is device-dependent — many OEMs (Xiaomi/Poco especially) - // report discharge as POSITIVE, the opposite of the AOSP convention. Gating wattage on the - // sign therefore reads 0W on battery on those devices (and only shows a value while charging). - // Use the magnitude for the power figure; the reliable charge DIRECTION is EXTRA_PLUGGED (the - // `charging` flag), which the HUD already uses for the PWR/CHG label. - // EXTRA_VOLTAGE reads 0 on some OEM firmwares even though current is fine — fall back to the - // power_supply voltage_now (µV → mV) so wattage isn't stuck at 0. - if (voltageMv <= 0) { - Long uv = readLongFromChannels(VOLTAGE_CHANNELS); - if (uv != null && uv > 0) voltageMv = (int) (Math.abs(uv) / 1000L); - } - // current unit auto-detected (mA vs µA) — see currentRawToAmps. - float watts = (voltageMv / 1000f) * currentRawToAmps(microAmps); - return new Battery(watts, charging); - } - - /** - * Full GameNative-parity battery snapshot for the GameNative-style HUD: %, power W (magnitude, - * regardless of charge/discharge), smoothed runtime-left estimate, and temperature. - */ public Battery collectBattery() { BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); if (bm == null) return new Battery(0f, false); @@ -771,15 +575,12 @@ public class HudMetrics { Intent status = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); if (status == null) { - // No battery intent at all → additive power_supply charge probe (default stays false). Boolean sysCharging = readChargingFromPowerSupply(); return new Battery(0f, sysCharging != null && sysCharging, percent, null, null); } int st = status.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN); boolean charging = st == BatteryManager.BATTERY_STATUS_CHARGING || st == BatteryManager.BATTERY_STATUS_FULL; - // ADDITIVE: only when the framework reports no usable charge state do we consult power_supply. - // A device that reports CHARGING/DISCHARGING/FULL/NOT_CHARGING (e.g. Adreno) never enters this. if (st == BatteryManager.BATTERY_STATUS_UNKNOWN) { Boolean sysCharging = readChargingFromPowerSupply(); if (sysCharging != null) charging = sysCharging; @@ -792,23 +593,16 @@ public class HudMetrics { int rawTemp = status.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0); if (rawTemp > 0) tempC = Math.round(rawTemp / 10f); - // EXTRA_VOLTAGE reads 0 on some OEM firmwares (e.g. HONOR Magic) even though capacity, temp - // and current all work — which left wattage stuck at 0.0W. Fall back to the power_supply - // voltage_now (µV → mV); if voltage is still unavailable, use power_now (µW) directly. if (voltageMv <= 0) { Long uv = readLongFromChannels(VOLTAGE_CHANNELS); if (uv != null && uv > 0) voltageMv = (int) (Math.abs(uv) / 1000L); } - // Wattage = current × voltage, with current UNIT auto-detected (CURRENT_NOW is nominally µA but - // many OEMs — e.g. this HONOR — report mA; see batteryCurrentAmps). currentMicroAmps above stays - // as-is for the runtime estimate (a ratio, so unit-independent). If neither current nor voltage - // resolves, fall back to power_now (µW) directly. float amps = batteryCurrentAmps(bm); float watts = 0f; if (amps > 0f && voltageMv > 0) { watts = (voltageMv / 1000f) * amps; } else { - Long uw = readLongFromChannels(POWER_CHANNELS); // µW + Long uw = readLongFromChannels(POWER_CHANNELS); if (uw != null && uw != 0L) watts = (float) (Math.abs(uw) / 1_000_000.0); } @@ -842,24 +636,12 @@ public class HudMetrics { return minutes + "m"; } - private long readCurrentNowFallback() { - BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); - return bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW); - } - - /** Raw battery current reading → AMPS, with unit AUTO-DETECT. {@code BATTERY_PROPERTY_CURRENT_NOW} - * (and {@code current_now}) are nominally µA, but many OEMs report mA — a small number, whereas a - * real µA reading is always huge. So a magnitude < 20000 means the device gave mA. Without this, - * wattage on mA-reporting devices came out ~1000× too small → 0.0W even though current & voltage are - * fine. Sentinel/zero → 0. (Heuristic mirrors Ludashi-plus WinlatorHUD.getBatteryCurrentAmps().) */ private static float currentRawToAmps(long raw) { if (raw == 0L || raw == Long.MIN_VALUE) return 0f; long a = Math.abs(raw); - return a < 20000L ? a / 1000f : a / 1_000_000f; // mA → A, else µA → A + return a < 20000L ? a / 1000f : a / 1_000_000f; } - /** Battery current in AMPS for the wattage calc: framework property first, then power_supply - * {@code current_now} sysfs (battery/bms/main) when it's unsupported ({@code Long.MIN_VALUE}) or 0. */ private float batteryCurrentAmps(BatteryManager bm) { long raw = (bm != null) ? bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) : 0L; if (raw == 0L || raw == Long.MIN_VALUE) { @@ -869,11 +651,6 @@ public class HudMetrics { return currentRawToAmps(raw); } - // ======================================================================= - // Clock frequencies (best-effort, nullable) - // ======================================================================= - - /** Peak CPU core clock in MHz (max scaling_cur_freq across cores), or null when unreadable. */ public Integer getCpuClockMhz() { int cores = Runtime.getRuntime().availableProcessors(); long maxKhz = 0; @@ -882,29 +659,26 @@ public class HudMetrics { if (cur != null && cur > maxKhz) maxKhz = cur; } if (maxKhz <= 0) return null; - return (int) (maxKhz / 1000L); // kHz → MHz + return (int) (maxKhz / 1000L); } private List gpuClockPathsCache = null; private static final String[] GPU_CLOCK_PATHS = { - "/sys/class/kgsl/kgsl-3d0/gpuclk", // Adreno, Hz - "/sys/class/kgsl/kgsl-3d0/devfreq/cur_freq", // Adreno devfreq, Hz - "/sys/class/kgsl/kgsl-3d0/clock_mhz", // some kernels, already MHz - "/sys/kernel/gpu/gpu_clock", // Mali vendor node, MHz - "/sys/devices/platform/gpusysfs/gpu_clock", // Exynos Mali vendor node, MHz - "/sys/class/misc/mali0/device/clock", // Mali, kHz/MHz - "/sys/class/devfreq/gpu/cur_freq", // generic devfreq gpu, Hz - // ── Additive cross-vendor fallbacks (appended LAST: only reached when every node above - // is unreadable, so Adreno/Mali devices that already resolve are unaffected). ── - "/sys/kernel/ged/hal/current_freqency", // MediaTek GED (vendor's spelling), kHz - "/sys/kernel/ged/hal/gpu_clock", // MediaTek GED alt, MHz + "/sys/class/kgsl/kgsl-3d0/gpuclk", + "/sys/class/kgsl/kgsl-3d0/devfreq/cur_freq", + "/sys/class/kgsl/kgsl-3d0/clock_mhz", + "/sys/kernel/gpu/gpu_clock", + "/sys/devices/platform/gpusysfs/gpu_clock", + "/sys/class/misc/mali0/device/clock", + "/sys/class/devfreq/gpu/cur_freq", + "/sys/kernel/ged/hal/current_freqency", + "/sys/kernel/ged/hal/gpu_clock", }; private List discoverGpuClockPaths() { if (gpuClockPathsCache != null) return gpuClockPathsCache; LinkedHashSet found = new LinkedHashSet<>(); for (String p : GPU_CLOCK_PATHS) if (new File(p).canRead()) found.add(p); - // Vendor-agnostic devfreq walk: any gpu/mali/panfrost/xclipse/sgpu node's cur_freq (Hz). File[] devfreqRoots = { new File("/sys/class/devfreq"), new File("/sys/devices/virtual/devfreq"), @@ -928,49 +702,31 @@ public class HudMetrics { return gpuClockPathsCache; } - /** GPU clock in MHz (Adreno KGSL / devfreq / Mali nodes), or null when no source reports it. */ public Integer getGpuClockMhz() { for (String p : discoverGpuClockPaths()) { Long raw = readLongFromLine(p); if (raw == null || raw <= 0) continue; long mhz; - if (raw >= 1_000_000L) mhz = raw / 1_000_000L; // Hz - else if (raw >= 10_000L) mhz = raw / 1_000L; // kHz (defensive) - else mhz = raw; // already MHz + if (raw >= 1_000_000L) mhz = raw / 1_000_000L; + else if (raw >= 10_000L) mhz = raw / 1_000L; + else mhz = raw; if (mhz >= 1 && mhz <= 3000) return (int) mhz; } return null; } - // ======================================================================= - // VRAM (best-effort, Java-only sysfs — no native rebuild) - // ======================================================================= - // Covered here in pure Java: Adreno KGSL (bytes mapped/allocated) and amdgpu-style drm nodes - // (Samsung Xclipse / "sgpu", AMD-on-Exynos), which expose mem_info_vram_used/_total directly. - // TODO(native): VK_EXT_memory_budget as a vendor-agnostic fallback for Mali/PowerVR, which expose - // no world-readable VRAM node. FEASIBILITY (checked 2026-07): the compositor's Vulkan layer - // (app/src/main/cpp/winlator/vulkan.c) creates an EPHEMERAL VkInstance/VkPhysicalDevice inside - // getRenderer() and destroys it every call — it holds no reusable device and loads only the - // properties/extension entrypoints, not vkGetPhysicalDeviceMemoryProperties2. Wiring the budget - // query therefore means a new JNI method + enabling VK_KHR_get_physical_device_properties2 / - // VK_EXT_memory_budget at instance create + an NDK rebuild — out of scope for a source-only pass. - // CAVEAT to document if/when added: this HUD is an app-side overlay, NOT in the game's Vulkan - // device, so an app-created instance reports the DEVICE-WIDE heap budget (a GPU-memory-pressure - // proxy), not the game's per-process VRAM. Devices with no readable node return null → HUD hides VRAM. private List vramUsedPathsCache = null; private static final String[] VRAM_USED_PATHS = { - "/sys/class/kgsl/kgsl-3d0/gpumem_mapped", // bytes currently mapped to the GPU - "/sys/class/kgsl/kgsl-3d0/page_alloc", // bytes allocated to the KGSL page pool - "/sys/class/kgsl/kgsl-3d0/mapped", // alt name on some kernels - "/sys/class/drm/card0/device/mem_info_vram_used",// amdgpu / Xclipse (sgpu), bytes - "/sys/class/kgsl/kgsl-3d0/page_alloc_max", // high-water fallback + "/sys/class/kgsl/kgsl-3d0/gpumem_mapped", + "/sys/class/kgsl/kgsl-3d0/page_alloc", + "/sys/class/kgsl/kgsl-3d0/mapped", + "/sys/class/drm/card0/device/mem_info_vram_used", + "/sys/class/kgsl/kgsl-3d0/page_alloc_max", }; - // Total-VRAM companions (amdgpu exposes one; used only by the diagnostics report). private static final String[] VRAM_TOTAL_PATHS = { "/sys/class/drm/card0/device/mem_info_vram_total", }; - /** Used GPU memory in bytes from Adreno KGSL sysfs, or null when this device exposes nothing readable. */ public Long getVramUsedBytes() { if (vramUsedPathsCache == null) { ArrayList found = new ArrayList<>(); @@ -986,50 +742,33 @@ public class HudMetrics { private static Long sanitizeVramBytes(Long v) { if (v == null) return null; - if (v < (1L << 20)) return null; // < 1 MiB → idle-zero / not a byte count - if (v > 64L * (1L << 30)) return null; // > 64 GiB → not bytes + if (v < (1L << 20)) return null; + if (v > 64L * (1L << 30)) return null; return v; } - /** Used GPU memory as "x.xGiB" (or "yMiB"), or null when no VRAM source is readable. */ - public String getVramUsedText() { - Long b = getVramUsedBytes(); - return b == null ? null : formatGiB(b); - } - private static String formatGiB(long bytes) { return String.format(Locale.US, "%.1fGiB", bytes / (1024.0 * 1024.0 * 1024.0)); } - // ======================================================================= - // Single cached snapshot — read EVERYTHING once per HUD refresh - // ======================================================================= - - /** - * Immutable point-in-time reading of every metric the DroidDeck HUD draws. Collected in one pass by - * {@link #snapshot()} so the view never touches sysfs / BatteryManager during draw (the user's - * explicit ask: one collection pass, cached, so gameplay isn't strained). All numeric fields are - * raw (nullable when unreadable); the view formats + colours them. - */ public static final class Snapshot { - public final Integer cpuPercent; // 0..100 or null - public final Integer gpuPercent; // 0..100 or null - public final Integer cpuClockMhz; // MHz or null - public final Integer gpuClockMhz; // MHz or null - public final Integer cpuTempC; // °C or null - public final Integer gpuTempC; // °C or null + public final Integer cpuPercent; + public final Integer gpuPercent; + public final Integer cpuClockMhz; + public final Integer gpuClockMhz; + public final Integer cpuTempC; + public final Integer gpuTempC; public final long ramUsedBytes; public final long ramTotalBytes; - public final float ramPercent; // 0..100 - public final Long vramUsedBytes; // bytes or null - public final Battery battery; // never null - // ---- Mega-only extras ---- - public final int[] perCorePercent; // per-core 0..100, -1 unknown (never null; may be empty) - public final int[] perCoreClockMhz; // per-core MHz, 0 unknown (never null; may be empty) - public final Long swapUsedBytes; // bytes or null (no swap) - public final Long swapTotalBytes; // bytes or null - public final Long netDownBps; // bytes/sec down, or null (first sample / unreadable) - public final Long netUpBps; // bytes/sec up, or null + public final float ramPercent; + public final Long vramUsedBytes; + public final Battery battery; + public final int[] perCorePercent; + public final int[] perCoreClockMhz; + public final Long swapUsedBytes; + public final Long swapTotalBytes; + public final Long netDownBps; + public final Long netUpBps; Snapshot(Integer cpuPercent, Integer gpuPercent, Integer cpuClockMhz, Integer gpuClockMhz, Integer cpuTempC, Integer gpuTempC, long ramUsedBytes, long ramTotalBytes, @@ -1066,7 +805,6 @@ public class HudMetrics { } } - /** One collection pass over every metric. Call from the HUD's own ~1 s refresh thread, never draw. */ public Snapshot snapshot() { if (mango != null) return mangoSnapshot(mango); Integer cpu = getCpuUsagePercent(); @@ -1102,7 +840,6 @@ public class HudMetrics { net == null ? null : net[0], net == null ? null : net[1]); } - // DroidDeck: the same snapshot with MangoHud's sources and rounding (see MangoReadings). private Snapshot mangoSnapshot(MangoReadings m) { MangoReadings.Cpu cpu = m.cpu(); MangoReadings.Memory mem = m.memory(); @@ -1121,11 +858,6 @@ public class HudMetrics { net == null ? null : net[0], net == null ? null : net[1]); } - // ======================================================================= - // Mega-view extras: per-core CPU, swap, network (best-effort, nullable) - // ======================================================================= - - /** Per-core clock in MHz (each core's {@code scaling_cur_freq}); 0 for a core that can't be read. */ public int[] getPerCoreClockMhz() { int cores = Runtime.getRuntime().availableProcessors(); int[] out = new int[Math.max(0, cores)]; @@ -1136,11 +868,9 @@ public class HudMetrics { return out; } - // Per-core /proc/stat deltas — one prev sample per core, seeded on the first read. private long[] lastCoreTotal = null; private long[] lastCoreIdle = null; - /** Per-core CPU usage 0..100 from the {@code cpuN} /proc/stat deltas; -1 for a core not yet sampled. */ public int[] readPerCorePercent() { int cores = Runtime.getRuntime().availableProcessors(); int[] out = new int[Math.max(0, cores)]; @@ -1153,8 +883,8 @@ public class HudMetrics { try (BufferedReader r = new BufferedReader(new FileReader("/proc/stat"))) { String line; while ((line = r.readLine()) != null) { - if (!line.startsWith("cpu")) break; // cpu lines are first; stop at the block end - if (line.length() < 4 || !Character.isDigit(line.charAt(3))) continue; // skip aggregate "cpu " + if (!line.startsWith("cpu")) break; + if (line.length() < 4 || !Character.isDigit(line.charAt(3))) continue; String[] parts = line.trim().split("\\s+"); int core; try { core = Integer.parseInt(parts[0].substring(3)); } catch (Exception e) { continue; } @@ -1165,8 +895,8 @@ public class HudMetrics { try { long v = Long.parseLong(parts[i]); total += v; - if (i == 4) idle = v; // idle - else if (i == 5) idle += v; // + iowait + if (i == 4) idle = v; + else if (i == 5) idle += v; } catch (NumberFormatException e) { ok = false; break; } } if (!ok) continue; @@ -1179,10 +909,6 @@ public class HudMetrics { } } } catch (Exception ignored) {} - // Frequency fallback for any core /proc/stat couldn't give us — restricted on some firmwares - // (e.g. HONOR / Android 16), which is also why the aggregate getCpuUsagePercent() falls back. - // scaling_cur_freq / cpuinfo_max_freq is a load proxy (per-core clocks ARE readable there), and - // it also seeds the very first sample. Cores with a real /proc/stat delta keep it (no regression). for (int i = 0; i < out.length; i++) { if (out[i] >= 0) continue; Long cur = readLongFromLine("/sys/devices/system/cpu/cpu" + i + "/cpufreq/scaling_cur_freq"); @@ -1193,7 +919,6 @@ public class HudMetrics { return out; } - /** Swap {used, total} in bytes from /proc/meminfo, or null when there is no swap. */ public long[] getSwapBytes() { long totalKb = -1, freeKb = -1; try (BufferedReader r = new BufferedReader(new FileReader("/proc/meminfo"))) { @@ -1215,10 +940,8 @@ public class HudMetrics { return v == null ? -1 : v; } - // Net rate: cumulative rx/tx bytes + the wall-clock of the previous read → bytes/sec. private long lastNetRx = -1, lastNetTx = -1, lastNetWallMs = 0; - /** Network {down, up} in bytes/sec (all interfaces except loopback), or null on the first/failed read. */ public long[] readNetRateBps() { long rx = 0, tx = 0; boolean any = false; @@ -1241,7 +964,7 @@ public class HudMetrics { long now = SystemClock.elapsedRealtime(); long prevRx = lastNetRx, prevTx = lastNetTx, prevWall = lastNetWallMs; lastNetRx = rx; lastNetTx = tx; lastNetWallMs = now; - if (prevRx < 0 || prevWall <= 0) return null; // seed + if (prevRx < 0 || prevWall <= 0) return null; long dt = now - prevWall; if (dt <= 0) return null; long down = Math.max(0L, (rx - prevRx) * 1000L / dt); @@ -1249,20 +972,8 @@ public class HudMetrics { return new long[]{down, up}; } - // ======================================================================= - // Diagnostics report (invoked ONLY on user tap — never on the HUD refresh path) - // ======================================================================= - - /** - * One-tap plain-text device report for the "Export HUD diagnostics" action: for every metric it - * lists the resolved source path/API + current value, or MISS + the exact candidates it tried, - * followed by an "exists on this device" dump (thermal zone types, devfreq/kgsl/mali nodes) so a - * device owner can send back the real path for anything that missed. Does a short prime+sample so - * delta-based readers (CPU%/GPU%/net) report a live value. Run OFF the main thread. - */ public String buildDiagnosticsReport(Context ctxIn) { final Context ctx = ctxIn != null ? ctxIn : this.context; - // Prime the delta-based readers, wait, then take a live sample. snapshot(); try { Thread.sleep(350L); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } Snapshot s = snapshot(); @@ -1281,7 +992,6 @@ public class HudMetrics { sb.append("\n== Metrics ==\n"); - // ---- CPU ---- boolean procStat = new File("/proc/stat").canRead(); if (s.cpuPercent != null) { metricHit(sb, "CPU %", s.cpuPercent + "%", @@ -1292,7 +1002,6 @@ public class HudMetrics { "/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq"); } - // Per-core % StringBuilder cores = new StringBuilder(); for (int i = 0; i < s.perCorePercent.length; i++) { if (i > 0) cores.append(' '); @@ -1304,7 +1013,6 @@ public class HudMetrics { else metricMiss(sb, "Per-core %", "/proc/stat"); - // Per-core clock StringBuilder coreClk = new StringBuilder(); for (int i = 0; i < s.perCoreClockMhz.length; i++) { if (i > 0) coreClk.append(' '); @@ -1317,7 +1025,6 @@ public class HudMetrics { else metricMiss(sb, "CPU clock", "/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq"); - // CPU temp String cpuTempPath = resolveTempPath(discoverPrioritizedCpuTempPaths()); if (s.cpuTempC != null && cpuTempPath != null) metricHit(sb, "CPU temp", s.cpuTempC + "°C", cpuTempPath); @@ -1325,7 +1032,6 @@ public class HudMetrics { metricMiss(sb, "CPU temp", "/sys/class/thermal/thermal_zone*/temp (types: cpu*/cpuss/tsens/mtktscpu/s5p-tmu/soc)"); - // ---- GPU ---- String gpuUsagePath = firstReadableGpuUsagePath(); if (s.gpuPercent != null && gpuUsagePath != null) metricHit(sb, "GPU %", s.gpuPercent + "%", gpuUsagePath); @@ -1347,7 +1053,6 @@ public class HudMetrics { "/sys/class/kgsl/kgsl-3d0/temp", "/sys/class/misc/mali0/device/temp", "/sys/class/thermal/thermal_zone*/temp (types: gpu*/gpuss/g3d/kgsl/mali/mtktsgpu)"); - // ---- Memory ---- metricHit(sb, "RAM", s.ramUsedText() + " / " + s.ramTotalText() + " (" + Math.round(s.ramPercent) + "%)", "ActivityManager.MemoryInfo"); if (s.swapUsedBytes != null) @@ -1355,7 +1060,6 @@ public class HudMetrics { else metricMiss(sb, "SWAP", "/proc/meminfo (SwapTotal=0 or unreadable)"); - // VRAM String vramPath = firstReadableVramUsedPath(); if (s.vramUsedBytes != null && vramPath != null) { Long total = readFirstReadableLong(VRAM_TOTAL_PATHS); @@ -1366,14 +1070,12 @@ public class HudMetrics { new ArrayList<>(), VRAM_USED_PATHS); } - // ---- Net ---- if (s.netDownBps != null) metricHit(sb, "NET", formatRate(s.netDownBps) + " down / " + formatRate(s.netUpBps) + " up", "/proc/net/dev"); else metricMiss(sb, "NET", "/proc/net/dev (first sample or unreadable)"); - // ---- Battery ---- Battery b = s.battery; StringBuilder bat = new StringBuilder(); bat.append(b.percent != null ? b.percent + "%" : "?%") @@ -1383,7 +1085,6 @@ public class HudMetrics { .append(b.runtimeText != null ? ", " + b.runtimeText : ""); metricHit(sb, "Battery", bat.toString(), "BatteryManager + /sys/class/power_supply"); - // ---- Exists-on-device dump ---- appendExistsDump(sb); return sb.toString(); } @@ -1415,7 +1116,7 @@ public class HudMetrics { } private String firstReadableVramUsedPath() { - if (vramUsedPathsCache == null) getVramUsedBytes(); // builds the cache + if (vramUsedPathsCache == null) getVramUsedBytes(); if (vramUsedPathsCache == null) return null; for (String p : vramUsedPathsCache) if (sanitizeVramBytes(readLongFromLine(p)) != null) return p; return null; @@ -1479,12 +1180,6 @@ public class HudMetrics { appendGpuNoRootProbes(sb); } - /** Probes for a ROOT-FREE GPU-load path on locked-sysfs Adreno devices (SM8750/HONOR etc.): - * (1) can the app open the KGSL device node? The sysfs mirror (/sys/class/kgsl) is SELinux-blocked - * (vendor_sysfs_kgsl), but /dev/kgsl-3d0 is labeled gpu_device and is normally app-openable — - * that's the door for reading GPU busy via ioctl perf-counters instead of sysfs. - * (2) is there an app-readable devfreq node under /sys/devices/platform that bypasses the blocked - * /sys/class/kgsl symlink (a free win for at least the clock). */ private void appendGpuNoRootProbes(StringBuilder sb) { sb.append("-- GPU no-root reachability probes --\n"); sb.append(" /dev/kgsl-3d0 open O_RDWR: ") @@ -1516,8 +1211,6 @@ public class HudMetrics { if (!found) sb.append(" devfreq (platform kgsl): (no app-readable node found)\n"); } - /** open()+close() a path with the given flags, reporting OK or the errno class — used to test - * whether the app's sandbox can reach a device node without root. */ private static String probeOpen(String path, int flags) { java.io.FileDescriptor fd = null; try { @@ -1530,8 +1223,6 @@ public class HudMetrics { } } - /** Battery-wattage inputs: which power_supply nodes this app can actually READ, and their raw - * values. Confirms the voltage_now / power_now fallback for devices where EXTRA_VOLTAGE is 0. */ private void appendPowerSupplyDump(StringBuilder sb) { sb.append("-- /sys/class/power_supply (battery watts inputs) --\n"); File[] nodes = new File("/sys/class/power_supply").listFiles(); @@ -1582,9 +1273,6 @@ public class HudMetrics { return names.length() == 0 ? "(no readable files)" : names.toString(); } - // ======================================================================= - // Low-level helpers - // ======================================================================= private static int clampPercent(int v) { return v < 0 ? 0 : (v > 100 ? 100 : v); } private static String readFirstLine(String path) { diff --git a/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java b/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java index 1c3a1ba9b..f455d8525 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java +++ b/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java @@ -8,7 +8,7 @@ * link to https://github.com/The412Banner/FusionHUD in the project documentation * AND in the in-app credits/about screen. * - * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck. */ package com.droiddeck.launcher.hud; @@ -20,17 +20,14 @@ import java.util.Iterator; public class KeyValueSet implements Iterable { private String data = ""; - // Default constructor for an empty KeyValueSet public KeyValueSet() { this.data = ""; } - // Constructor to initialize with a string representation of key-value pairs public KeyValueSet(String data) { this.data = data != null && !data.isEmpty() ? data : ""; } - // Helper method to find the start and end indices of a key in the data string private int[] indexOfKey(String key) { int start = 0; int end = data.indexOf(","); @@ -48,37 +45,31 @@ public class KeyValueSet implements Iterable { return null; } - // Method to get the value associated with a key, or an empty string if the key is not present public String get(String key) { for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1]; return ""; } - // Method to get the value associated with a key, or a fallback value if the key is not present public String get(String key, String fallback) { for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1]; return fallback; } - // Method to get a boolean value associated with a key, or a fallback value if the key is not present public boolean getBoolean(String key, boolean fallback) { String value = get(key); if (value.isEmpty()) return fallback; return value.equals("1") || value.equals("t") || value.equalsIgnoreCase("true"); } - // Method to get a float value associated with a key, or a fallback value if the key is not present public float getFloat(String key, float fallback) { String value = get(key); try { if (!value.isEmpty()) return Float.parseFloat(value); } catch (NumberFormatException e) { - // Ignore exception and return fallback } return fallback; } - // Method to set a value for a key in the key-value data public void put(String key, Object value) { int[] range = indexOfKey(key); if (range != null) { @@ -88,21 +79,15 @@ public class KeyValueSet implements Iterable { } } - // Method to set a float value for a key in the key-value data public void put(String key, float value) { put(key, String.valueOf(value)); } - // Iterator implementation for key-value pairs in the format [key, value] @NonNull @Override public Iterator iterator() { final int[] start = {0}; final int[] end = {data.indexOf(",")}; - // A single-item config (no comma) gives indexOf == -1, making hasNext() (start < end) - // immediately false so the sole pair was never iterated (get() then returned the - // fallback). Guard it exactly as next() already does. An empty string still yields - // end == 0 → no iteration, which is correct. if (end[0] == -1) end[0] = data.length(); final String[] item = new String[2]; return new Iterator() { diff --git a/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt b/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt index 116eac8ca..bea6ce30e 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt @@ -5,12 +5,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.File import kotlin.math.roundToInt -/** - * The readings Steam's performance overlay shows in a DroidDeck session (Valve's mangohud 20260902.1, - * tools/mangoapp), taken from the files the session hands it and computed the same way, so the two - * overlays agree. Sources and formulas follow that build: cpu.cpp calculateCPUData, deckard_temp.cpp, - * msm.cpp getQcomGpuInfo, memory.cpp update_meminfo and battery.cpp getPercent / getPower. - */ internal class MangoReadings(private val sessionDir: File, private val rootDir: File) { class Cpu(val percent: Int, val perCore: IntArray) class Memory(val usedBytes: Long, val totalBytes: Long, val swapUsedBytes: Long, val swapTotalBytes: Long) @@ -19,7 +13,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir: private val cpuTimes = HashMap() private val cpuPercents = HashMap() - /** Total and per-core load from the session's /proc/stat (DroidDeck's stand-in when the app can't read it). */ @JvmOverloads fun cpu(stat: File = statFile()): Cpu? { val lines = runCatching { stat.readLines() }.getOrNull() ?: return null @@ -40,7 +33,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir: File(sessionDir, "proc-stat").takeIf { !File("/proc/stat").canRead() && it.isFile } ?: File("/proc/stat") private fun cpuPercent(key: String, v: LongArray): Int { - // user nice system idle iowait irq softirq steal guest guest_nice; guest time is already in user. val user = v[0] - v[8] val nice = v[1] - v[9] val systemAll = v[2] + v[5] + v[6] @@ -57,7 +49,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir: return busy.coerceIn(0f, 100f).toInt().also { cpuPercents[key] = it } } - /** Average of the zones the session types cpuN-thermal, truncated. */ fun cpuTempC(): Int? { externalCpuTemp()?.let { return it } var sum = 0f @@ -95,7 +86,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir: return (milli / 1000.0).toInt() } - /** MemTotal - MemAvailable and SwapTotal - SwapFree from /proc/meminfo. */ fun memory(): Memory? { val kb = HashMap() runCatching { @@ -115,7 +105,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir: /** The KGSL total DroidDeck shares with the session (GpuMemComponent). */ fun vramBytes(): Long? = fresh(File(rootDir, VRAM_FILE), VRAM_FRESH_MS)?.let(::readLong)?.takeIf { it > 0 } - /** BAT* under the session's power_supply (BatteryComponent): no watts while charging, full or unknown. */ fun battery(): Battery? { val dirs = File(sessionDir, "sys/power_supply").listFiles { f -> f.name.contains("BAT") }?.sortedBy { it.name } if (dirs.isNullOrEmpty()) return null @@ -160,7 +149,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir: private fun readLong(file: File): Long? = runCatching { file.readText().trim().toLong() }.getOrNull() private fun readFloat(file: File): Float? = runCatching { file.readLines().first().trim().toFloat() }.getOrNull() - /** std::stod on the first line: the leading number, "6 %" -> 6. */ fun leadingNumber(file: File): Double? = runCatching { Regex("^\\s*-?\\d+(\\.\\d+)?").find(file.readLines().first())?.value?.trim()?.toDouble() }.getOrNull() diff --git a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt index 65f9e5b83..54ae373ae 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt @@ -5,7 +5,8 @@ import android.os.Handler import android.os.Looper class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap: (Boolean) -> Unit, - private val doubleTapMs: () -> Long = { 500 }, private val onCancel: () -> Unit = {}) { + private val doubleTapMs: () -> Long = { 500 }, private val onCancel: () -> Unit = {}, + private val holds: () -> Boolean = { true }) { private class Press(val hold: Runnable, var held: Boolean = false, var used: Boolean = false) private val handler = Handler(Looper.getMainLooper()) private val presses = mutableMapOf() @@ -20,6 +21,11 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap: fun onChordInput() { if (presses.isEmpty()) return + if (!holds()) { + presses.values.forEach { handler.removeCallbacks(it.hold); it.used = true } + cancelTap() + return + } val alreadyHeld = presses.values.any { it.held } presses.values.forEach { handler.removeCallbacks(it.hold) @@ -43,6 +49,7 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap: handler.removeCallbacks(singleTap) val hold = Runnable { val press = presses[device] ?: return@Runnable + if (!holds()) { press.used = true; cancelTap(); return@Runnable } val alreadyHeld = presses.values.any { it.held } press.held = true presses.values.forEach { it.used = true } diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index 46ce99798..8cbe135e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -612,7 +612,6 @@ public class FakeInputWriter { for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i); nativeStoreFence(); if (seq != ring.getLong(TRITON_OUTPUT_SEQ_OFFSET)) return null; - // Acknowledge the exact report sequence taken so a concurrent timeout overwrite cannot lose its length. ring.putInt(TRITON_OUTPUT_ACK_OFFSET, (int) seq); nativeStoreFence(); return output; diff --git a/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt b/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt index b8be5bf54..55588d6fd 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt @@ -2,7 +2,6 @@ package com.droiddeck.launcher.input import android.content.Context -/** Global defaults with optional overrides for the currently focused game. */ object GesturePrefs { private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE) diff --git a/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt b/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt index 68d5722d0..4d69f68c5 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt @@ -2,7 +2,6 @@ package com.droiddeck.launcher.input import android.view.MotionEvent -/** Keeps split Android touch targets from restarting one shared gesture recognizer. */ internal class GestureTouchStreamGate { enum class Source { SURFACE, SCREEN_KEYS, ON_SCREEN_CONTROLS } diff --git a/app/src/main/java/com/droiddeck/launcher/input/GuestKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/GuestKeys.kt new file mode 100644 index 000000000..fbc3739cd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/GuestKeys.kt @@ -0,0 +1,31 @@ +package com.droiddeck.launcher.input + +import android.view.KeyEvent + +object GuestKeys { + /** Sends [event] as evdev presses, with Shift around characters typed by a keyboard that has no Shift key; false when it has no evdev code. */ + fun send(event: KeyEvent, sendKey: (code: Int, down: Boolean) -> Unit): Boolean { + val down = event.action == KeyEvent.ACTION_DOWN + if (!down && event.action != KeyEvent.ACTION_UP) return false + var evdev = EvdevKeys.fromKeyCode(event.keyCode) + val ch = event.unicodeChar + val plain = if (ch > 0) EvdevKeys.unshiftedChar(ch) else 0 + if (evdev <= 0 && ch > 0) { + val code = EvdevKeys.fromKeyCode(EvdevKeys.keycodeForChar(if (plain != 0) plain else ch)) + if (code > 0) evdev = code + } + val shiftEvdev = if (evdev > 0 && plain != 0 && event.deviceId <= 0) KEY_LEFTSHIFT else 0 + if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode + if (evdev <= 0) return false + if (down) { + if (shiftEvdev != 0) sendKey(shiftEvdev, true) + sendKey(evdev, true) + } else { + sendKey(evdev, false) + if (shiftEvdev != 0) sendKey(shiftEvdev, false) + } + return true + } + + private const val KEY_LEFTSHIFT = 42 +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/HomeButtonService.kt b/app/src/main/java/com/droiddeck/launcher/input/HomeButtonService.kt index 21ca4717d..61dfe32c4 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/HomeButtonService.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/HomeButtonService.kt @@ -37,7 +37,6 @@ object HomeButtonShortcuts { } fun onKeyEvent(context: Context, event: KeyEvent): Boolean { - // AYN maps its system-assistant key (gpio-keys KEY_F24) to HOME too; only the pad's Home is Guide. if (!HomeGuideInput.isHome(event.keyCode) || !PadBridge.isControllerKeyEvent(event)) return false return input.onKeyEvent(event, enabled() && HomeApp.isDefault(context)) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt b/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt index fd289f734..e4d712044 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt @@ -46,7 +46,6 @@ class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCa } companion object { - // The Retroid Nova pad falls back to Generic.kl, which maps its Home scan code to MOVE_HOME. fun isHome(keyCode: Int) = keyCode == KeyEvent.KEYCODE_HOME || keyCode == KeyEvent.KEYCODE_MOVE_HOME private const val DUO_QUICK_MENU_SCAN_CODE = 194 diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index b7fced710..8f546e8b0 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -672,7 +672,6 @@ class OnScreenControls( private fun closestSnap(position: Float, targets: List): Float? = targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) } - /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */ fun takesTouchAt(x: Float, y: Float): Boolean = editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) || (!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null) diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index 7dd234daf..edb95fbe6 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -250,11 +250,6 @@ public final class PadBridge { || device.supportsSource(InputDevice.SOURCE_JOYSTICK)); } - public static boolean isControllerKey(KeyEvent event) { - return isFromController(event.getDevice()) - && isPadKey(event.getKeyCode(), event.getDevice().getKeyboardType()); - } - public static boolean isPadKey(int keyCode, int keyboardType) { if ((keyCode >= KeyEvent.KEYCODE_BUTTON_A && keyCode <= KeyEvent.KEYCODE_BUTTON_MODE) || (keyCode >= KeyEvent.KEYCODE_BUTTON_1 && keyCode <= KeyEvent.KEYCODE_BUTTON_16)) return true; @@ -616,7 +611,6 @@ public final class PadBridge { private void writePad(PadState report) { if (backQamAxesBlocked && !hasAxes(state)) backQamAxesBlocked = false; long at = android.os.SystemClock.uptimeMillis(); - // Steam must see the modifier before a partner; preserve quick press/release pairs in order. if (backQamPressed || homeGuidePressed || !backQamWrites.isEmpty()) at += backQamDelayMs; writeBackQamReport(report, at); } diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt index 2967a06f6..422375432 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt @@ -36,12 +36,6 @@ fun screenKeyDirections(shape: Int): List = when (shape) { else -> emptyList() } -val SCREEN_KEY_SHAPES = - listOf( - SCREEN_KEY_PILL, SCREEN_KEY_ROUND, SCREEN_KEY_CIRCLE, SCREEN_KEY_RECT, - SCREEN_KEY_TRIGGER, SCREEN_KEY_DPAD, SCREEN_KEY_STICK, - ) - data class ScreenKey( val codes: List, val x: Float, @@ -55,9 +49,4 @@ data class ScreenKey( val toggle: Boolean = false, val labels: Boolean = true, ) { - fun shownLabel(context: Context): String = when { - text.isNotEmpty() -> text - glyph.isNotEmpty() -> glyph - else -> screenKeyLabel(context, codes) - } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt index df129888c..be1f658f3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt @@ -609,7 +609,6 @@ class ScreenKeys(context: Context, private val sendKey: (Int, Boolean) -> Unit) else -> entry.halfH } - /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */ fun takesTouchAt(x: Float, y: Float): Boolean = editing || entryAt(x, y) != null private fun entryAt(x: Float, y: Float): Entry? = entries.lastOrNull { entry -> diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt index f030565ff..0f18132ef 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt @@ -10,14 +10,15 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) { KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad), TERMINAL("terminal", R.string.second_mode_terminal), DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls), - DASHBOARD("dashboard", R.string.second_mode_dashboard); + DASHBOARD("dashboard", R.string.second_mode_dashboard), + DESKTOP("desktop", R.string.second_mode_desktop); companion object { - fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false): SecondScreenMode { + fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, desktop: Boolean = false): SecondScreenMode { entries.firstOrNull { - it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) + it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) && + (it != DESKTOP || desktop) }?.let { return it } - // No pick yet: the dashboard. A pick that can't open right now keeps the old fallback. val picked = entries.any { it != NONE && it.id == remembered } return when { dashboard && !picked -> DASHBOARD diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt index 68eed5ede..672cf9b47 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt @@ -112,7 +112,6 @@ class SteamTouchControls( private var selected = -1 private var dragOffset = 0f to 0f private var pinchStart = 0f - private var pinchScale = 1f private var saving = false private val pendingBindings = HashMap() private var pendingColor: FloatArray? = null @@ -244,7 +243,6 @@ class SteamTouchControls( private fun hit(list: List, x: Float, y: Float): Element? = list.filter { reach(it, x, y) <= 1.15f }.minByOrNull { reach(it, x, y) } - /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */ fun takesTouchAt(x: Float, y: Float): Boolean = editing || menuOpen || hit(live(), x, y) != null private fun live(): List = @@ -350,17 +348,6 @@ class SteamTouchControls( canvas.restore() } - private fun drawSteamMark(canvas: Canvas, x: Float, y: Float, r: Float, color: Int) { - val p = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color; style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND } - p.strokeWidth = r * 0.16f - canvas.drawCircle(x + r * 0.28f, y - r * 0.18f, r * 0.27f, p) - canvas.drawCircle(x + r * 0.28f, y - r * 0.18f, r * 0.1f, Paint(p).apply { style = Paint.Style.FILL }) - canvas.drawCircle(x - r * 0.3f, y + r * 0.3f, r * 0.17f, p) - p.strokeWidth = r * 0.2f - canvas.drawLine(x + r * 0.1f, y - r * 0.02f, x - r * 0.2f, y + r * 0.22f, p) - canvas.drawLine(x - r * 0.45f, y + r * 0.28f, x - r * 0.95f, y + r * 0.1f, p) - } - private fun drawFace(canvas: Canvas, type: Int, binding: SteamTouchBindings.Binding?, x: Float, y: Float, r: Float, alpha: Int, pressed: Boolean) { val square = isSquare(type) @@ -855,19 +842,6 @@ class SteamTouchControls( invalidate() } - private fun controlName(type: Int) = when (type) { - SteamTouchConfig.DPAD -> context.getString(R.string.touch_dpad) - SteamTouchConfig.JOYSTICK_LEFT -> context.getString(R.string.touch_left_stick) - SteamTouchConfig.JOYSTICK_RIGHT -> context.getString(R.string.touch_right_stick) - SteamTouchConfig.TRACKPAD_LEFT -> context.getString(R.string.deck_left_trackpad) - SteamTouchConfig.TRACKPAD_RIGHT -> context.getString(R.string.deck_right_trackpad) - SteamTouchConfig.TRACKPAD_CENTER -> context.getString(R.string.deck_surface_trackpad) - SteamTouchConfig.STEAM -> "Steam" - SteamTouchConfig.THUMB -> context.getString(R.string.touch_menu) - SteamTouchConfig.KEYBOARD -> context.getString(R.string.touch_keyboard) - else -> label(type) - } - private fun moreMenu() { val host = parent as? android.view.ViewGroup ?: return val density = resources.displayMetrics.density diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt index 2d63358d3..560bff4f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt @@ -74,8 +74,6 @@ class SteamTouchDevice private constructor(private val buffer: ByteBuffer) { Log.i(TAG, "steam touch: " + if (value) "plugged in" else "unplugged") } - fun setBattery(percent: Int) = buffer.putInt(OFF_BATTERY, percent.coerceIn(0, 100)) - fun state(): State { repeat(8) { val seq = buffer.getLong(OFF_STATE_SEQ) diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamVolumeKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamVolumeKeys.kt index 3e0ca925a..062bfbfdc 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamVolumeKeys.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamVolumeKeys.kt @@ -3,7 +3,6 @@ package com.droiddeck.launcher.input import android.media.AudioManager import android.view.KeyEvent -/** Use Steam's PulseAudio bridge for feedback instead of requesting Android's volume UI. */ internal object SteamVolumeKeys { fun dispatch(event: KeyEvent, enabled: Boolean, adjust: (Int) -> Unit): Boolean { val direction = when (event.keyCode) { diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java index 140b4ace4..819226764 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java @@ -28,7 +28,6 @@ public final class TouchscreenGestures { void key(int code, boolean down); void wheel(int steps); void showKeyboard(); - /** Establish the pointer origin before a relative middle-mouse pan begins. */ default void prepareRelativePan() {} default void gesture(Gesture gesture) {} } @@ -97,12 +96,10 @@ public final class TouchscreenGestures { final int code; MouseButton(int code) { this.code = code; } } - // Long-press detection private Runnable longPressRunnable; private boolean longPressTriggered; private boolean longPressActionHeld, twoFingerHoldActionHeld, threeFingerHoldActionHeld; - // Double-tap detection private long lastTapTime; private float lastTapX; private float lastTapY; @@ -110,76 +107,54 @@ public final class TouchscreenGestures { private float doubleTapAnchorX, doubleTapAnchorY; private long singleFingerDownTime; - // Drag tracking (box selection) private boolean isDragging; private boolean dragButtonPressed; - // Two-finger tracking private boolean twoFingerDragging; private float twoFingerLastX0, twoFingerLastY0; private float twoFingerLastX1, twoFingerLastY1; private boolean twoFingerMiddleButtonDown; - // Pinch tracking private float pinchLastDistance; private float pinchZoomRemainder; private float keyPanRemainderX, keyPanRemainderY; private static final float PINCH_STEP_PX = 10f; - // Two-finger tap detection private boolean twoFingerTapPossible; private boolean twoFingerTapFired; - // Track which WASD/arrow keys are currently held private boolean panKeyUp, panKeyDown, panKeyLeft, panKeyRight; - // True when finger has moved beyond tap tolerance (prevents spurious tap on lift) private boolean movedBeyondTapThreshold; - // True after a multi-finger gesture has been active — prevents the remaining - // finger from starting a spurious single-finger drag on lift sequence. private boolean multiFingerGestureUsed; - // Remember which keycodes were actually pressed so releasePanKeys releases only those private Integer panLeftKey, panRightKey, panUpKey, panDownKey; - // Long-press movement tolerance (pixels of raw finger movement before cancelling long-press) private static final float LONG_PRESS_MOVE_TOLERANCE = 50f; - // Raw touch-down coordinates for measuring finger travel private float touchDownRawX, touchDownRawY; - // Last raw coordinates for single-finger drag delta private float singleFingerLastRawX, singleFingerLastRawY; - // Two-finger gesture mutual exclusion (only pan OR pinch, not both) private static final int TWO_FINGER_GESTURE_NONE = 0; private static final int TWO_FINGER_GESTURE_PAN = 1; private static final int TWO_FINGER_GESTURE_ZOOM = 2; private int twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; - // Accumulated deltas for deciding which two-finger gesture to lock into private float accumulatedPinchDelta, accumulatedPanDelta; - // Settle window: ignore movement for first N ms after second finger lands (#1) - // Also serves as a grace period for a third finger to arrive before 2F locks in private static final long TWO_FINGER_SETTLE_MS = 120; private static final long THREE_FINGER_SETTLE_MS = 50; private long twoFingerDownTime; private long threeFingerDownTime; - // Velocity gating for two-finger hold vs drag (#2) private long twoFingerLastMoveTime; - private static final float MIN_DRAG_VELOCITY = 0.3f; // px/ms to commit to drag + private static final float MIN_DRAG_VELOCITY = 0.3f; - // Pinch vs drag ratio: pinch delta must exceed pan by this factor to classify as pinch (#4) private static final float PINCH_RATIO_THRESHOLD = 1.5f; - // Cancel-and-reclassify: allow reclassification within this window after lock-in (#5) private static final long RECLASSIFY_WINDOW_MS = 100; private long twoFingerLockTime; private float reclassifyPinchDelta, reclassifyPanDelta; - // Gesture pointer ownership tracking (prevents d-pad/button pointer conflicts) private int gestureFingerCount = 0; private final ArrayList gestureOwnedPointerIds = new ArrayList<>(); private final int[] multiFingerStartIds = new int[3]; private final float[] multiFingerStartX = new float[3], multiFingerStartY = new float[3]; private int multiFingerStartCount; - // Two-finger hold detection private Runnable twoFingerHoldRunnable; private boolean twoFingerHoldTriggered; - // Three-finger tracking private boolean threeFingerTapPossible; private boolean threeFingerTapFired; private boolean threeFingerDragging; @@ -199,9 +174,7 @@ public final class TouchscreenGestures { switch (action) { case MotionEvent.ACTION_DOWN: - // A new touch must not shorten or discard an already recognized click. handleTsCancel(true); - // First pointer overall — gesture system claims it gestureOwnedPointerIds.clear(); gestureOwnedPointerIds.add(pointerId); gestureFingerCount = 1; @@ -209,13 +182,11 @@ public final class TouchscreenGestures { break; case MotionEvent.ACTION_POINTER_DOWN: - // New pointer arrived — gesture system claims it if (!gestureOwnedPointerIds.contains(pointerId)) { gestureOwnedPointerIds.add(pointerId); gestureFingerCount++; } if (gestureFingerCount == 1) { - // First gesture finger (controls own earlier pointers) handleTsDown(event, actionIndex); } else if (gestureFingerCount == 2) { handleTsPointerDown(event); @@ -242,15 +213,12 @@ public final class TouchscreenGestures { gestureOwnedPointerIds.remove(Integer.valueOf(pointerId)); gestureFingerCount = Math.max(0, gestureFingerCount - 1); if (prevCount == 3 && gestureFingerCount == 2) { - // Three-finger gesture ending handleTsThreeFingerUp(event); } else if (prevCount == 2 && gestureFingerCount == 1) { - // Two-finger gesture ending (2→1) handleTsPointerUp(event); } else if (prevCount >= 4) { if (removedIndex < 3) rebaseThreeFingerMovement(event); } else if (prevCount >= 1 && gestureFingerCount == 0) { - // Last gesture finger lifted while controls still hold pointers handleTsUp(event, actionIndex); } } @@ -261,9 +229,6 @@ public final class TouchscreenGestures { int prevCount = gestureFingerCount; gestureOwnedPointerIds.remove(Integer.valueOf(pointerId)); gestureFingerCount = Math.max(0, gestureFingerCount - 1); - // Clean up multi-finger state if needed, routing by previous - // gesture mode so a 3-finger gesture isn't ended via the - // two-finger cleanup path. if (prevCount >= 3) { handleTsThreeFingerUp(event); } else if (prevCount == 2) { @@ -271,7 +236,6 @@ public final class TouchscreenGestures { } handleTsUp(event, actionIndex); } - // ACTION_UP = last pointer overall — full cleanup gestureOwnedPointerIds.clear(); gestureFingerCount = 0; break; @@ -309,15 +273,12 @@ public final class TouchscreenGestures { threeFingerHoldTriggered = false; accumulatedThreeFingerDelta = 0; - // Store raw touch coordinates for long-press movement tolerance touchDownRawX = event.getX(actionIndex); touchDownRawY = event.getY(actionIndex); singleFingerDownTime = SystemClock.uptimeMillis(); singleFingerLastRawX = touchDownRawX; singleFingerLastRawY = touchDownRawY; - // Keep a nearby re-tap undecided until UP: it can still become a drag, - // long press, or multi-finger gesture instead of a double click. if (isDoubleTap(event, actionIndex)) { secondTapCandidate = true; doubleTapAnchorX = lastTapX; @@ -327,7 +288,6 @@ public final class TouchscreenGestures { if (!secondTapCandidate && delayedPress == null && pendingTapPress == null) moveCursorTo(x, y); - // Schedule long-press if (gestureConfig.getLongPressEnabled()) { longPressRunnable = () -> { secondTapCandidate = false; @@ -342,7 +302,6 @@ public final class TouchscreenGestures { postDelayed(longPressRunnable, gestureConfig.getLongPressDelay()); } - // Tap: immediate cursor move (press is deferred to UP for click, or MOVE for drag) } private void handleTsPointerDown(MotionEvent event) { @@ -351,11 +310,6 @@ public final class TouchscreenGestures { flushPendingTapRelease(); lastTapTime = 0; flushPendingHoldClickRelease(); - // Cancel any single-finger long-press. cancelLongPressTimer only - // clears the pending runnable; if the long-press has already fired - // we must also release the held action before we transition into a - // multi-finger gesture, otherwise the long-press button/key stays - // down for the rest of the touch sequence. if (longPressTriggered) { longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction()); longPressTriggered = false; @@ -388,7 +342,6 @@ public final class TouchscreenGestures { event.getY(idx0) - event.getY(idx1)); recordMultiFingerOrigins(event, 2); } - // Schedule two-finger hold if (gestureConfig.getTwoFingerHoldEnabled()) { twoFingerHoldRunnable = () -> { twoFingerHoldTriggered = true; @@ -404,33 +357,26 @@ public final class TouchscreenGestures { } private void handleTsMove(MotionEvent event, int pointerIndex) { - // After a multi-finger gesture, ignore single-finger movement until all fingers lift if (multiFingerGestureUsed) return; int x = (int) event.getX(pointerIndex); int y = (int) event.getY(pointerIndex); - // If long press already triggered, ignore movement (right-click already sent) if (longPressTriggered) return; - // Measure raw finger travel from initial touch point float rawTravel = maxFingerTravel(event, pointerIndex, touchDownRawX, touchDownRawY); if (secondTapCandidate && (rawTravel > doubleTapSlop || maxFingerTravel(event, pointerIndex, doubleTapAnchorX, doubleTapAnchorY) >= doubleTapSlop)) { secondTapCandidate = false; } - // The threshold decides when a drag starts. An active drag must keep - // following the finger even when it returns through the touch origin. if (!isDragging && rawTravel <= LONG_PRESS_MOVE_TOLERANCE) { return; } - // Beyond tolerance — cancel long press cancelLongPressTimer(); movedBeyondTapThreshold = true; - // Detect drag start if (!isDragging && gestureConfig.getDragEnabled()) { flushPendingTapRelease(); String dragAction = gestureConfig.getDragAction(); @@ -438,14 +384,7 @@ public final class TouchscreenGestures { dragButtonPressed = true; singleFingerLastRawX = event.getX(pointerIndex); singleFingerLastRawY = event.getY(pointerIndex); - // Send the configured drag press immediately so that a short - // move followed straight by UP still produces a press+release - // pair. A zero-delta performPanAction just presses the button - // (or is a no-op for the key-pan variants, which press per - // direction on their next move frame). if (isClickDragAction(dragAction)) { - // The candidate's cursor stayed at the previous click. Start - // a drag at this touch's own origin before pressing its button. moveCursorTo((int) touchDownRawX, (int) touchDownRawY); if (useRelativeMouseDragMovement()) { pressClickDragAction(dragAction); @@ -456,7 +395,6 @@ public final class TouchscreenGestures { performPanAction(0f, 0f, dragAction); } notifyGesture(Gesture.DRAG); - // Non-click pan actions continue with real deltas on the next frame. return; } @@ -481,14 +419,11 @@ public final class TouchscreenGestures { return; } - // Move cursor moveCursorTo(x, y); } private void handleTsTwoFingerMove(MotionEvent event) { if (multiFingerGestureUsed) return; - // If a two-finger hold has already fired, ignore subsequent jitter so - // it can't lock into a pan/zoom gesture mid-hold. if (twoFingerHoldTriggered) return; if (gestureFingerCount < 2 || gestureOwnedPointerIds.size() < 2) return; @@ -496,20 +431,15 @@ public final class TouchscreenGestures { int idx1 = event.findPointerIndex(gestureOwnedPointerIds.get(1)); if (idx0 < 0 || idx1 < 0) return; - // Settling delays recognition, not movement tracking. A swipe or pinch - // must stop being a tap/hold even when it finishes inside that delay. if (multiFingerMovementExceeded(event)) { twoFingerTapPossible = false; cancelTwoFingerHoldTimer(); } - // Slow deliberate travel can cross touch slop before reaching the - // pan/zoom threshold. It must not turn into a stationary hold first. if (multiFingerMovementExceeded(event, true)) cancelTwoFingerHoldTimer(); float x0 = event.getX(idx0), y0 = event.getY(idx0); float x1 = event.getX(idx1), y1 = event.getY(idx1); - // Compute per-frame pan and pinch deltas float currDist = (float) Math.hypot(x0 - x1, y0 - y1); float framePinchDelta = Math.abs(currDist - pinchLastDistance); @@ -523,7 +453,6 @@ public final class TouchscreenGestures { long now = SystemClock.uptimeMillis(); if (now - twoFingerDownTime < TWO_FINGER_SETTLE_MS) { - // Still settling — update stored positions but don't accumulate deltas twoFingerLastX0 = x0; twoFingerLastY0 = y0; twoFingerLastX1 = x1; twoFingerLastY1 = y1; pinchLastDistance = currDist; @@ -531,14 +460,12 @@ public final class TouchscreenGestures { } long frameTime = now - twoFingerLastMoveTime; twoFingerLastMoveTime = now; - float velocity = (frameTime > 0) ? framePanDelta / frameTime : 0; // px/ms + float velocity = (frameTime > 0) ? framePanDelta / frameTime : 0; - // Accumulate deltas until we have enough movement to decide gesture type if (twoFingerGestureMode == TWO_FINGER_GESTURE_NONE) { accumulatedPinchDelta += framePinchDelta; accumulatedPanDelta += framePanDelta; if (accumulatedPinchDelta + accumulatedPanDelta > (float) gestureConfig.getGestureThreshold()) { - // Enough movement to lock into a gesture — no longer a tap or hold twoFingerTapPossible = false; cancelTwoFingerHoldTimer(); if (accumulatedPinchDelta > accumulatedPanDelta * PINCH_RATIO_THRESHOLD @@ -551,7 +478,6 @@ public final class TouchscreenGestures { twoFingerLockTime = now; notifyGesture(Gesture.TWO_FINGER_DRAG); } else if (accumulatedPanDelta >= accumulatedPinchDelta && gestureConfig.getTwoFingerDragEnabled()) { - // Fallback: pan is dominant but velocity was too low — still drag twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; twoFingerLockTime = now; notifyGesture(Gesture.TWO_FINGER_DRAG); @@ -571,7 +497,7 @@ public final class TouchscreenGestures { reclassifyPinchDelta += framePinchDelta; reclassifyPanDelta += framePanDelta; float reclassifyTotal = reclassifyPinchDelta + reclassifyPanDelta; - if (reclassifyTotal > 20f) { // enough post-lock data to reclassify + if (reclassifyTotal > 20f) { boolean shouldBeZoom = reclassifyPinchDelta > reclassifyPanDelta * PINCH_RATIO_THRESHOLD; if (shouldBeZoom && twoFingerGestureMode == TWO_FINGER_GESTURE_PAN && gestureConfig.getPinchEnabled()) { @@ -585,7 +511,7 @@ public final class TouchscreenGestures { resetSmallMovement(); notifyGesture(Gesture.TWO_FINGER_DRAG); } - twoFingerLockTime = 0; // done reclassifying + twoFingerLockTime = 0; reclassifyPinchDelta = 0; reclassifyPanDelta = 0; } @@ -595,12 +521,10 @@ public final class TouchscreenGestures { pinchZoomRemainder = accumulateSignedMovement(pinchZoomRemainder, delta); if (Math.abs(pinchZoomRemainder) >= PINCH_STEP_PX) { performZoomAction(pinchZoomRemainder > 0); - // Keep the fractional travel, without queuing a burst of old - // zoom actions after a large move or a direction reversal. pinchZoomRemainder %= PINCH_STEP_PX; } } - pinchLastDistance = currDist; // Always update baseline to avoid stale deltas + pinchLastDistance = currDist; if (twoFingerGestureMode == TWO_FINGER_GESTURE_PAN) { String twoFingerDragAction = gestureConfig.getTwoFingerDragAction(); if (isClickDragAction(twoFingerDragAction)) { @@ -627,14 +551,11 @@ public final class TouchscreenGestures { private void handleTsPointerUp(MotionEvent event) { cancelTwoFingerHoldTimer(); if (multiFingerMovementExceeded(event)) twoFingerTapPossible = false; - // Two-finger hold release if (twoFingerHoldTriggered) { twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction()); twoFingerHoldTriggered = false; } - // Two-finger tap detection else if (twoFingerTapPossible && !twoFingerDragging && gestureConfig.getTwoFingerTapEnabled()) { - // Move cursor to midpoint between the two fingers float midX = (twoFingerLastX0 + twoFingerLastX1) / 2f; float midY = (twoFingerLastY0 + twoFingerLastY1) / 2f; queueTapClick(gestureConfig.getTwoFingerTapAction(), midX, midY); @@ -652,7 +573,6 @@ public final class TouchscreenGestures { reclassifyPinchDelta = 0; reclassifyPanDelta = 0; twoFingerLockTime = 0; - // Suppress single-finger tap/drag when last finger lifts after any 2F gesture movedBeyondTapThreshold = true; multiFingerGestureUsed = true; } @@ -661,7 +581,6 @@ public final class TouchscreenGestures { cancelLongPressTimer(); if (longPressTriggered) { - // Release the long-press action longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction()); longPressTriggered = false; return; @@ -671,7 +590,6 @@ public final class TouchscreenGestures { float y = event.getY(actionIndex); if (isDragging && dragButtonPressed) { - // End drag (release any held drag buttons/keys) releaseDragInput(); dragButtonPressed = false; isDragging = false; @@ -685,8 +603,6 @@ public final class TouchscreenGestures { ? gestureConfig.getLongPressDelay() : ViewConfiguration.getLongPressTimeout(); if (SystemClock.uptimeMillis() - singleFingerDownTime < tapLimit && Math.hypot(x - doubleTapAnchorX, y - doubleTapAnchorY) < doubleTapSlop) { - // A normal left tap already supplied the first click. Other - // single-tap mappings need a complete synthesized double click. boolean firstTapClicked = gestureConfig.getTapEnabled() && TouchGestureConfig.ACTION_LEFT_CLICK.equals(gestureConfig.getTapAction()); int clicks = firstTapClicked ? 1 : 2; @@ -697,26 +613,21 @@ public final class TouchscreenGestures { } } - // Two-finger tap already sent its click on POINTER_UP — skip the tap on UP if (twoFingerTapFired) { twoFingerTapFired = false; return; } - // Three-finger tap already sent its click — skip the tap on UP if (threeFingerTapFired) { threeFingerTapFired = false; return; } - // Simple tap — only if finger stayed within tap tolerance if (gestureConfig.getTapEnabled() && !movedBeyondTapThreshold) { queueTapClick(gestureConfig.getTapAction(), x, y); notifyGesture(Gesture.TAP); } - // Record for double-tap detection (even if tap itself is disabled, - // so double-tap can still prime from single touch-ups) if (!movedBeyondTapThreshold && (gestureConfig.getTapEnabled() || gestureConfig.getDoubleTapEnabled())) { lastTapTime = SystemClock.uptimeMillis(); lastTapX = x; @@ -776,13 +687,10 @@ public final class TouchscreenGestures { cancelLongPressTimer(); cancelDrag(); cancelTwoFingerHoldTimer(); - // If a two-finger hold already injected its action, release it before - // transitioning so the held button/key doesn't stay down. if (twoFingerHoldTriggered) { twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction()); twoFingerHoldTriggered = false; } - // Clean up two-finger state releaseDragInput(); resetSmallMovement(); twoFingerDragging = false; @@ -797,7 +705,6 @@ public final class TouchscreenGestures { threeFingerDragOffsetX = threeFingerDragOffsetY = 0f; threeFingerDownTime = SystemClock.uptimeMillis(); - // Compute midpoint of three gesture fingers if (gestureOwnedPointerIds.size() >= 3) { float sumX = 0, sumY = 0; int validCount = 0; @@ -809,13 +716,12 @@ public final class TouchscreenGestures { validCount++; } } - if (validCount < 3) return; // Can't proceed without all 3 pointers + if (validCount < 3) return; threeFingerLastMidX = sumX / 3f; threeFingerLastMidY = sumY / 3f; recordMultiFingerOrigins(event, 3); } - // Schedule three-finger hold if (gestureConfig.getThreeFingerHoldEnabled()) { threeFingerHoldRunnable = () -> { threeFingerHoldTriggered = true; @@ -831,8 +737,6 @@ public final class TouchscreenGestures { private void handleTsThreeFingerMove(MotionEvent event) { if (gestureFingerCount < 3 || gestureOwnedPointerIds.size() < 3) return; - // If a three-finger hold has already fired, ignore subsequent jitter so - // it can't lock into a pan gesture mid-hold. if (threeFingerHoldTriggered) return; if (multiFingerMovementExceeded(event)) { threeFingerTapPossible = false; @@ -877,8 +781,6 @@ public final class TouchscreenGestures { } if (threeFingerGestureMode == THREE_FINGER_GESTURE_PAN) { - // Once recognized, retain small mouse movements. Keyboard panning - // accumulates its own direction threshold in performKeyPan. String threeFingerDragAction = gestureConfig.getThreeFingerDragAction(); if (isClickDragAction(threeFingerDragAction)) { if (useRelativeMouseDragMovement()) { @@ -919,7 +821,6 @@ public final class TouchscreenGestures { multiFingerStartCount = 0; resetSmallMovement(); threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; - // Suppress single-finger tap/drag when last finger lifts after any 3F gesture movedBeyondTapThreshold = true; multiFingerGestureUsed = true; } @@ -966,7 +867,6 @@ public final class TouchscreenGestures { } x /= 3f; y /= 3f; - // Replacing a tracked finger changes the centroid without moving the cursor. if (threeFingerDragging) { threeFingerDragOffsetX += threeFingerLastMidX - x; threeFingerDragOffsetY += threeFingerLastMidY - y; @@ -1030,9 +930,6 @@ public final class TouchscreenGestures { private void startPendingTapClick() { if (delayedPress != null || pendingTapPress != null || pendingTapClicks.isEmpty()) return; TapClick click = pendingTapClicks.peekFirst(); - // Games that poll button state need an observable UP interval too. - // Reuse the first tap's location: normal finger drift must not turn - // this into two clicks on different targets or a tiny drag. long delay = Math.max(0, CLICK_DELAYED_TIME - (SystemClock.uptimeMillis() - lastInputReleaseTime)); pendingTapPress = () -> { pendingTapPress = null; @@ -1224,9 +1121,6 @@ public final class TouchscreenGestures { pressButton(MouseButton.BUTTON_MIDDLE); twoFingerMiddleButtonDown = true; } - // Pan from the client's current pointer. Games can recenter the mouse - // when the middle button goes down; replaying our old absolute touch - // anchor after that warp causes a large, unintended camera movement. moveCursorByRelativeDelta(dx, dy); } @@ -1287,10 +1181,6 @@ public final class TouchscreenGestures { } private void performKeyPan(float dx, float dy, Integer leftKey, Integer rightKey, Integer upKey, Integer downKey) { - // When fingers are actively moving, update key states to match the - // swipe direction. When fingers stop moving but stay on screen, - // keep the current keys held — movement continues until finger-up - // calls releasePanKeys(). keyPanRemainderX = accumulateSignedMovement(keyPanRemainderX, dx); keyPanRemainderY = accumulateSignedMovement(keyPanRemainderY, dy); @@ -1300,9 +1190,6 @@ public final class TouchscreenGestures { dy = keyPanRemainderY; keyPanRemainderX = keyPanRemainderY = 0f; - // --- Significant movement detected: update each axis --- - - // Horizontal: determine desired state from this frame's delta boolean wantLeft = dx < -3f; boolean wantRight = dx > 3f; @@ -1311,7 +1198,6 @@ public final class TouchscreenGestures { if (wantLeft && !panKeyLeft) { pressKey(leftKey); panKeyLeft = true; panLeftKey = leftKey; } if (wantRight && !panKeyRight) { pressKey(rightKey); panKeyRight = true; panRightKey = rightKey; } - // Vertical: determine desired state from this frame's delta boolean wantUp = dy < -3f; boolean wantDown = dy > 3f; diff --git a/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt b/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt index 343d18bdd..0734710a3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.io.File -/** What SteamVR sees for the game pad. The driver reads the file once, when SteamVR starts. */ object VrControllers { enum class Emulate(val key: String) { OFF("off"), INDEX("index"), TOUCH("touch"), VIVE("vive") } @@ -46,7 +45,6 @@ object VrControllers { fun encode(settings: Settings): String = "emulate=${settings.emulate.key}\nsticks=${if (settings.sticksMoveHands) "hands" else "game"}\n" - /** Writes into the session's runtime dir, which the guest sees as /run/droiddeck. */ fun publish(context: Context) { runCatching { val dir = File(context.filesDir, ".wayland-rt").apply { mkdirs() } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index 5c5687c96..c45c74821 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -23,7 +23,6 @@ object DeckyManager { private const val PATH = "root/homebrew/services/PluginLoader" private const val VERSION = "root/homebrew/services/.droiddeck-decky-version" private const val SETTINGS = "root/homebrew/settings/loader.json" - // droiddeck-session reads the marker from the root of the client it runs. private val CEF_REMOTE_DEBUG_MARKERS = listOf( "root/.local/share/Steam/.cef-enable-remote-debugging", "root/.droiddeck-x64/.local/share/Steam/.cef-enable-remote-debugging", @@ -58,7 +57,6 @@ object DeckyManager { check(staged.renameTo(file)) { "cannot write ${file.path}" } } - // Mirrors Decky's updater: Stable takes the newest non-prerelease tag, Prerelease the newest tag of either kind. fun latest(channels: ReleaseChannels, branch: Int): Release? = if (branch == BRANCH_STABLE) channels.stable.firstOrNull() else channels.all.firstOrNull() diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index 4a4857634..2b976796f 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -51,7 +51,6 @@ object DesktopCatalog { private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX") - /** Packages whose extraction over the runtime started and never finished. */ fun interrupted(context: Context): List = interrupted(LinuxRuntime.rootDir(context)) fun interrupted(root: File): List = @@ -63,7 +62,6 @@ object DesktopCatalog { private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download") - /** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */ fun pruneDownloads(context: Context) = synchronized(installLock) { val keep = interrupted(context).map { downloadFile(context, it).name }.toSet() context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") } @@ -102,7 +100,6 @@ object DesktopCatalog { o.optString("icon", "applications-games"), o.optString("category", "Game"), ) - /** A finished download is kept until its extraction completes, so resuming does not fetch it again. */ private fun downloaded(file: File, entry: Entry): Boolean = file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() && (entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt index cf0d3d7f6..334bce3df 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt @@ -189,7 +189,6 @@ class DeviceControlsComponent(context: Context, root: File, private val activity companion object { private const val TAG = "DeviceControls" - // AudioService sends these on every stream step; the Settings observer and 250 ms poll lag a held volume key. private const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION" private const val STREAM_MUTE_CHANGED_ACTION = "android.media.STREAM_MUTE_CHANGED_ACTION" private const val PERMISSION_CHANNEL = "brightness_permission" diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index 7db52a9e2..8e5769c31 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -39,7 +39,6 @@ object GuestCommand { if (linkDir != null) cmd.add(1, "--link2symlink") val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds) binds.forEach { cmd.add("-b"); cmd.add(it) } - // Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path. val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null cmd += listOf( "/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8", @@ -48,8 +47,6 @@ object GuestCommand { "DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}", "XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share", ) - // The Steam client installer and the package tools inside the guest fetch over plain curl, - // so the app proxy has to reach them through the environment proot starts them with. DownloadSource.proxyHostPort(context).takeIf { it.isNotEmpty() }?.let { hostPort -> cmd += listOf( "http_proxy=http://$hostPort", "https_proxy=http://$hostPort", @@ -77,7 +74,6 @@ object GuestCommand { try { while (process.isAlive && !flag.get()) Thread.sleep(200) if (flag.get()) { - // PRoot ignores SIGTERM; SIGQUIT makes it kill its tracees and exit cleanly. if (pid > 0) { runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGQUIT) } } else { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 4448c3bc8..1f39eeb23 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -50,7 +50,6 @@ public final class LinuxRuntime { } public static File liveDir(Context context) { - // Android may evict cache files even while guest processes are using them. File dir = new File(context.getNoBackupFilesDir(), "guest-runtime"); dir.mkdirs(); return dir; @@ -63,7 +62,6 @@ public final class LinuxRuntime { } private static final String HOST_DIR = "opt/android-host"; - /** The downloaded add-on pack. The APK does not ship this binary. */ public static final String PROOT_PACK = "add-on pack"; public static final String PROOT_PACKAGED = "packaged"; /** Termux's unpatched proot inside the rootfs. Developer switch only. */ @@ -94,9 +92,8 @@ public final class LinuxRuntime { return packagedProot(context).isFile() && packagedLoader(context).isFile(); } - /** Patched proot is the add-on pack. The rootfs copy is not that binary. */ public static boolean patchedProot(Context context) { - return AddonPack.PROOT.installed(context); + return packagedProotReady(context) || AddonPack.PROOT.installed(context); } public static File prootBinary(Context context) { @@ -113,7 +110,6 @@ public final class LinuxRuntime { return AddonPack.PROOT.file(context, "libproot-loader.so"); } - /** Where {@link #prootBinary} came from: {@link #PROOT_PACK}, {@link #PROOT_ROOTFS} or {@link #PROOT_MISSING}. */ public static String prootSource(Context context) { File bin = prootBinary(context); if (!bin.isFile()) return PROOT_MISSING; @@ -133,7 +129,6 @@ public final class LinuxRuntime { return hasRootfs(context) && (packagedProotReady(context) || patchedProot(context) || rootfsProotReady(context)); } - /** The rootfs alone: enough to read the game library, while the proot pack can still be downloading. */ public static boolean hasRootfs(Context context) { if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false; File root = rootDir(context); @@ -442,9 +437,6 @@ public final class LinuxRuntime { return thermalZone("ddr"); } - // MangoHud reads the GPU and DDR temperatures from these zone numbers (bindAdrenoStats). On phones - // where they are CPU zones, their type follows the temperature put there, so they stay out of the - // CPU average. private static java.util.Map replacedZoneTypes() { java.util.Map replaced = new java.util.HashMap<>(); if (gpuTempSource() != null) { @@ -455,7 +447,6 @@ public final class LinuxRuntime { return replaced; } - /** The zones the session's MangoHud averages for the CPU temperature (all typed cpuN-thermal there). */ public static List cpuTempZones() { java.util.Map replaced = replacedZoneTypes(); List zones = new ArrayList<>(); diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index ee3349405..50fb07355 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -244,10 +244,6 @@ 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]", ""); @@ -255,7 +251,6 @@ public final class LinuxRuntimeInstaller { 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; @@ -264,8 +259,6 @@ public final class LinuxRuntimeInstaller { private static boolean installOnce(Context context, Release release, ProgressListener listener) { 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()); @@ -526,8 +519,6 @@ public final class LinuxRuntimeInstaller { } } - // Packages extract over the live runtime and their archives list a link before the file it - // names, so a kill mid-way must leave neither a truncated file nor a link to a missing one. public static boolean extract(Context context, File archive, File destination, ProgressListener listener) { return extract(context, archive, destination, destination, listener); } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt b/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt index 974b6fe16..e9a9f4a72 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt @@ -12,10 +12,6 @@ object ProotFastPath { const val LIBRARY = "/usr/local/lib/libblfastpath.so" const val MAX_BINDS = 256 - /** - * The fast path's trampoline is let through only by the proot built with patches/0014. - * The rootfs copy (Termux's stock proot) is not that binary, so the library stays out. - */ fun allows(preference: Boolean, noSeccomp: Boolean, source: String): Boolean = preference && !noSeccomp && source == LinuxRuntime.PROOT_PACK diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java index 3b2a39ee8..fbbc5ddae 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java @@ -74,11 +74,6 @@ public final class RuntimeFileTree { throw new IOException("Cannot make directory removable: " + dir); } - /** - * Paths in a runtime tree that belong to the user: the home folder, imported AppImages and script apps, and catalog - * AppImages with their menu entries and markers. Tar-kind catalog packages (the desktop) unpack over /usr and cannot - * be carried. - */ static java.util.List userPaths(File tree) throws IOException { java.util.List paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps", "opt/droiddeck/wincomponents")); for (String n : children(new File(tree, "usr/local/share/applications"))) { @@ -168,13 +163,6 @@ public final class RuntimeFileTree { return false; } - /** - * Moves each user path that exists in {@code from} into {@code to}, replacing what {@code to} ships there. With - * {@code onlyMissing}, a path already present in {@code to} is left alone. On failure the paths already moved go back - * before the exception is thrown. Rollback failures are suppressed on that exception; callers must retain the source - * of any incomplete rollback. - * Returns the moved paths. - */ static java.util.List carryUserData(File from, File to, boolean onlyMissing) throws IOException { return carryUserData(from, to, onlyMissing, new java.util.ArrayList<>(userPaths(from, to))); } @@ -200,7 +188,6 @@ public final class RuntimeFileTree { return moved; } - /** Puts the given paths back from {@code from} into {@code to}; failures are collected, not stopped on. */ static void carryBack(File from, File to, java.util.List paths) throws IOException { IOException failed = null; for (int i = paths.size() - 1; i >= 0; i--) { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt index f334e6cc5..94d7f443f 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt @@ -10,7 +10,6 @@ object RuntimeHealth { fun brokenLibraryLinks(context: Context): List = brokenLibraryLinks(LinuxRuntime.rootDir(context)) - /** A complete runtime has no library link in usr/lib whose target is missing. */ fun brokenLibraryLinks(root: File): List = listOf("usr/lib", "usr/lib/aarch64-linux-gnu").flatMap { dir -> val lib = File(root, dir) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt index aa53092fe..a641cf1f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -117,7 +117,6 @@ class RuntimeInstallService : Service() { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) } - /** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */ fun holdForPackage(context: Context, title: String): () -> Unit { packageTitle = title packageStage = title diff --git a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt index 517fa6c74..ef64bfde0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt @@ -56,7 +56,6 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart val timeToEmptyS = if (discharging && chargeNowUah > 0 && abs(currentAvgUa) > 0) chargeNowUah * 3600L / abs(currentAvgUa) else 0L val charging = status == BatteryManager.BATTERY_STATUS_CHARGING val timeToFullS = if (charging && chargeFullUah > chargeNowUah && abs(currentAvgUa) > 0) (chargeFullUah - chargeNowUah) * 3600L / abs(currentAvgUa) else 0L - // Android can keep reporting charging at 100%; Linux reports Full there, and Steam shows 99% until it does. val statusText = when (status) { BatteryManager.BATTERY_STATUS_CHARGING -> if (pct >= 100) "Full" else "Charging" BatteryManager.BATTERY_STATUS_DISCHARGING -> "Discharging" @@ -127,9 +126,6 @@ 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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt index 953196900..a525bed37 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt @@ -24,7 +24,6 @@ object BootMovie { 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) @@ -39,18 +38,14 @@ object BootMovie { private fun dir(context: Context) = File(context.filesDir, "boot-movie") private fun customFile(context: Context) = File(dir(context), "custom.webm") - /** A movie imported before movies were kept in Steam's own folder. */ internal fun legacyCustomFile(context: Context) = customFile(context) - /** Unpacks one of DroidDeck's own movies on demand; null for anything else. */ internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) } - /** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */ fun select(context: Context, id: String) { SessionPrefs.setBootMovie(context, id) SessionPrefs.setBootMovieApplyNext(context, true) BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}") - // Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later. SessionFiles.startupMovieLocalPath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) } } @@ -67,7 +62,6 @@ object BootMovie { } } - /** The file the current choice plays from, before any volume or length change. */ fun currentFile(context: Context): File? = sourceOf(context, choice(context)) private fun sourceOf(context: Context, choice: String): File? = when { @@ -84,7 +78,6 @@ object BootMovie { return variant(context, source) } - /** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */ private fun variant(context: Context, source: File): File? { val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0 val seconds = SessionPrefs.bootMovieSeconds(context) @@ -124,21 +117,15 @@ object BootMovie { internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN } - /** - * How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and - * whether it still plays as it is when every step fails. - */ internal class Plan(val passthrough: Boolean, val steps: List, val sourceOnFailure: Boolean) internal fun plan(volume: Int, seconds: Int): Plan = when { volume == 100 && seconds == 0 -> Plan(true, emptyList(), true) volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false) - // Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder. volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true) else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false) } - /** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */ internal const val MAX_LONG_SIDE = 1920 internal const val MAX_SHORT_SIDE = 1080 @@ -150,22 +137,16 @@ object BootMovie { private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true - /** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */ internal fun oversizedPlan(volume: Int): Plan = Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false) private fun transcodeSilent(source: File, target: File, limitUs: Long) = BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null - /** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */ private fun prune(folder: File, keep: String?) { folder.listFiles()?.forEach { if (it.name != keep) it.delete() } } - /** - * Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before, - * that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed. - */ fun syncFromSteam(context: Context): Boolean { val named = SessionFiles.startupMovieLocalPath(BootMovieLibrary.steamConfig(context)) ?: return false val seen = SessionPrefs.bootMovieSteamSeen(context) @@ -174,7 +155,6 @@ object BootMovie { if (named == SessionFiles.BOOT_MOVIE_PATH || (named.isEmpty() && seen == null)) return false val path = named.ifEmpty { "/movies/steam_os_startup.webm" } val prefix = "/uioverrides/movies/" - // The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder. val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else { val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) } listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt index 0dd7a0fad..6fb1f56a9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt @@ -9,22 +9,14 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import org.json.JSONObject import java.io.File -/** - * The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in - * Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own - * three startup movies and the movies bought in Steam's points shop (neither can be removed). - */ object BootMovieLibrary { private const val TAG = "BootMovieLibrary" const val PREFIX = "file:" - /** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:`. */ const val STEAM_PREFIX = "steam:" - /** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */ private val lock = Any() - /** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */ internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm") const val SOURCE_DROIDDECK = "droiddeck" @@ -37,28 +29,21 @@ object BootMovieLibrary { internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies") - /** - * The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the - * x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file. - */ internal fun customMovieDirs(context: Context): List = listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path } - /** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */ internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library") private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json") internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") - /** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */ internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf") private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs") fun idOf(name: String) = PREFIX + name - /** Every Steam client folder: the usual one and the x86-64 client's. */ internal fun steamRoots(context: Context): List = listOf( steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"), ).filter { it.isDirectory } @@ -86,7 +71,6 @@ object BootMovieLibrary { private fun readMeta(context: Context): JSONObject = runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject()) - /** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */ private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) { val meta = readMeta(context) if (change(meta)) { @@ -100,7 +84,6 @@ object BootMovieLibrary { } } - /** The post a movie was installed from, if it came from SteamDeckRepo. */ fun repoId(context: Context, name: String): String? = readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() } @@ -128,7 +111,6 @@ object BootMovieLibrary { val info = meta.optJSONObject(f.name) val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension - // Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand). out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true)) } val steam = LinkedHashMap() @@ -147,14 +129,12 @@ object BootMovieLibrary { for ((id, file) in steam) { val native = file.nameWithoutExtension.lowercase() in NATIVE if ((file.name in STAGED && !native) || !seen.add(file.canonicalPath)) continue - // Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it. val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false)) } return out } - /** The four startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */ private val NATIVE = mapOf( "deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup", "steam_os_startup" to "SteamOS startup", @@ -167,13 +147,8 @@ object BootMovieLibrary { private val scans = HashMap() - /** Forgets the last search for Steam's own movies. */ internal fun forgetScan() = synchronized(scanLock) { scans.clear() } - /** - * Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the - * client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder. - */ private fun pointShopMovies(root: File): Scan = synchronized(scanLock) { val now = System.currentTimeMillis() scans[root.path]?.let { if (now - it.at < 15_000L) return it } @@ -191,10 +166,8 @@ object BootMovieLibrary { scan } - /** The item number in the name Steam gives a bought movie, `_.webm`; null for any other name. */ private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1) - /** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */ private fun boughtTitle(root: File, id: String): String? { val key = "communityitemid\\\":\\\"$id\\\"" val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)") @@ -209,7 +182,6 @@ object BootMovieLibrary { private const val OVERRIDES = "/uioverrides/movies/" - /** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */ internal fun restore(context: Context) = synchronized(lock) { migrateLegacyMeta(context) val meta = readMeta(context) @@ -222,7 +194,6 @@ object BootMovieLibrary { } } - /** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */ private fun migrateLegacyMeta(context: Context) { val legacy = File(moviesDir(context), ".droiddeck-movies.json") if (!legacy.isFile) return @@ -238,7 +209,6 @@ object BootMovieLibrary { legacy.delete() } - /** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */ private fun mirror(kept: File, copy: File): Boolean { val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged") return try { @@ -255,10 +225,8 @@ object BootMovieLibrary { fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id - /** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */ fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id) - /** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */ fun freeName(context: Context, title: String): String { val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" } val dirs = customMovieDirs(context) @@ -269,10 +237,6 @@ object BootMovieLibrary { return name } - /** - * Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its - * title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time. - */ internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? = synchronized(lock) { val name = freeName(context, title) @@ -283,7 +247,6 @@ object BootMovieLibrary { name } - /** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */ fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) { editMeta(context) { meta -> val info = JSONObject().put("title", title).put("source", source) @@ -301,12 +264,10 @@ object BootMovieLibrary { val removed = when { id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true } else -> { - // Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone. if (!id.startsWith(PREFIX)) return false val name = id.removePrefix(PREFIX) if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false val copies = customMovieDirs(context).map { File(it, name) } - // A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted. val target = copies.firstOrNull { it.exists() } val managed = File(libraryDir(context), name) if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false @@ -335,14 +296,12 @@ object BootMovieLibrary { return removed } - /** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */ internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-" private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_") private fun coverName(name: String) = "cover-" + safe(name) - /** How long a movie runs, in whole seconds; 0 when it cannot be read. */ fun durationSeconds(file: File?): Int { if (file == null || !file.isFile) return 0 val retriever = MediaMetadataRetriever() @@ -357,7 +316,6 @@ object BootMovieLibrary { } } - /** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */ private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? { var best: Bitmap? = null var bestLuma = -1 @@ -382,11 +340,9 @@ object BootMovieLibrary { private const val MIN_COVER_LUMA = 24 - /** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */ fun cover(context: Context, entry: Entry): File? { val file = fileOf(context, entry) ?: return null val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id - // Points Shop covers once kept a picture that was not always the movie's own; those files are not used. val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name)) if (given.isFile && given.length() > 0L) return given val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg" diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt index 3a672dcfd..18ff31169 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt @@ -8,10 +8,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** - * What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and - * where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config. - */ object BootMovieReport { private const val TAG = "BootMovieReport" private const val MAX_TRACE_LINES = 200 @@ -23,7 +19,6 @@ object BootMovieReport { private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log") - /** Adds a line to the short trace kept for the report; only the latest lines are kept. */ fun note(context: Context, line: String) { synchronized(lock) { runCatching { @@ -36,7 +31,6 @@ object BootMovieReport { } } - /** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */ fun write(context: Context): File? { val text = runCatching { build(context) }.getOrElse { Log.w(TAG, "could not build the report", it) diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt index 92bbbae4e..55f84c006 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt @@ -46,7 +46,6 @@ internal object BootMovieTranscoder { return Result(audioDropped = hadAudio && !muxedAudio) } - /** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */ fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean { val audio = runCatching { encodeAudio(source, gain, limitUs) } .onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false @@ -121,7 +120,6 @@ internal object BootMovieTranscoder { 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) @@ -137,7 +135,6 @@ internal object BootMovieTranscoder { return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch) } - /** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */ internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray { val frames = pcm.size / channels val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt() @@ -164,7 +161,6 @@ internal object BootMovieTranscoder { return out } - /** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */ private const val MAX_PCM_BYTES = 64 shl 20 private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? { @@ -212,7 +208,6 @@ internal object BootMovieTranscoder { } dec.releaseOutputBuffer(index, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break - // Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped. if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomDesktop.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomDesktop.kt new file mode 100644 index 000000000..923ee2999 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/BottomDesktop.kt @@ -0,0 +1,353 @@ +package com.droiddeck.launcher.session + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.util.Log +import android.view.Surface +import com.droiddeck.launcher.core.HostProcess +import com.droiddeck.launcher.runtime.DesktopCatalog +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.wayland.BottomDesktopCompositorService as Comp +import java.io.File + +/** + * KDE Plasma on the bottom screen while the session keeps the top one: its own compositor process + * ([Comp]) and a second guest started with the session's environment, like the bottom-screen terminal. + */ +object BottomDesktop { + private const val TAG = "BottomDesktop" + const val SCRIPT = "/usr/local/bin/droiddeck-bottom-desktop" + private const val TARGET_SHORT_SIDE = 720f + + enum class State { OFF, STARTING, RUNNING, ENDED, FAILED } + + private class Launch( + val guest: List, + val binds: List, + val sessionRoot: File, + val hostEnvironment: Array, + val root: File, + val sessionDir: File, + val generation: Int, + val installed: Boolean, + ) + + @Volatile private var launch: Launch? = null + private val launchLock = Any() + private val main = Handler(Looper.getMainLooper()) + private val listeners = LinkedHashSet<() -> Unit>() + + @Volatile var state = State.OFF + private set + private var onTextInput: ((Boolean) -> Unit)? = null + private var imeWatcher: android.os.FileObserver? = null + + private var appContext: Context? = null + private var service: Messenger? = null + private var binding: ServiceConnection? = null + private var pendingAttach: Message? = null + /** The desktop guest's proot: -1 when there is none, 0 while it is being started. */ + @Volatile private var guestPid = -1 + + /** The proot to freeze with the session when it pauses. */ + val suspendPid: Int get() = guestPid + private var guestGeneration = 0 + private var outputSize = 0 to 0 + private var cursorShown = false + /** The session generation whose launch the running compositor and guest belong to; -1 when none. */ + private var owner = -1 + + private val replies = Messenger(object : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + if (msg.arg1 != owner) return + when (msg.what) { + Comp.MSG_ATTACHED -> { + service?.let { send(it, Message.obtain(null, Comp.MSG_CURSOR, if (cursorShown) 1 else 0, 0)) } + if (guestPid < 0 && state == State.STARTING) startGuest() + } + Comp.MSG_FIRST_FRAME -> if (state == State.STARTING) setState(State.RUNNING) + } + } + }) + + fun prepare( + guest: List, + binds: List, + sessionRoot: File, + hostEnvironment: Array, + root: File, + sessionDir: File, + generation: Int, + stillCurrent: () -> Boolean, + ) { + val prepared = Launch(ArrayList(guest), ArrayList(binds), sessionRoot, hostEnvironment.copyOf(), root, sessionDir, generation, + DesktopCatalog.desktopInstalled(root)) + synchronized(launchLock) { if (stillCurrent()) launch = prepared } + } + + fun available(): Boolean = + launch?.installed == true && SessionState.running && SessionState.mode != SessionService.MODE_DESKTOP + + /** + * The desktop renders below the panel's resolution: about 720 pixels on the short side keeps + * Plasma's text big enough to touch and its software rendering cheap. A running desktop keeps its size. + */ + fun outputSizeFor(width: Int, height: Int): Pair { + if ((state == State.STARTING || state == State.RUNNING) && outputSize.first > 0) return outputSize + if (width <= 0 || height <= 0) return 0 to 0 + val scale = maxOf(1f, Math.round(minOf(width, height) / TARGET_SHORT_SIDE * 2f) / 2f) + return ((width / scale).toInt() and 1.inv()) to ((height / scale).toInt() and 1.inv()) + } + + fun addListener(listener: () -> Unit) { listeners.add(listener) } + + fun removeListener(listener: () -> Unit) { listeners.remove(listener) } + + /** Called on the main thread when a text field in the desktop gains or loses focus (droiddeck-ime's report). */ + fun setTextInputListener(listener: ((Boolean) -> Unit)?) { onTextInput = listener } + + private fun watchIme(dir: File) { + imeWatcher?.stopWatching() + dir.deleteRecursively() + dir.mkdirs() + @Suppress("DEPRECATION") + imeWatcher = object : android.os.FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) { + override fun onEvent(event: Int, path: String?) { + if (path != "ime.json") return + val focused = runCatching { org.json.JSONObject(File(dir, path).readText()).optBoolean("textField") } + .getOrNull() ?: return + main.post { if (imeWatcher === this) onTextInput?.invoke(focused) } + } + }.also { it.startWatching() } + } + + private fun setState(next: State) { + if (Looper.myLooper() != Looper.getMainLooper()) { main.post { setState(next) }; return } + if (state == next) return + Log.i(TAG, "state $state -> $next") + state = next + listeners.toList().forEach { it() } + } + + fun attach(context: Context, surface: Surface, width: Int, height: Int, refreshHz: Float) { + val current = launch + if (current == null || !SessionState.running) { + setState(State.FAILED) + return + } + if (owner != -1 && owner != current.generation) stop(context, owner) + appContext = context.applicationContext + owner = current.generation + outputSize = width to height + val dir = File(current.sessionDir, "bottom-desktop").apply { mkdirs() } + val msg = Message.obtain(null, Comp.MSG_ATTACH).apply { + arg1 = owner + obj = surface + replyTo = replies + data = Bundle().apply { + putString(Comp.KEY_RUNTIME_DIR, runtimeDir(context).path) + putInt(Comp.KEY_WIDTH, width) + putInt(Comp.KEY_HEIGHT, height) + putFloat(Comp.KEY_REFRESH, refreshHz) + putString(Comp.KEY_LOG, File(dir, "wayland.log").path) + } + } + if (state == State.OFF || state == State.ENDED || state == State.FAILED) setState(State.STARTING) + val target = service + if (target != null) send(target, msg) else { + pendingAttach = msg + bind(context.applicationContext) + } + } + + fun detach() { + pendingAttach = null + service?.let { send(it, Message.obtain(null, Comp.MSG_DETACH)) } + } + + fun sendTouch(action: Int, pointerId: Int, x: Int, y: Int) { + val target = service ?: return + send(target, Message.obtain(null, Comp.MSG_TOUCH, (pointerId shl 8) or (action and 0xff), + (x.coerceIn(0, 0xffff) shl 16) or y.coerceIn(0, 0xffff))) + } + + fun sendPointer(x: Int, y: Int) { + val target = service ?: return + send(target, Message.obtain(null, Comp.MSG_POINTER, x, y)) + } + + /** Button (3), wheel (4) and smooth-scroll (12) input, as `WaylandCompositor.nativeSendSceneInput` takes it. */ + fun sendScene(type: Int, a: Int, b: Int = 0) { + val target = service ?: return + send(target, Message.obtain(null, Comp.MSG_SCENE, type, a).apply { + if (b != 0) data = Bundle().apply { putInt(Comp.KEY_B, b) } + }) + } + + fun showCursor(on: Boolean) { + cursorShown = on + service?.let { send(it, Message.obtain(null, Comp.MSG_CURSOR, if (on) 1 else 0, 0)) } + } + + /** False when the next attach starts a new compositor process, which needs a surface no earlier one used. */ + val connected: Boolean get() = service != null + + fun sendKey(evdev: Int, down: Boolean) { + val target = service ?: return + send(target, Message.obtain(null, Comp.MSG_KEY, evdev, if (down) 1 else 0)) + } + + fun sendText(text: String) { + val target = service ?: return + send(target, Message.obtain(null, Comp.MSG_TEXT).apply { data = Bundle().apply { putString(Comp.KEY_TEXT, text) } }) + } + + fun stop(context: Context, generation: Int? = null) { + if (Looper.myLooper() != Looper.getMainLooper()) { main.post { stop(context, generation) }; return } + synchronized(launchLock) { + if (generation != null && launch?.generation == generation) launch = null + } + // A newer session may already have prepared its launch; only the desktop the stopped session started goes. + if (generation != null && owner != generation) return + owner = -1 + val pid = guestPid + guestPid = -1 + guestGeneration++ + if (pid > 1) SessionService.stopTerminalProcess(context, pid) + service?.let { send(it, Message.obtain(null, Comp.MSG_STOP)) } + binding?.let { runCatching { context.applicationContext.unbindService(it) } } + binding = null + service = null + pendingAttach = null + imeWatcher?.stopWatching() + imeWatcher = null + setState(State.OFF) + } + + private fun bind(context: Context) { + if (binding != null) return + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + if (binding !== this || binder == null) return + val messenger = Messenger(binder) + service = messenger + pendingAttach?.let { send(messenger, it) } + pendingAttach = null + } + + override fun onServiceDisconnected(name: ComponentName?) { + if (binding !== this) return + Log.w(TAG, "the desktop's compositor process died") + service = null + binding = null + runCatching { context.unbindService(this) } + val pid = guestPid + guestPid = -1 + guestGeneration++ + if (pid > 1) SessionService.stopTerminalProcess(context, pid) + imeWatcher?.stopWatching() + imeWatcher = null + owner = -1 + setState(State.FAILED) + } + } + binding = connection + val bound = runCatching { + context.bindService(Intent(context, Comp::class.java), connection, + Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT) + }.getOrDefault(false) + if (!bound) { + Log.e(TAG, "could not bind the desktop's compositor") + binding = null + setState(State.FAILED) + } + } + + private fun send(target: Messenger, msg: Message) { + runCatching { target.send(msg) }.onFailure { Log.w(TAG, "compositor message ${msg.what} not delivered", it) } + } + + private fun runtimeDir(context: Context) = File(context.filesDir, ".wayland-rt-bottom") + + private fun startGuest() { + val context = appContext ?: return + val current = launch + if (current == null || !SessionState.running || current.generation != owner) { + setState(State.FAILED) + return + } + // A freeze collects its helpers once, when it begins; a guest started after that would run on unfrozen. + if (SessionState.suspended || SessionState.suspending) { + setState(State.OFF) + return + } + val generation = ++guestGeneration + guestPid = 0 + val (width, height) = outputSize + service?.let { send(it, Message.obtain(null, Comp.MSG_NEW_GUEST)) } + Thread({ + if (!DesktopCatalog.desktopInstalled(current.root)) { + Log.e(TAG, "the desktop package is not installed") + main.post { if (generation == guestGeneration) { guestPid = -1; setState(State.FAILED) } } + return@Thread + } + val dir = File(current.sessionDir, "bottom-desktop").apply { mkdirs() } + val runtime = runtimeDir(context).apply { mkdirs() } + val imeDir = File(current.sessionRoot, "bottom-desktop-ime") + main.post { if (generation == guestGeneration) watchIme(imeDir) } + val binds = LinuxRuntime.binds(context, current.sessionRoot, runtime, Environment.getExternalStorageDirectory(), current.binds) + val guest = ArrayList(current.guest).apply { + add("BL_WIDTH=$width") + add("BL_HEIGHT=$height") + add("BL_DEBUG_DIR=" + dir.path) + add("BL_TOP_SESSION=" + SessionState.mode) + add("BL_IME_DIR=" + imeDir.path) + add(SCRIPT) + } + val command = LinuxRuntime.commandWithBinds(context, current.root, binds, guest) + val line = command.joinToString(" ") { it.replace("\\", "\\\\").replace(" ", "\\ ") } + val pid = HostProcess.start(line, current.hostEnvironment, current.root, { status -> + main.post { + if (generation != guestGeneration) return@post + Log.i(TAG, "the desktop ended ($status)") + SessionService.terminalProcessExited(context, guestPid) + guestPid = -1 + guestGeneration++ + setState(if (state == State.RUNNING || status == 0) State.ENDED else State.FAILED) + } + }, null, File(dir, "proot.log")) + main.post { + if (generation != guestGeneration || launch !== current) { + if (pid > 1) SessionService.stopTerminalProcess(context, pid) + if (generation == guestGeneration) guestPid = -1 + return@post + } + if (pid <= 1) { + guestPid = -1 + Log.e(TAG, "the desktop's guest did not start") + setState(State.FAILED) + return@post + } + if (SessionState.suspended || SessionState.suspending) { + SessionService.stopTerminalProcess(context, pid) + guestPid = -1 + guestGeneration++ + setState(State.OFF) + return@post + } + guestPid = pid + SessionService.registerTerminalProcess(context, pid) + Log.i(TAG, "desktop guest pid $pid at ${width}x$height") + } + }, "bottom-desktop-start").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt index d43b1f617..45e0890c5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt @@ -7,13 +7,8 @@ import android.service.notification.StatusBarNotification import androidx.compose.runtime.mutableStateListOf import androidx.core.app.NotificationManagerCompat -/** One notification for the bottom-screen list. Kept in memory only; never written to disk or logs. */ data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long) -/** - * Notifications from other apps for the bottom screen, so a message can be read without leaving the game. - * Works only after the user allows DroidDeck notification access in Android settings. - */ object BottomNotifications { val items = mutableStateListOf() diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomScreenshots.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomScreenshots.kt index 90010a82c..905f22715 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BottomScreenshots.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BottomScreenshots.kt @@ -12,12 +12,10 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** A screenshot for the bottom-screen gallery: one DroidDeck took (a MediaStore [uri]) or one Steam saved (a [file]). */ data class BottomShot(val uri: Uri?, val file: File?, val takenAt: Long, val steam: Boolean) { val model: Any get() = uri ?: file!! } -/** Game frames DroidDeck saves to Pictures/DroidDeck, plus the screenshots Steam keeps under userdata. */ object BottomScreenshots { private const val FOLDER = "DroidDeck" private const val QUALITY = 95 @@ -58,7 +56,6 @@ object BottomScreenshots { } }.getOrDefault(false) - /** Newest first, at most [LIMIT]. */ fun list(context: Context, runtimeRoot: File): List = (own(context) + steam(runtimeRoot)).sortedByDescending { it.takenAt }.take(LIMIT) @@ -85,7 +82,6 @@ object BottomScreenshots { shots }.getOrDefault(emptyList()) - /** The .jpg files in userdata//760/remote//screenshots of each Steam home; the thumbnails folder is skipped. */ internal fun steam(runtimeRoot: File): List = SteamDownloadMonitor.STEAM_HOMES.flatMap { home -> File(runtimeRoot, "$home/userdata").listFiles { f -> f.isDirectory }.orEmpty().flatMap { account -> diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index 84435dc74..8255209d1 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -35,7 +35,6 @@ object ComponentsManager { "Dxvk-gplasync-Linux" to "dxvk", "Vkd3d-proton-Linux" to "vkd3d", ) - // DD_NIGHTLY holds every component in one release; the file name says which. private val NIGHTLY_PREFIXES = linkedMapOf("DD-dxvk-" to "dxvk", "DD-vkd3d-proton-" to "vkd3d", "DD-FEXCore-" to "fex") private val SOURCES = RELEASES.keys.map { NIGHTLIES to it } + (DD_COMPONENTS to DD_NIGHTLY) private val DOWNLOAD_REPOS = listOf(NIGHTLIES, DD_COMPONENTS) @@ -979,12 +978,9 @@ object ComponentsManager { internal fun assetComp(release: String, name: String): String? = RELEASES[release] ?: if (release == DD_NIGHTLY) NIGHTLY_PREFIXES.entries.firstOrNull { name.startsWith(it.key) }?.value else null - // Per component: the stable packages newest first, then the nightlies. internal val CATALOG_ORDER: Comparator = compareBy { it.comp }.thenBy { it.release == DD_NIGHTLY }.then(newestFirst { it.file }) - // A nightly DD--[+N]-nightly.wcp pairs with the newest stable -[-]-linux.wcp - // in the same list, so the stable row can offer it behind a Stable/Nightly switch. Unpaired nightlies keep their own row. internal fun nightlyPairs(items: List): Map { val stable = items.filter { it.release != DD_NIGHTLY } return items.filter { it.release == DD_NIGHTLY }.mapNotNull { n -> diff --git a/app/src/main/java/com/droiddeck/launcher/session/FexCatalog.kt b/app/src/main/java/com/droiddeck/launcher/session/FexCatalog.kt index 92cd7652d..d3471283e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/FexCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/FexCatalog.kt @@ -7,11 +7,6 @@ import com.droiddeck.launcher.core.Downloader import org.json.JSONObject import java.io.File -/** - * The FEX build DroidDeck-Components' fex.json names for x86 programs. A session uses the last copy read. - * Components' build-fex.yml replaces the archive on its single `fex` release and records the build's version; - * a release per build (fex-2610-r5) is still read. - */ object FexCatalog { private const val TAG = "FexCatalog" const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/fex.json" @@ -43,7 +38,6 @@ object FexCatalog { fun cached(context: Context): Build? = runCatching { parse(file(context).readText()) }.getOrNull() - /** Reads fex.json straight from GitHub (a digest never comes from a mirror), at most every ten minutes. */ @Synchronized fun refresh(context: Context, now: Long = System.currentTimeMillis()): Build? { if (now - refreshedAt < REFRESH_MS) return cached(context) @@ -62,7 +56,6 @@ object FexCatalog { return build } - /** For droiddeck-fex-update: the build and every source its archive may come from (it checks the sha256). */ fun environment(build: Build, sources: List = DownloadSource.candidates(build.url)): List = listOf( "BL_FEX_TAG=${build.tag}", "BL_FEX_SHA256=${build.sha256}", diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt index 447fb254c..eb5bb4e0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt @@ -17,7 +17,6 @@ object GameStorage { val primary = context.getExternalFilesDir(null)?.absolutePath val primaryStorage = Environment.getExternalStorageDirectory()?.absolutePath return context.getExternalFilesDirs(null).orEmpty().filterNotNull().mapNotNull { dir -> - // Non-primary app dirs are usable even when Android reports the volume as non-removable (USB SSDs often do). if (primary != null && dir.absolutePath == primary) return@mapNotNull null if (!primaryStorage.isNullOrBlank() && dir.absolutePath.startsWith("$primaryStorage/")) return@mapNotNull null val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null } @@ -63,12 +62,14 @@ object GameStorage { fun gamesFolderLibraries(context: Context): List { if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList() val card = effective(context)?.let { canonical(File(it.path)) } + val added = SteamLibraries.list(context).map { canonical(it.host) } val sm = context.getSystemService(StorageManager::class.java) return AddedGames.roots(context).mapNotNull { root -> val host = canonical(root.host) if (!hasInstalledGames(root.host)) return@mapNotNull null if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null + if (added.any { SteamLibraries.overlaps(it, host) }) return@mapNotNull null val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null } val name = root.host.name.ifEmpty { context.getString(R.string.gstore_folder) } val label = if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt b/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt index 28777f6b0..2a32d817e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt @@ -3,7 +3,6 @@ package com.droiddeck.launcher.session import java.nio.ByteBuffer import java.nio.ByteOrder -/** One reading from a racing game's telemetry. Fractions are 0..1; null means the game does not send it. */ data class Telemetry( val source: String, val speedKmh: Float? = null, @@ -19,33 +18,22 @@ data class Telemetry( val currentLapS: Float? = null, val lastLapS: Float? = null, val bestLapS: Float? = null, - /** G-force as the game sends it (lateral, longitudinal) and steering from -1 (left) to 1 (right). */ val gLat: Float? = null, val gLon: Float? = null, val steer: Float? = null, - /** Front left, front right, rear left, rear right. */ val tireTempsC: List? = null, val turboBar: Float? = null, val engineTempC: Float? = null, val oilTempC: Float? = null, - /** OutGauge dashboard lights that are on, as GameTelemetry.DL_* bits. */ val lights: Int? = null, ) -/** - * Racing telemetry games send over UDP to 127.0.0.1. Each format has its own port, the one the game or - * its common tools use by default: - * - [CODEMASTERS_PORT]: DiRT Rally 1/2, DiRT 4, GRID ("extradata 3" float array), DiRT Showdown (152-byte packet). - * - [FORZA_PORT]: Forza Motorsport 7/2023 and Forza Horizon 4/5 "Data Out" (Sled and Dash). - * - [OUTGAUGE_PORT]: BeamNG.drive and Live for Speed OutGauge. - */ object GameTelemetry { const val CODEMASTERS_PORT = 20777 const val FORZA_PORT = 5300 const val OUTGAUGE_PORT = 4444 val PORTS = listOf(CODEMASTERS_PORT, FORZA_PORT, OUTGAUGE_PORT) - // Games send to 127.0.0.1; on Android InetAddress.getLoopbackAddress() is ::1, which IPv4 packets never reach. val LISTEN_ADDRESS: java.net.InetAddress = java.net.InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)) fun parse(port: Int, data: ByteArray, length: Int): Telemetry? = runCatching { @@ -61,15 +49,12 @@ object GameTelemetry { private const val MPS_TO_KMH = 3.6f private const val GRAVITY = 9.81f - // OutGauge DashLights / ShowLights bits (Live for Speed InSim; BeamNG uses the same values). const val DL_SHIFT = 1 const val DL_HANDBRAKE = 4 const val DL_PITSPEED = 8 const val DL_TC = 16 const val DL_ABS = 1024 - // Float indexes of the Codemasters packet; engine rpm values are sent divided by 10. Older games - // (DiRT Showdown) send only the first 38 floats, so lap count, position, fuel and max rpm are missing there. private const val CM_LAP_TIME = 1 private const val CM_SPEED = 7 private const val CM_THROTTLE = 29 @@ -99,7 +84,6 @@ object GameTelemetry { speedKmh = f(CM_SPEED) * MPS_TO_KMH, rpm = f(CM_RPM) * 10f, maxRpm = (f(CM_MAX_RPM) * 10f).takeIf { it > 0f }, - // Neutral is 0; DiRT Rally 2.0 reports reverse as 10, older games as -1. gear = when { gear == 0 -> "N" gear < 0 || gear == 10 -> "R" @@ -119,8 +103,6 @@ object GameTelemetry { ) } - // Forza "Data Out": Sled is 232 bytes; the Dash block follows it (FM7 311, FM 2023 331). Horizon 4/5 send - // 324 bytes with 12 extra bytes before the Dash block. private const val FORZA_SLED = 232 private const val FORZA_FM7 = 311 private const val FORZA_HORIZON = 324 @@ -140,7 +122,6 @@ object GameTelemetry { if (dash < 0) return Telemetry(source = "Forza", rpm = rpm, maxRpm = maxRpm.takeIf { it > 0f }) if (!racing) return Telemetry(source = "Forza") val gear = buf.get(dash + 75).toInt() and 0xFF - // Tire temperatures are read as Fahrenheit, as community Data Out parsers do; not checked on a device. val tiresF = (0 until 4).map { buf.getFloat(dash + 24 + it * 4) } return Telemetry( source = "Forza", @@ -163,7 +144,6 @@ object GameTelemetry { ) } - // OutGauge: 92 bytes, or 96 with the optional id at the end. private const val OUTGAUGE_SIZE = 92 internal fun outGauge(buf: ByteBuffer, length: Int): Telemetry? { @@ -189,14 +169,12 @@ object GameTelemetry { } } -/** Games that publish their state locally without any setup: War Thunder's web page and Elite Dangerous' Status.json. */ object GameStatusFiles { const val WAR_THUNDER_STATE = "http://127.0.0.1:8111/state" private const val ELITE_APP = "359320" private const val ELITE_STATUS = "pfx/drive_c/users/steamuser/Saved Games/Frontier Developments/Elite Dangerous/Status.json" const val WAR_THUNDER_ROWS = 12 - /** War Thunder's own labels (for example "TAS, km/h") with their values, while a match runs. */ internal fun warThunder(json: String): List> { val state = org.json.JSONObject(json) if (!state.optBoolean("valid", false)) return emptyList() @@ -214,7 +192,6 @@ object GameStatusFiles { data class Elite(val fuelMain: Double?, val fuelReserve: Double?, val cargo: Double?, val pips: List?, val legal: String?, val balance: Long?) - /** Null while Elite is at its menus (Status.json then carries only the timestamp and flags). */ internal fun elite(json: String): Elite? { val s = org.json.JSONObject(json) val fuel = s.optJSONObject("Fuel") diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameThreadHints.kt b/app/src/main/java/com/droiddeck/launcher/session/GameThreadHints.kt index 1b5b90db6..3c2c5098b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameThreadHints.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameThreadHints.kt @@ -12,54 +12,26 @@ 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 SCAN_EVERY = 20 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 LEAVE = 0.15 + private const val MAX_THREADS = 2 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" @@ -69,7 +41,6 @@ object GameThreadHints { return "game thread hints: watching" } - /** At a session's end. */ @Synchronized fun stop() { worker?.interrupt() @@ -92,7 +63,6 @@ object GameThreadHints { 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() @@ -101,7 +71,6 @@ object GameThreadHints { } 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" } + @@ -119,7 +88,6 @@ object GameThreadHints { } } - /** 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): PerformanceHintManager.Session? { session?.close() if (tids.isEmpty()) return null @@ -131,19 +99,9 @@ object GameThreadHints { } } - /** - * 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, busy: Map): Set = busy.entries .filter { (tid, share) -> share >= if (tid in boosted) LEAVE else ENTER } @@ -154,7 +112,6 @@ object GameThreadHints { 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() @@ -163,7 +120,6 @@ object GameThreadHints { private var lastThread = HashMap() private var lastBusy = HashMap() - /** Only the busiest few processes have their threads read, so a second costs a few dozen small reads. */ fun sample(): Map { val now = System.nanoTime() val seconds = (now - lastNs) / 1e9 @@ -197,7 +153,6 @@ object GameThreadHints { 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(' ') diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt index 93214c3d1..a9d55199d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt @@ -21,7 +21,8 @@ class GpuMemComponent(private val file: File, private val root: File) : SessionP thread = Thread({ while (running) { try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } - if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing)) sample()?.let { write(it) } + if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing || + com.droiddeck.launcher.hud.BottomHudPresence.showing)) sample()?.let { write(it) } } }, "gpu-mem").apply { isDaemon = true; start() } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index 006350916..8d2a3801d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -7,10 +7,16 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.File import java.io.RandomAccessFile -class GpuStatsComponent(val dir: File) : SessionPart() { +class GpuStatsComponent(val dir: File, private val root: File) : SessionPart() { @Volatile private var running = false private var thread: Thread? = null private val feeds = ArrayList() + private var layerLoad: LayerLoad? = null + private var memstore: KgslBusy.Reader? = null + private var memstoreLoad: MemstoreLoad? = null + private var memstoreState: String? = null + private var cycleClock: KgslClock.Sampler? = null + @Volatile private var shown = false private class Feed( val file: File, val source: String, val read: () -> Long?, val format: String, val percent: Boolean = false, @@ -24,9 +30,11 @@ class GpuStatsComponent(val dir: File) : SessionPart() { } fun prepare(): Boolean { + sources = "" if (!File(KGSL).exists()) return false val unreadable = MANGOHUD_FILES.any { File(KGSL, it).let { f -> f.exists() && !f.canRead() } } - if (!unreadable && !File(GPUBUSY).canRead()) return false + // Where SELinux hides the kgsl nodes completely (exists() is false too), the layer and cycle-counter feeds stand in. + if (!unreadable && !File(GPUBUSY).canRead() && !loadBlocked()) return false return try { dir.mkdirs() dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) } @@ -34,19 +42,18 @@ class GpuStatsComponent(val dir: File) : SessionPart() { PASSTHROUGH_FILES.forEach { name -> runCatching { File(dir, name).writeText(File(KGSL, name).readText()) } } - loadFeed()?.let { load -> + (loadFeed() ?: blockedLoadFeed())?.let { load -> feeds.add(load) feeds.add(Feed(File(dir, "perf_now"), load.source, load.read, "%-6s\n", percent = true)) } - pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) }?.let { clock -> + (pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) } ?: cycleClockFeed())?.let { clock -> feeds.add(clock) feeds.add(Feed(File(dir, "cur_freq"), clock.source, { clock.read()?.times(1_000_000) }, "%-10d\n")) } pick("temp", tempSources(), "%6d\n") { milliCelsius(it) }?.let { feeds.add(it) } refresh() - Log.i(TAG, "hud: kgsl stats from the session's own: " + MANGOHUD_FILES.joinToString { name -> - "$name " + (feeds.firstOrNull { it.file.name == name }?.source ?: "none") - }) + describeSources() + Log.i(TAG, "hud: kgsl stats from the session's own: $sources") true } catch (e: Exception) { Log.w(TAG, "hud: could not stand in for kgsl stats: $e") @@ -66,19 +73,214 @@ class GpuStatsComponent(val dir: File) : SessionPart() { }, "gpu-stats").apply { isDaemon = true; start() } } - // kgsl's gpubusy can read 0 0 straight after a read, so feeds that share a reader share one read. private fun refresh() { + if (layerLoad != null || cycleClock != null || memstore != null) { + shown = hudShown() + memstore?.run(shown) + if (!shown) memstoreLoad?.pause() + layerLoad?.wanted(shown && memstoreLoad?.working != true) + } val values = HashMap<() -> Long?, Long?>() for (feed in feeds) { if (feed.read !in values) values[feed.read] = feed.read() values[feed.read]?.let { write(feed, it) } } + memstoreLoad?.let { load -> + val state = when { + load.working -> "working" + load.failed -> "failed" + else -> null + } + if (state != memstoreState) { + memstoreState = state + if (load.failed) { + memstore?.close() + memstore = null + Log.i(TAG, "hud: kgsl memstore timestamps never moved; GPU load from Vulkan timing") + } else if (load.working) { + Log.i(TAG, "hud: GPU load from the kgsl memstore (all GPU work)") + } + describeSources() + } + } + } + + private fun describeSources() { + sources = MANGOHUD_FILES.joinToString { name -> + val source = if (name == "gpu_busy_percentage" && memstoreLoad != null) memstoreSource() + else feeds.firstOrNull { it.file.name == name }?.source + "$name " + (source ?: "none") + } + } + + private fun memstoreSource(): String = when { + memstoreLoad?.working == true -> "kgsl memstore ringbuffer timestamps (all GPU work)" + memstoreLoad?.failed == true -> LAYER_SOURCE + else -> "kgsl memstore once its timestamps move; until then $LAYER_SOURCE" } override fun stop() { running = false thread?.interrupt() thread = null + memstore?.close() + memstore = null + memstoreLoad = null + memstoreState = null + layerLoad?.clear() + cycleClock?.close() + cycleClock = null + sources = "" + } + + private fun hudShown(): Boolean = OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing || + com.droiddeck.launcher.hud.BottomHudPresence.showing + + // Android hides the load (kgsl sysfs blocked): the kgsl memstore gives the whole GPU's busy share. The in-game + // Vulkan layer and the compositor time their own GPU work until the memstore timestamps are seen moving, and + // for the whole session where they never move. + private fun blockedLoadFeed(): Feed? { + if (!loadBlocked()) return null + val load = LayerLoad(guestLoadDir(root), extra = ::compositorBusyNs, onWanted = ::compositorTiming) + .also { it.clear(); layerLoad = it } + val sampled = KgslBusy.open()?.let { reader -> + memstore = reader + MemstoreLoad(reader::counts).also { memstoreLoad = it } + } + val read = read@{ + if (!shown) return@read null + val whole = sampled?.percent() + if (sampled?.working == true) whole else load.percent() + } + return Feed(File(dir, "gpu_busy_percentage"), LAYER_SOURCE, read, "%3d %%\n") + } + + // The clock nodes are blocked too: read the GPU's cycle counter from a small GPU job of our own. + private fun cycleClockFeed(): Feed? { + val sampler = KgslClock.open() ?: return null + cycleClock = sampler + // clock_mhz and cur_freq both read this; one GPU job per refresh serves both. + var at = 0L + var last: Long? = null + val read = { + val now = SystemClock.elapsedRealtime() + if (now - at > PERIOD_MS / 2) { at = now; last = if (shown) sampler.mhz()?.toLong() else null } + last + } + return Feed(File(dir, "clock_mhz"), "GPU cycle counter (own GPU job)", read, "%5d\n") + } + + private fun compositorBusyNs(): Long? = + runCatching { com.droiddeck.launcher.wayland.WaylandCompositor.nativeGpuBusyNs() }.getOrNull()?.takeIf { it >= 0 } + + private fun compositorTiming(on: Boolean) { + runCatching { com.droiddeck.launcher.wayland.WaylandCompositor.nativeSetGpuTiming(on) } + } + + /** + * Busy time the layer (tools/gpuload) writes per process, plus [extra] (the compositor's own GPU time), as a share + * of wall time between two reads. + */ + internal class LayerLoad( + private val dir: File, + private val clock: () -> Long = System::nanoTime, + private val extra: () -> Long? = { null }, + private val onWanted: (Boolean) -> Unit = {}, + ) { + private val last = HashMap() + private var lastAt = 0L + private var on = false + + fun wanted(on: Boolean) { + val flag = File(dir, "on") + if (on && !flag.exists()) runCatching { dir.mkdirs(); flag.createNewFile() } + if (!on && flag.exists()) { flag.delete(); last.clear(); lastAt = 0L } + if (on != this.on) onWanted(on) + this.on = on + } + + fun percent(): Long? { + val now = clock() + val files = dir.listFiles { f -> f.name.isNotEmpty() && f.name.all(Char::isDigit) }.orEmpty() + var busy = 0L + val seen = HashSet() + for (file in files) { + val total = runCatching { file.readText().trim().substringBefore(' ').toLong() }.getOrNull() ?: continue + seen += file.name + last[file.name]?.let { if (total >= it) busy += total - it } + last[file.name] = total + if (System.currentTimeMillis() - file.lastModified() > STALE_MS) file.delete() + } + extra()?.let { total -> + seen += EXTRA + last[EXTRA]?.let { if (total >= it) busy += total - it } + last[EXTRA] = total + } + last.keys.retainAll(seen) + val wall = now - lastAt + val first = lastAt == 0L + lastAt = now + if (first || wall <= 0) return null + return (busy * 100 / wall).coerceIn(0, 100) + } + + fun clear() { + dir.listFiles()?.forEach { it.delete() } + last.clear() + lastAt = 0L + if (on) onWanted(false) + on = false + } + + private companion object { + const val EXTRA = "compositor" + } + } + + /** + * Busy share over the last [WINDOW] reads from [KgslBusy] running totals (busy samples, samples, samples with + * new ringbuffer work). Not [working] until new work has been seen; [failed] once [GIVE_UP_SAMPLES] samples + * passed without any. + */ + internal class MemstoreLoad(private val counts: () -> LongArray?) { + var working = false + private set + var failed = false + private set + private val window = ArrayDeque() + private var waited = 0L + + fun percent(): Long? { + if (failed) return null + val now = counts() ?: return null + val previous = window.lastOrNull() + window.addLast(now) + while (window.size > WINDOW + 1) window.removeFirst() + if (previous == null) return null + val fresh = now[1] - previous[1] + if (fresh <= 0) return null + if (!working) { + if (now[2] > previous[2]) { + working = true + } else { + waited += fresh + if (waited >= GIVE_UP_SAMPLES) failed = true + return null + } + } + val base = window.first() + return ((now[0] - base[0]) * 100 / (now[1] - base[1])).coerceIn(0, 100) + } + + /** The totals stop while sampling is off; the next reading starts a new window. */ + fun pause() { + window.clear() + } + + companion object { + const val GIVE_UP_SAMPLES = 5_000L + const val WINDOW = 2 + } } private fun loadFeed(): Feed? { @@ -86,8 +288,6 @@ class GpuStatsComponent(val dir: File) : SessionPart() { return pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) } } - // kgsl publishes busy/total once per ~1 s of GPU-on time and zeroes it on any read while the GPU is - // power-collapsed, so direct pollers mostly see 0; latch each published window instead. private fun latchedBusyFeed(): Feed? { var load = 0L var windowAt = 0L @@ -134,6 +334,24 @@ class GpuStatsComponent(val dir: File) : SessionPart() { private const val LOAD_HOLD_MS = 5_000L private val MANGOHUD_FILES = listOf("gpu_busy_percentage", "clock_mhz", "temp") private val PASSTHROUGH_FILES = listOf("gpu_model", "gpu_chipid") + const val GUEST_LOAD_DIR = "/run/droiddeck-hud/gpu-load" + private const val LAYER_SOURCE = "Vulkan timing: games ($GUEST_LOAD_DIR) + DroidDeck compositor" + + /** Where the running session's GPU feeds come from, for the HUD diagnostics export. */ + @Volatile var sources = "" + private const val STALE_MS = 30_000L + + fun guestLoadDir(root: File) = File(root, GUEST_LOAD_DIR.removePrefix("/")) + + /** True when this app can see kgsl but none of its GPU load nodes (SELinux-blocked, e.g. the Retroid Pocket Duo). */ + fun loadBlocked(): Boolean = File(KGSL).exists() && !File(GPUBUSY).canRead() && LOAD_SOURCES.none { File(it).canRead() } + + /** Turns on the in-game layer (tools/gpuload) where the load is blocked; empty elsewhere. */ + fun guestEnv(root: File): List { + if (!loadBlocked()) return emptyList() + guestLoadDir(root).mkdirs() + return listOf("DROIDDECK_GPU_LOAD=1", "DROIDDECK_GPU_LOAD_DIR=$GUEST_LOAD_DIR") + } private const val QCOM_GPU = "/sys/kernel/gpu" private val LOAD_SOURCES = listOf( "$KGSL/gpu_busy_percentage", "$KGSL/devfreq/gpu_load", "$KGSL/gpuload", "$QCOM_GPU/gpu_busy", diff --git a/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt index 2cd84747d..66401c7fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt @@ -108,5 +108,9 @@ class HwmonComponent(val dir: File, private val root: File) : SessionPart() { private fun readNumber(path: String): Long? = runCatching { File(path).readText().trim().toLong() }.getOrNull()?.takeIf { it >= 0 } + + fun fanSource(): String? = FAN_SOURCES().firstOrNull { readNumber(it) != null } + + fun readRpm(path: String): Long? = readNumber(path) } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt b/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt new file mode 100644 index 000000000..dad62121a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt @@ -0,0 +1,50 @@ +package com.droiddeck.launcher.session + +import android.util.Log + +/** + * Whole-GPU busy share for Adreno devices whose kgsl load nodes are SELinux-blocked for apps + * (app/src/main/cpp/gpuclock/kgsl_busy.c). It covers every process's GPU work, Android's own composition included. + */ +object KgslBusy { + private const val TAG = "SessionService" + + @JvmStatic private external fun nativeOpen(error: IntArray): Long + @JvmStatic private external fun nativeRun(handle: Long, on: Boolean) + @JvmStatic private external fun nativeRead(handle: Long, out: LongArray): Boolean + @JvmStatic private external fun nativeClose(handle: Long) + + private val loaded: Boolean by lazy { + runCatching { System.loadLibrary("gpuclock") }.onFailure { Log.w(TAG, "hud: gpuclock library: $it") }.isSuccess + } + + class Reader internal constructor(private var handle: Long) : AutoCloseable { + /** Samples once a millisecond while on. */ + @Synchronized fun run(on: Boolean) { + if (handle != 0L) nativeRun(handle, on) + } + + /** Running totals: busy samples, samples, samples in which a ringbuffer started new work. */ + @Synchronized fun counts(): LongArray? { + if (handle == 0L) return null + val out = LongArray(3) + return if (nativeRead(handle, out)) out else null + } + + @Synchronized override fun close() { + if (handle != 0L) nativeClose(handle) + handle = 0L + } + } + + fun open(): Reader? { + if (!loaded) return null + val error = IntArray(1) + val handle = runCatching { nativeOpen(error) }.getOrDefault(0L) + if (handle == 0L) { + Log.i(TAG, "hud: no GPU load from the kgsl memstore (open error ${error[0]})") + return null + } + return Reader(handle) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt b/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt new file mode 100644 index 000000000..11c7367ef --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt @@ -0,0 +1,50 @@ +package com.droiddeck.launcher.session + +import android.util.Log + +/** + * GPU clock from the GPU's own cycle counter, for Adreno devices whose kgsl clock nodes are SELinux-blocked for + * apps (app/src/main/cpp/gpuclock/kgsl_clock.c). Each [mhz] call runs one tiny GPU job, so callers sample only + * while a HUD is shown. + */ +object KgslClock { + private const val TAG = "SessionService" + + @JvmStatic private external fun nativeOpen(error: IntArray): Long + @JvmStatic private external fun nativeSample(handle: Long): Int + @JvmStatic private external fun nativeClose(handle: Long) + + private val loaded: Boolean by lazy { + runCatching { System.loadLibrary("gpuclock") }.onFailure { Log.w(TAG, "hud: gpuclock library: $it") }.isSuccess + } + + class Sampler internal constructor(private var handle: Long) : AutoCloseable { + /** MHz, or null when this sample was refused or failed its checks. */ + @Synchronized fun mhz(): Int? = if (handle == 0L) null else nativeSample(handle).takeIf { it > 0 } + + @Synchronized override fun close() { + if (handle != 0L) nativeClose(handle) + handle = 0L + } + } + + /** A sampler that already produced one good reading, or null (the reason goes to the log). */ + fun open(): Sampler? { + if (!loaded) return null + val error = IntArray(1) + val handle = runCatching { nativeOpen(error) }.getOrDefault(0L) + if (handle == 0L) { + Log.i(TAG, "hud: no GPU clock from the cycle counter (open error ${error[0]})") + return null + } + val sampler = Sampler(handle) + val first = (0 until 3).firstNotNullOfOrNull { sampler.mhz() } + if (first == null) { + Log.i(TAG, "hud: GPU cycle counter gave no usable reading") + sampler.close() + return null + } + Log.i(TAG, "hud: GPU clock from the cycle counter ($first MHz now)") + return sampler + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt b/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt index 5632bd27e..f673f282b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt @@ -21,7 +21,6 @@ object OfflineMode { fun account(context: Context): String? { val root = LinuxRuntime.rootDir(context) - // Until its first start the x86-64 client has no sign-in; droiddeck-steam-x64 --sync-login copies the arm64 one. val file = x64LoginUsers(root).takeIf { SessionPrefs.activeSteamClientArch(context) == "x86_64" && it.isFile } ?: loginUsers(root) val text = try { diff --git a/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt b/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt index 35ac169a7..300306f8c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt @@ -11,7 +11,7 @@ object OrphanReaper { fun release(pid: Int) { kept.remove(pid) } - fun reap(reason: String): Int { + fun reap(reason: String, matching: (String) -> Boolean = { true }): Int { val me = android.os.Process.myPid() val uid = android.os.Process.myUid() val spared = descendants(kept) @@ -21,6 +21,7 @@ object OrphanReaper { if (pid == me || pid <= 1 || pid in spared) return@forEach if (uidOf(pid) != uid) return@forEach val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" } + if (!matching(name)) return@forEach try { android.os.Process.killProcess(pid) killed++ diff --git a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt index 5826db97a..39d99bfa6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt @@ -214,8 +214,6 @@ class RumbleComponent : SessionPart() { 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)) } @@ -255,7 +253,6 @@ class RumbleComponent : SessionPart() { 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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index b4408c48b..4fc004304 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -31,7 +31,6 @@ object SessionArtifacts { private val collectingDone = collectingLock.newCondition() private val collecting = HashSet() - /** Recovery leaves [dir] alone from the stop until [collect] has finished it. */ fun collectionStarted(dir: File) { collectingLock.lock() try { collecting.add(dir) } finally { collectingLock.unlock() } @@ -317,7 +316,6 @@ object SessionArtifacts { runCatching { File(dir, CLIENT_FILE).writeText("$arch\n") } } - /** The Steam client a session ran, when it recorded one. */ internal fun recordedClient(dir: File): String? = runCatching { File(dir, CLIENT_FILE).readText().trim() }.getOrNull()?.takeIf { it == "arm64" || it == "x86_64" } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt index 31b9b1098..1cb526c90 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt @@ -7,6 +7,10 @@ object SessionDisplay { const val MATCH_SCREEN = "screen" const val FOLLOW_SCREEN = "follow" const val FOLLOW_MIN_SIDE = 480 + val FOLLOW_SCALES = listOf(1.5f, 2f, 2.5f, 3f, 4f) + const val FOLLOW_CUSTOM = "follow/custom" + const val FOLLOW_SCALE_MAX = 8f + private const val FOLLOW_SCALED_PREFIX = "follow/" const val DEFAULT_RESOLUTION = "720p" private val PRESET_HEIGHTS = listOf(720, 900, 1080) @@ -14,12 +18,30 @@ object SessionDisplay { fun presetHeight(choice: String): Int? = if (choice.endsWith("p")) choice.dropLast(1).toIntOrNull()?.takeIf { it > 0 } else null + fun followScaled(scale: Float): String = FOLLOW_SCALED_PREFIX + formatScale(scale) + + fun formatScale(scale: Float): String = + (Math.round(scale * 10) / 10f).let { if (it % 1f == 0f) it.toInt().toString() else it.toString() } + + fun parseFollowScale(text: String): Float? = + text.trim().replace(',', '.').toFloatOrNull() + ?.let { Math.round(it * 10) / 10f }?.takeIf { it > 1f && it <= FOLLOW_SCALE_MAX } + + fun isFollow(choice: String): Boolean = followScale(choice) != null + + fun followScale(choice: String): Float? = when { + choice == FOLLOW_SCREEN -> 1f + choice.startsWith(FOLLOW_SCALED_PREFIX) -> parseFollowScale(choice.removePrefix(FOLLOW_SCALED_PREFIX)) + else -> null + } + fun screenSize(panel: Pair): Pair = (maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv()) fun resolveChoice(panel: Pair, choice: String, portrait: Boolean = false): Pair { fun orient(size: Pair) = if (portrait) size.second to size.first else size - if (choice == MATCH_SCREEN || choice == FOLLOW_SCREEN) return orient(screenSize(panel)) + if (choice == MATCH_SCREEN) return orient(screenSize(panel)) + followScale(choice)?.let { return orient(scaledDown(screenSize(panel), it)) } presetHeight(choice)?.let { return orient(resolve(panel, it, SessionPrefs.SHAPE_AUTO)) } val parts = choice.split('x').map { it.toIntOrNull() } return if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!! @@ -29,13 +51,16 @@ object SessionDisplay { fun resolutionOptions(panel: Pair): List { val panelHeight = minOf(panel.first, panel.second) return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" } - .filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + FOLLOW_SCREEN + .filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + FOLLOW_SCREEN + FOLLOW_SCALES.map(::followScaled) } - fun followSize(width: Int, height: Int, controlsBelow: Boolean = false): Pair? = + fun followSize(width: Int, height: Int, controlsBelow: Boolean = false, scale: Float = 1f): Pair? = if (width <= 0 || height <= 0 || (if (controlsBelow) width else minOf(width, height)) < FOLLOW_MIN_SIDE) null - else (width and 1.inv()) to (height and 1.inv()) + else scaledDown(width to height, scale) + + private fun scaledDown(size: Pair, scale: Float): Pair = + (Math.round(size.first / scale) and 1.inv()) to (Math.round(size.second / scale) and 1.inv()) const val UNFOLDED_MAX_GAME_SHARE = 0.8f diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 2f31f4ec2..a947bf7e1 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -22,6 +22,7 @@ object SessionFiles { internal val DESKTOP_FILES = arrayOf( "usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop", + "usr/local/bin/droiddeck-bottom-desktop" to "usr/local/bin/droiddeck-bottom-desktop", "usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu", "usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu", "usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules", @@ -32,7 +33,6 @@ object SessionFiles { "etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop", ) - /** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */ fun stage(context: Context, root: File, startsSession: Boolean = false) { if (DesktopCatalog.desktopInstalled(root) && !DesktopCatalog.desktopFromPack(root)) try { DesktopCatalog.cleanLegacy(context) } catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) } @@ -160,12 +160,16 @@ object SessionFiles { ) + handModels).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } + listOf("usr/local/lib/droiddeck-vr/layer/VK_LAYER_DROIDDECK_vr_implicit.json" to "usr/share/vulkan/implicit_layer.d/droiddeck_vr.json") + // HUD GPU load where Android hides it (GpuStatsComponent.guestEnv turns the layer on). + val gpuLoad = listOf( + "usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so" to "usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so", + "usr/local/lib/droiddeck-gpuload/VK_LAYER_DROIDDECK_gpu_load.json" to "usr/share/vulkan/implicit_layer.d/droiddeck_gpu_load.json", + ) val fexPreloads = listOf("x86_64", "i386").flatMap { arch -> listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" } } + listOf("libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" } val optional = (arrayOf( "usr/local/bin/gamescope" to "usr/local/bin/gamescope", - // The gamescope pack carries its own WSI layer (patch 0117); it replaces the runtime's stock copy. "usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so" to "usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so", "usr/local/lib/vkbridge/libvulkan_droidbridge.so" to "usr/local/lib/vkbridge/libvulkan_droidbridge.so", "usr/local/share/vkbridge/droidbridge_icd.json" to "usr/local/share/vkbridge/droidbridge_icd.json", @@ -174,7 +178,7 @@ object SessionFiles { "usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst", "usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE", "usr/local/lib/droiddeck-dirt/ws2_32.dll" to "usr/local/lib/droiddeck-dirt/ws2_32.dll", - ) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) -> + ) + mangoapp + msitools.map { it to it } + vr + gpuLoad + fexPreloads).filter { (asset, _) -> val dir = asset.substringBeforeLast('/') runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) } @@ -279,7 +283,6 @@ object SessionFiles { val movieDir = File(home, "config/uioverrides/movies") val config = File(home, "config/config.vdf") if (startupMovie == null) { - // Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either. for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) { File(movieDir, name).delete() File(movieDir, ".$name.source").delete() @@ -299,17 +302,10 @@ object SessionFiles { if (!bigPictureMovieInstalled) allInstalled = false if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else "" } - // A pick made in the app is done once a session started with every Steam client's config having it; guest commands run - // before that, and a Steam that is already running writes its own choice back when it exits. if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false) seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) } } - /** - * Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that - * could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config - * no longer names a missing movie. - */ internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean { if (!config.isFile) return true val text = runCatching { config.readText() }.getOrElse { @@ -388,7 +384,6 @@ object SessionFiles { installed = staged.setReadable(true, false) && staged.renameTo(movie) if (installed) { FileUtils.writeString(stamp, identity) - // Steam's picker draws its preview from this picture and does not redraw it when the movie changes. File(directory, name.removeSuffix(".webm") + ".jpg").delete() } } catch (e: Exception) { @@ -405,10 +400,6 @@ object SessionFiles { internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/" internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm" - /** - * Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam - * settings is where older clients kept it, and DroidDeck still keeps it in step. - */ private fun customizationMovie(text: String): VdfBlock? { var from = 0 while (true) { @@ -418,7 +409,6 @@ object SessionFiles { } } - /** The older `StartupMovie` section: the first one that is not inside `Customization`. */ private fun olderMovie(text: String): VdfBlock? { val inside = customizationMovie(text) var from = 0 @@ -429,10 +419,8 @@ object SessionFiles { } } - /** The section Steam reads: the one under `Customization` when there is one. */ private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text) - /** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */ internal fun startupMoviePath(config: File): String? = startupMovieLocalPath(config)?.takeIf { it.isNotEmpty() } internal fun startupMovieLocalPath(config: File): String? { @@ -441,7 +429,6 @@ object SessionFiles { return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath") } - /** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */ internal fun startupMovieBlock(config: File): String? { val text = runCatching { config.readText() }.getOrNull() ?: return null val parts = listOfNotNull( @@ -451,7 +438,6 @@ object SessionFiles { return parts.takeIf { it.isNotEmpty() }?.joinToString("\n") } - /** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */ internal fun customizationBlock(config: File): String? { val text = runCatching { config.readText() }.getOrNull() ?: return null val customization = findObject(text, "Customization", 0, text.length) ?: return null @@ -464,7 +450,6 @@ object SessionFiles { return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline)) } - /** True when Steam's config selects DroidDeck's staged movie once this returns. */ internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean { val path = BOOT_MOVIE_PATH val legacyPath = "/uioverrides/movies/droiddeck-startup.webm" @@ -491,7 +476,6 @@ object SessionFiles { return false } } - // Later sections first, so the positions of earlier ones stay valid. val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart } for (target in targets) text = pointAt(text, target, path, newline) if (olderMovie(text) == null) { @@ -535,11 +519,6 @@ object SessionFiles { private val IDLE_SLEEP_TIMERS = listOf("IdleSuspendBatterySeconds", "IdleSuspendACSeconds") - /** - * Sets Steam's Power > "Sleep after" (battery and plugged in) to Disabled at every session start: Android - * owns the device's sleep, and a client that suspends inside the session only shows a black screen. Dimming stays the user's. Runs - * only before the client starts, which rewrites its config on exit. - */ internal fun disableIdleSleep(steamHome: File): Boolean { val config = File(steamHome, "config/config.vdf") val text = runCatching { config.readText() }.getOrNull() ?: return false @@ -570,7 +549,6 @@ object SessionFiles { } } - /** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */ private fun withCustomization(text: String, path: String, newline: String): String { val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 } @@ -640,13 +618,6 @@ object SessionFiles { fun logDirectory(context: Context): File = LinuxRuntime.logDir(context).apply { mkdirs() } - /** - * Registers the Android system fonts (which include CJK faces such as Noto Sans CJK / - * MiSans) with the container's fontconfig, so the Steam Deck UI (CEF/webkit renderer) - * resolves Chinese/Japanese glyphs instead of showing tofu / mojibake. - * The fonts themselves are bind-mounted by LinuxRuntime.bindSystemFonts() into - * /usr/local/share/fonts/android; here we only need to point fontconfig at them. - */ private val DESKTOP_STRINGS = listOf( "desktop_restore_title" to R.string.desktop_restore_title, "desktop_restore_body" to R.string.desktop_restore_body, diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index ded53162c..185550370 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -69,7 +69,6 @@ object SessionLogShare { return zip } - /** Every entry is scrubbed line by line, whatever its size: a raw copy can carry tokens out of the device. */ private fun addEntry(z: ZipOutputStream, name: String, f: File) { z.putNextEntry(ZipEntry(name)) val w = z.bufferedWriter() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt index f73609f44..6f335f231 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt @@ -44,7 +44,6 @@ object SessionPaths { fun current(): File? = dir - /** Whether the current session folder will survive teardown. No session means the next one can retain logs. */ fun currentSessionLogsRetained(context: Context): Boolean = dir?.let { it.parentFile == SessionFiles.logDirectory(context) } ?: true diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index b0e7ef85e..3fe3390c4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -100,12 +100,44 @@ object SessionPrefs { prefs(context).edit().putString("secondScreenPanel", mode).apply() } + /** The bottom-screen desktop takes touch as a trackpad unless the user switched it to direct touch. */ + fun bottomDesktopDirectTouch(context: Context): Boolean = prefs(context).getBoolean("bottomDesktopDirectTouch", false) + + fun setBottomDesktopDirectTouch(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("bottomDesktopDirectTouch", on).apply() + } + fun statsStrip(context: Context): String? = prefs(context).getString("statsStrip", null) fun setStatsStrip(context: Context, id: String) { prefs(context).edit().putString("statsStrip", id).apply() } + fun bottomStripMetrics(context: Context): Set = StripMetric.parse(prefs(context).getString("bottomStripMetrics", null)) + + fun setBottomStripMetric(context: Context, metric: StripMetric, on: Boolean) { + val now = bottomStripMetrics(context).let { if (on) it + metric else it - metric } + prefs(context).edit().putString("bottomStripMetrics", StripMetric.encode(now)).apply() + } + + fun bottomHud(context: Context): Boolean = prefs(context).getBoolean("bottomHud", true) + + fun setBottomHud(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("bottomHud", on).apply() + } + + fun bottomStripOnTabs(context: Context): Boolean = prefs(context).getBoolean("bottomStripTabs", true) + + fun setBottomStripOnTabs(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("bottomStripTabs", on).apply() + } + + fun statsHud(context: Context): String? = prefs(context).getString("statsHud", null) + + fun setStatsHud(context: Context, id: String) { + prefs(context).edit().putString("statsHud", id).apply() + } + fun statsSpeedUnit(context: Context): String? = prefs(context).getString("statsSpeedUnit", null) fun setStatsSpeedUnit(context: Context, id: String) { @@ -118,14 +150,12 @@ object SessionPrefs { prefs(context).edit().putString("statsDash", id).apply() } - /** In-game stats "Send dashboard data" Automatic: on unless the user switched it off. */ fun telemetryAuto(context: Context): Boolean = prefs(context).getBoolean("telemetryAuto", true) fun setTelemetryAuto(context: Context, on: Boolean) { prefs(context).edit().putBoolean("telemetryAuto", on).apply() } - /** Games the user switched off by hand, as TelemetryOutput keys; Automatic leaves them alone. */ fun telemetryOff(context: Context): Set = prefs(context).getStringSet("telemetryOff", null)?.toSet().orEmpty() fun setTelemetryOff(context: Context, keys: Set) { @@ -156,13 +186,6 @@ object SessionPrefs { prefs(context).edit().putString("gameStoresSpeedTier", tier).apply() } - /** - * Hidden A/B switch for the Epic installer: "adaptive" (default: native parallel fetch written - * straight into staged files), "lookahead" (the native ordered-lookahead engine that caches each - * chunk and copies it into staged files), "batch" (the older whole-cache native fetch, then - * native assembly; needs about twice the game's size free) or "java" (the bounded Java writer). - * No UI; set it in the `session` preferences for benchmarks. - */ fun storesEpicMode(context: Context): String = prefs(context).getString("storesEpicMode", "adaptive") ?: "adaptive" fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false) @@ -239,7 +262,6 @@ object SessionPrefs { prefs(context).edit().putString("storesInstallTarget", path).apply() } - /** Empty means follow the app/system language for GOG and Epic installs. */ fun storesInstallLanguage(context: Context): String = prefs(context).getString("storesInstallLanguage", "") ?: "" fun setStoresInstallLanguage(context: Context, code: String) { @@ -360,6 +382,12 @@ object SessionPrefs { prefs(context).edit().putBoolean("directCursor", on).apply() } + fun backHold(context: Context): Boolean = prefs(context).getBoolean("backHold", true) + + fun setBackHold(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("backHold", on).apply() + } + fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false) fun setBackActionsInverted(context: Context, inverted: Boolean) { @@ -459,10 +487,6 @@ object SessionPrefs { position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply() } - /** - * After a game launched from the front end (or another launcher's link) closes, the client - * exits cleanly and the session returns there, instead of staying in Big Picture. - */ fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true) fun setReturnAfterGame(context: Context, on: Boolean) { @@ -472,11 +496,6 @@ object SessionPrefs { private fun shaderPrecacheApplied(context: Context) = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.bl-shader-precache-applied") - /** - * Shader pre-caching as the launcher last set it, until a session reports it written into the - * client's config.vdf; after that the client's own toggle is the user's again. A client that - * has never written a config.vdf starts with pre-caching off. - */ fun pendingShaderPrecache(context: Context): Boolean? { val prefs = prefs(context) if (!prefs.contains("shaderPrecache")) { @@ -647,7 +666,6 @@ 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) @@ -662,18 +680,12 @@ object SessionPrefs { prefs(context).edit().putBoolean("prootFastPath", on).apply() } - /** gamescope draws the cursor itself. Off by default: on a phone that plane costs frames. */ fun cursorComposite(context: Context): Boolean = prefs(context).getBoolean("cursorComposite", false) fun setCursorComposite(context: Context, on: Boolean) { prefs(context).edit().putBoolean("cursorComposite", on).apply() } - /** - * The rootfs ships Termux's unpatched proot. A session uses it only when this file exists, - * so a missing add-on pack cannot silently fall back to it. - * Path: Android/data/<package>/files/droiddeck-stock-proot - */ @JvmStatic fun allowStockProot(context: Context): Boolean = context.getExternalFilesDir(null)?.let { File(it, "droiddeck-stock-proot").isFile } == true @@ -782,7 +794,6 @@ object SessionPrefs { ) } - /** Extra Proton, Wine and graphics diagnostics for the next game launch; off unless chosen. */ fun gameDebugLogsEnabled(context: Context): Boolean = prefs(context).getBoolean("gameDebugLogsEnabled", false) @@ -891,7 +902,7 @@ object SessionPrefs { fun resolutionChoice(context: Context, mode: String, panel: Pair): String { val saved = prefs(context) saved.getString("displayResolution.$mode", null)?.let { value -> - if (value == SessionDisplay.MATCH_SCREEN || value == SessionDisplay.FOLLOW_SCREEN || + if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.isFollow(value) || SessionDisplay.presetHeight(value) != null) return value parseResolution(value)?.let { return "${it.first}x${it.second}" } } @@ -904,7 +915,7 @@ object SessionPrefs { } fun setResolutionChoice(context: Context, mode: String, choice: String) { - val value = if (choice == SessionDisplay.MATCH_SCREEN || choice == SessionDisplay.FOLLOW_SCREEN || + val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.isFollow(choice) || SessionDisplay.presetHeight(choice) != null) choice else { val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" } "${size.first}x${size.second}" @@ -936,7 +947,6 @@ object SessionPrefs { prefs(context).edit().putString("steamClientArch", id).apply() } - /** The running session's client, else the selected one: a changed setting applies from the next session. */ fun activeSteamClientArch(context: Context): String = SessionState.steamClientArch?.takeIf { SessionState.running } ?: steamClientArch(context) @@ -954,13 +964,10 @@ object SessionPrefs { fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() } fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100) fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() } - /** How long the boot movie plays, in seconds; 0 plays all of it. */ fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0) fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() } - /** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */ fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false) fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() } - /** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */ fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null) fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() } fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null) @@ -1124,13 +1131,11 @@ object SessionPrefs { prefs(context).edit().putString("theme", id).apply() } - /** The custom Gradient theme's primary and secondary colours (ARGB). */ fun gradientColors(context: Context): Pair = prefs(context).let { it.getInt("gradientPrimary", com.droiddeck.launcher.ui.Themes.DEFAULT_PRIMARY) to it.getInt("gradientSecondary", com.droiddeck.launcher.ui.Themes.DEFAULT_SECONDARY) } - /** How fast the Inner Gamer theme cycles its colours, 1 to 10. */ fun rgbSpeed(context: Context): Int = prefs(context).getInt("innerGamerSpeed", com.droiddeck.launcher.ui.Themes.DEFAULT_RGB_SPEED).coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS) @@ -1160,18 +1165,11 @@ object SessionPrefs { prefs(context).edit().putBoolean("hdr.$mode", on).apply() } - /** Compositor frame limit that releases buffers to the game unpaced. */ const val FPS_UNPACED = -1 - /** The drawer's frame limit slider: its first stop is Off. */ const val FPS_LIMIT_OFF = 15 const val FPS_LIMIT_MAX = 165 - /** - * Buffers go back to the game as soon as they are replaced instead of once per refresh; - * Steam's Frame Limit in the Quick Access menu caps Steam games, and the drawer's limit caps games - * launched without Steam. Mirroring still follows the glasses' refresh rate. - */ const val PIP_FPS = 30 fun compositorFpsLimit(mirrorRefreshLimit: Int, sessionLimit: Int = 0, pip: Boolean = false): Int { @@ -1472,4 +1470,16 @@ object SessionPrefs { fun setGameStorage(context: Context, path: String, label: String) { prefs(context).edit().putString("gameStorage", path).putString("gameStorageLabel", label).apply() } + + fun steamLibraries(context: Context): String = prefs(context).getString("steamLibraries", "") ?: "" + + fun setSteamLibraries(context: Context, json: String) { + prefs(context).edit().putString("steamLibraries", json).apply() + } + + fun steamLibraryNextId(context: Context): Int = prefs(context).getInt("steamLibraryNextId", 1) + + fun setSteamLibraryNextId(context: Context, id: Int) { + prefs(context).edit().putInt("steamLibraryNextId", id).apply() + } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 4cdf82642..e58d59e08 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -107,7 +107,6 @@ class SessionService : Service() { private var freezeCancelled = false private var pendingSteamGameId: String? = null private var pendingSteamGameGeneration = -1 - // Bumped to cancel a running game-exit watch (a new launch, or the session ending). @Volatile private var gameExitWatchToken = 0 private val flushSteamGame = object : Runnable { override fun run() = flushQueuedSteamGame() @@ -477,7 +476,6 @@ class SessionService : Service() { .onFailure { Log.w(TAG, "agent environment skipped", it) } .getOrDefault(emptyList()) SessionFiles.stage(this, root, startsSession = true) - // Earlier sessions' reports still read the Steam logs and VAC marker this session replaces. SessionArtifacts.awaitCollections() SessionArtifacts.finishAbandoned(this, beforeGuestStart = true) VacDiagnostics.arm(this) @@ -536,7 +534,6 @@ class SessionService : Service() { guest.add("DXVK_HDR=1") Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1") } - // gamescope's WSI layer and winex11 reuse their per-present window checks for this long. guest.add("DROIDDECK_WSI_BYPASS_CHECK_MS=100") guest.add("WINE_OFFSCREEN_CHECK_MS=100") guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz)) @@ -578,6 +575,7 @@ class SessionService : Service() { add("-c") add("cd \"\$HOME\" && exec /bin/bash -i") } + val desktopGuest = ArrayList(guest) guest.add(LinuxRuntime.SESSION_SCRIPT) guest.add(SessionState.mode) if (SessionState.mode == MODE_STEAM) SessionState.steamUrl?.takeIf { it.startsWith("steam://") }?.let { @@ -644,6 +642,10 @@ class SessionService : Service() { } if (gen == sessionGen && SessionState.running) { SessionTerminal.prepare(terminalCommand, terminalHostEnvironment.map { (key, value) -> "$key=$value" }.toTypedArray(), root, gen) + val (fastPathName, _) = ProotFastPath.hostEnv("") + BottomDesktop.prepare(desktopGuest, binds, sessionRoot, + hostEnv.asArray().filterNot { it.startsWith("$fastPathName=") }.toTypedArray(), root, sessionDir, gen, + ) { gen == sessionGen && SessionState.running } } if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) OfflineMode.apply(this, root) @@ -705,7 +707,10 @@ class SessionService : Service() { if (gen == sessionGen && SessionState.running) { suspendController = SessionSuspendController( sessionRoot = { sessionPid }, - helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } }, + helperRoots = { + components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } + + listOfNotNull(BottomDesktop.suspendPid.takeIf { it > 1 }) + }, suspendAudio = { if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink") if (!pulse.setMicSuspended(true)) Log.w(TAG, "could not suspend microphone source") @@ -809,6 +814,7 @@ class SessionService : Service() { if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") guest.add("MESA_DISK_CACHE_DATABASE=1") + guest.addAll(GpuStatsComponent.guestEnv(LinuxRuntime.rootDir(this))) guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216") if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0")) SessionState.steamClientArch = if (steamHere) SessionPrefs.steamClientArch(this) else null @@ -1016,7 +1022,7 @@ class SessionService : Service() { gpuMem.attach(this) components.add(gpuMem) } - val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0")) + val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0"), LinuxRuntime.rootDir(this)) if (gpuStats.prepare()) { gpuStats.attach(this) components.add(gpuStats) @@ -1123,8 +1129,43 @@ class SessionService : Service() { } } } + val addedLibraries = ArrayList() + val cardPath = storageDiagnosticLibrary?.let { library?.path }?.let { runCatching { File(it).canonicalPath }.getOrDefault(it) } + for (entry in SteamLibraries.list(this)) { + if (cardPath != null && SteamLibraries.overlaps(cardPath, runCatching { entry.host.canonicalPath }.getOrDefault(entry.path))) { + Log.w(TAG, "added steam library: ${entry.path} overlaps the card library; not a library this session") + continue + } + if (!entry.host.isDirectory) { + Log.w(TAG, "added steam library: ${entry.path} is not attached; not a library this session") + continue + } + val problem = GameStorage.prepare(this, entry.path) + if (problem != null) { + Log.w(TAG, "added steam library: $problem") + continue + } + try { + val room = if (ProotFastPath.enabled(this)) { + ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size + } else Int.MAX_VALUE + val content = SecondaryLibrary.contentPath(entry.host) + val extra = SecondaryLibrary.binds(filesDir, entry.host, listOf(entry.guest), content = content) + if (extra.size > room) { + Log.w(TAG, "added steam library: ${entry.path} needs ${extra.size} binds, $room left for the fast path; not a library this session") + continue + } + File(LinuxRuntime.rootDir(this), entry.guest.removePrefix("/")).mkdirs() + binds.addAll(extra) + libraries.add(entry.guest to entry.label) + steamLibraryMounts = steamLibraryMounts + (entry.guest to content) + addedLibraries.add(entry) + Log.i(TAG, "added steam library: ${content.path} -> ${entry.guest} (\"${entry.label}\")") + } catch (e: Exception) { + Log.w(TAG, "added steam library: ${entry.path} private directories could not be prepared", e) + } + } val x64Libraries = org.json.JSONArray() - // Desktop mode offers the x86-64 client either way; droiddeck-steam-x64 drops library folders it is not given. val x64Here = SessionPrefs.steamClientArch(this) == "x86_64" || (SessionState.mode == MODE_DESKTOP && com.droiddeck.launcher.storage.SteamStorage.x64ClientPresent(SessionPrefs.steamRoot(this, "x86_64"))) @@ -1149,6 +1190,27 @@ class SessionService : Service() { Log.w(TAG, "x86-64 steam library: $x64 private directories could not be prepared", e) } } + if (x64Here) for (entry in addedLibraries) { + val problem = GameStorage.prepare(this, entry.x64Host.path) + if (problem != null) { + Log.w(TAG, "x86-64 steam library: $problem") + continue + } + try { + val room = if (ProotFastPath.enabled(this)) { + ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size + } else Int.MAX_VALUE + val extra = SecondaryLibrary.binds(filesDir, entry.x64Host, listOf(entry.x64Guest)).drop(1) + if (extra.size > room) { + Log.w(TAG, "x86-64 steam library: ${entry.x64Host} needs ${extra.size} binds, $room left for the fast path; not a library this session") + continue + } + binds.addAll(extra) + x64Libraries.put(org.json.JSONObject().put("path", entry.x64Guest).put("label", entry.label)) + } catch (e: Exception) { + Log.w(TAG, "x86-64 steam library: ${entry.x64Host} private directories could not be prepared", e) + } + } try { val listing = File(filesDir, "session/steam-libraries-x64.json").apply { parentFile?.mkdirs() } listing.writeText(x64Libraries.toString()) @@ -1328,13 +1390,6 @@ class SessionService : Service() { Log.i(TAG, "queued Steam game $gameId for the running client") } - /** - * A game launched from the front end (the Games tab, or another launcher's link): once it has - * started and then been gone for [GAME_EXIT_GRACE_MS], ask the client to exit through the - * session script's steam-stop request. The client quits cleanly, finishing its cloud sync, so - * the session ends on its own and the activity returns to where the launch came from instead - * of leaving the player in Big Picture. The grace covers launchers that restart the game. - */ private fun watchGameExit(gameId: String, gen: Int) { if (!SessionPrefs.returnAfterGame(this)) return val token = ++gameExitWatchToken @@ -1349,7 +1404,6 @@ class SessionService : Service() { seen = true goneSince = 0L } else if (!seen) { - // Never started (an install, an update or a failed launch): leave the session be. if (now > startBy) { Log.i(TAG, "game $gameId did not start; not watching for its exit") return@Thread @@ -1375,8 +1429,6 @@ class SessionService : Service() { Log.i(TAG, "game $gameId closed: asking the client to exit and returning to the launcher") } - /** A Steam game is running: the client runs each one under its reaper, and Proton's script - * waits on the game with waitforexitandrun. Read from the command lines, as ComponentsManager does. */ private fun steamGameRunning(): Boolean = SteamGameProcess.running() private fun resumeSession(panelVisible: Boolean = true) { @@ -1589,7 +1641,6 @@ class SessionService : Service() { if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) { manualPauseRequested = true } else if (!hidden && !SessionState.suspended) { - // Coming back before the freeze finished keeps the game running instead of pausing it a moment later. manualPauseRequested = false } val controller = suspendController ?: return @@ -1612,7 +1663,6 @@ class SessionService : Service() { SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend else -> false } - // A frozen game never sends the stop for a held rumble, so the keepalive would run on. val quietRumble = !screenOn || shouldSuspend || SessionState.suspended components.forEach { (it as? RumbleComponent)?.quiet(quietRumble) } if (freezePending && !shouldSuspend && !freezeCancelled) { @@ -1631,6 +1681,7 @@ class SessionService : Service() { )) if (shouldSuspend) { freezePending = true + SessionState.suspending = true freezeCancelled = false val freezeSteamToken = steamSleepToken val freezeNativeToken = nativeSleepToken @@ -1639,6 +1690,7 @@ class SessionService : Service() { ) { success -> mainHandler.post { if (gen != sessionGen || !SessionState.running) return@post + SessionState.suspending = false suspendOperationPending = false freezePending = false if (success) { @@ -1851,6 +1903,7 @@ class SessionService : Service() { GameThreadHints.stop() suspendOperationPending = false freezePending = false + SessionState.suspending = false launchWatcher?.stopWatching() launchWatcher = null syncWatcher?.stopWatching() @@ -1861,6 +1914,7 @@ class SessionService : Service() { auxiliaryProcesses.toMap().also { auxiliaryProcesses.clear() } } SessionTerminal.clear(sessionGen) + BottomDesktop.stop(this, stoppedGen) val controller = suspendController suspendController = null if (ended != null) { @@ -1928,7 +1982,7 @@ class SessionService : Service() { override fun onDestroy() { stopSession(0) - GameThreadHints.stop() // also when the session never got as far as running + GameThreadHints.stop() if (screenReceiverRegistered) { unregisterReceiver(screenReceiver) screenReceiverRegistered = false @@ -2167,9 +2221,7 @@ class SessionService : Service() { private const val DOWNLOAD_POLL_MS = 2_000L private const val STEAM_GAME_REQUEST = "steam-game" private const val GAME_URL_PREFIX = "steam://rungameid/" - /** How long a front-end launch may take to start (downloads, first-run installs) before the exit watch gives up. */ private const val GAME_START_TIMEOUT_MS = 20 * 60_000L - /** How long the game must stay gone before the client is asked to exit: launchers restart games. */ private const val GAME_EXIT_GRACE_MS = 10_000L private const val GAME_EXIT_POLL_MS = 2_000L private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 3cf93a322..8e63d05c6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -54,6 +54,8 @@ object SessionState { var suspended by mutableStateOf(false) var resumeRequested by mutableStateOf(false) var resuming by mutableStateOf(false) + /** A freeze has begun and has not finished; it already chose which processes it stops. */ + @Volatile var suspending = false var resumeFailures by mutableIntStateOf(0) var resumeFailed by mutableStateOf(false) var manualPause by mutableStateOf(false) @@ -70,8 +72,6 @@ object SessionState { var steamUrl: String? = null @Volatile var steamClientArch: String? = null - // A Play SteamVR request can arrive while an existing session is visible. - // Wake the status/launch effect even if no glasses surface has attached yet. var steamVr by mutableStateOf(false) var relaunch: android.content.Intent? = null @JvmStatic var hdr = false @@ -94,7 +94,6 @@ object SessionState { @Volatile var secondScreenAutoOpened = false @Volatile var secondScreenProvisional = false - /** Guest library paths on removable storage and the host folders they are mounted from, set by SessionService. */ @Volatile var steamLibraryMounts: Map = emptyMap() @Volatile @@ -105,6 +104,7 @@ object SessionState { @Volatile var followScreen = false + var followScale = 1f @Volatile var refreshHz: Float = 60f @@ -112,7 +112,6 @@ object SessionState { @Volatile var upscaleRatio: Float = 0f - /** Frame limit chosen in the drawer for a game launched without Steam; 0 is off. */ @Volatile var fpsLimit: Int = 0 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt index adb792e71..b0a685214 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt @@ -26,9 +26,7 @@ internal class SessionSuspendController( private val nowNanos: () -> Long = System::nanoTime, private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit = { _, _, _, _, _, _ -> }, - // False with a RAM cap set: the user asked for the session to use less memory, so nothing is read back. private val readBackAllowed: () -> Boolean = { true }, - // processes is 0 when the read-back was skipped: a RAM cap, or MemAvailable could not take it. private val pagedIn: (processes: Int, swapKb: Long, swapBeforeKb: Long, availableKb: Long, millis: Long) -> Unit = { _, _, _, _, _ -> }, ) { @@ -113,12 +111,10 @@ internal class SessionSuspendController( if (interrupted) Thread.currentThread().interrupt() } completion(complete) - // A cancelled or failed freeze leaves the game running with whatever the page-out already moved. if (!complete && frozen.isEmpty() && !interrupted) readBack() }, { completion(false) }) } - // A freeze still queued or paging out stops before its next SIGSTOP pass and rolls back. fun cancelFreeze() { cancelled = true } @@ -252,7 +248,6 @@ internal class SessionSuspendController( } private fun stoppedSnapshot(records: List, root: ProcessRecord): Map? { - // PRoot can leave the leader stopped while native worker threads keep running. val tasks = LinkedHashMap() var stopped = true for (record in records) { @@ -279,9 +274,6 @@ internal class SessionSuspendController( return tasks.takeIf { stopped && it.isNotEmpty() } } - // A stopped process cannot run libblsession's SIGURG handler, so the page-out has to finish - // before the stop. Android's low-memory killer otherwise finds the frozen game resident in RAM - // with most of zram still free. private fun pageOut(records: MutableMap, ProcessRecord>, root: Int, helpers: List) { val control = zramControl?.takeIf { it.isFile } ?: return try { @@ -304,8 +296,6 @@ internal class SessionSuspendController( val start = nowMillis() val before = memoryKb(targets) for (record in targets) send(record, SIGURG) - // A program can replace libblsession's SIGURG handler (PyInstaller's bootloader, the Go runtime); - // such a process never creates its ack and is not waited for once the start window has passed. val waiting = targets.toMutableList() while (!closing && !cancelled && nowMillis() - start < PAGE_OUT_TIMEOUT_MS) { val started = nowMillis() - start >= PAGE_OUT_START_MS @@ -319,13 +309,6 @@ internal class SessionSuspendController( pagedOut(targets.size, unacknowledged, after.first, after.second, before.second, nowMillis() - start) } - // Left in zram, the paged-out memory comes back one major fault at a time while the game runs; in a heavy - // game that is hundreds of faults per frame and stalls of a quarter second and more, long after the resume. - // libblsession's handler reads back only the swapped pages (mode 0, as the session script does for the client). - // The handler runs on whichever thread takes the signal, so that thread pauses for the read-back, - // once and right after the resume instead of mid-game. - // It signals even with nothing in zram yet: after a cancelled freeze a page-out handler may not have started, - // and it reads the mode only when it starts, so it reads back instead of paging out. private fun readBack() { val targets = pagedOutTargets.filterNot { isGone(it) } pagedOutTargets = emptyList() @@ -360,15 +343,10 @@ internal class SessionSuspendController( pagedIn(targets.size, swap, before, available, nowMillis() - start) } - // Reading back moves the swap into resident memory, out of MemAvailable. On a device short on memory that - // could push Android's low-memory killer onto DroidDeck itself, so the memory comes back only when - // MemAvailable is at least twice the swap and keeps 1 GB or a tenth of MemTotal, whichever is more, after it. - // Otherwise it stays in zram and faults back in as the game touches it, as before. private fun canTake(swapKb: Long, totalKb: Long, availableKb: Long): Boolean = availableKb >= swapKb * READ_BACK_AVAILABLE_FACTOR && availableKb - swapKb >= maxOf(READ_BACK_HEADROOM_KB, totalKb / READ_BACK_TOTAL_SHARE) - // MemTotal and MemAvailable, 0 when unreadable (which skips the read-back). private fun meminfoKb(): Pair = try { val values = HashMap() File(procRoot, "meminfo").forEachLine { line -> @@ -402,9 +380,6 @@ internal class SessionSuspendController( return resident to swap } - // PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts - // thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its - // signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP. private fun stop(records: List) { for (record in records.filter { it.role == Role.SESSION_ROOT }) send(record, SIGSTOP) for (record in records.filter { it.role == Role.HELPER_ROOT }) send(record, SIGSTOP) @@ -469,7 +444,6 @@ internal class SessionSuspendController( val current = try { readProcess(record.pid) } catch (_: Exception) { return false } if (current == null || current.started != record.started) return true if (current.state == 'T' || current.state == 't') return false - // A traced worker in 't' is usually PRoot handling a system call; 'T' is a job-control stop. return File(procRoot, "${record.pid}/task").listFiles().orEmpty().none { entry -> entry.name.toIntOrNull() != null && try { readStat(File(entry, "stat"))[0].first() == 'T' } catch (_: IOException) { false } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt index bce07bba5..b3a222304 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -15,7 +15,7 @@ object SettingsReset { "dualScreenMode", "dualScreenLauncher", "dualScreenSessionControls", "gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature", "storesInstallLanguage", - "hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController", + "hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backHold", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController", "gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController", "stretch16x9", "picturePosition", "railLabels", "gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop", @@ -26,7 +26,7 @@ object SettingsReset { "upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias", "steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds", "bootMovieApplyNext", "bootMovieSteamSeen", - "questDefaultsV1", "telemetryAuto", "telemetryOff", "statsStrip", "statsDash", "statsSpeedUnit", + "questDefaultsV1", "telemetryAuto", "telemetryOff", "statsStrip", "statsHud", "bottomHud", "bottomStripMetrics", "bottomStripTabs", "statsDash", "statsSpeedUnit", ) internal val SESSION_PREFIXES = listOf( diff --git a/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt b/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt index d0ff1486a..b438ba2f3 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt @@ -1,38 +1,58 @@ package com.droiddeck.launcher.session -/** The In-game stats page's top strip, picked on the page (tap or the Layout row) and remembered. */ enum class StatsStrip(val id: String) { - PANEL("panel"), TILES("tiles"), RINGS("rings"); + LINE("line"), PANEL("panel"), TILES("tiles"), RINGS("rings"); fun next(): StatsStrip = entries[(ordinal + 1) % entries.size] companion object { - fun from(id: String?): StatsStrip = entries.firstOrNull { it.id == id } ?: PANEL + fun from(id: String?): StatsStrip = entries.firstOrNull { it.id == id } ?: LINE + } +} + +enum class StripMetric(val id: String, val defaultOn: Boolean = true) { + ENGINE("engine"), GRAPH("graph"), LOWS("lows"), LOW001("low001"), GPU("gpu"), GPU_TEMP("gputemp", false), + CPU("cpu"), CPU_TEMP("cputemp"), VRAM("vram"), RAM("ram"), CLOCK("clock"); + + companion object { + val defaults: Set = entries.filter { it.defaultOn }.toSet() + + fun parse(saved: String?): Set = + if (saved == null) defaults + else saved.split(',').mapNotNull { id -> entries.firstOrNull { it.id == id.trim() } }.toSet() + + fun encode(on: Set): String = entries.filter { it in on }.joinToString(",") { it.id } + } +} + +enum class StatsHud(val id: String) { + BIG_FPS("bigfps"), FULL("full"), TILES("tiles"), RINGS("rings"), GRAPHS("graphs"), CORES("cores"), + HEAT("heat"), GAME("game"), PACING("pacing"), NIGHT("night"), MEGA("mega"); + + fun next(): StatsHud = entries[(ordinal + 1) % entries.size] + + companion object { + fun from(id: String?): StatsHud = entries.firstOrNull { it.id == id } ?: BIG_FPS } } -/** Speeds on the racing dashes; telemetry arrives in km/h. */ enum class SpeedUnit(val id: String, val label: String, val perKmh: Float) { AUTO("auto", "", 1f), KMH("kmh", "km/h", 1f), MPH("mph", "mph", 0.621371f); - /** Auto is mph where road speed limits are signed in mph, by the device's region, else km/h. */ fun resolve(country: String): SpeedUnit = if (this != AUTO) this else if (country.uppercase() in MPH_REGIONS) MPH else KMH fun next(): SpeedUnit = entries[(ordinal + 1) % entries.size] companion object { - /** The United States and its territories, the United Kingdom and its Crown Dependencies, Liberia, the Bahamas, Belize. */ private val MPH_REGIONS = setOf("US", "PR", "GU", "VI", "AS", "MP", "GB", "IM", "JE", "GG", "LR", "BS", "BZ") fun from(id: String?): SpeedUnit = entries.firstOrNull { it.id == id } ?: AUTO } } -/** The racing dash under the strip; Auto picks one by the game's data format. */ enum class StatsDash(val id: String) { AUTO("auto"), SPEEDOMETER("speedometer"), CIRCUIT("circuit"), TRACE("trace"), DIALS("dials"), GLANCE("glance"); - /** The dash to draw for data from [source] (Telemetry.source). */ fun resolve(source: String): StatsDash = if (this != AUTO) this else when (source) { "Forza" -> CIRCUIT "OutGauge" -> DIALS @@ -44,7 +64,6 @@ enum class StatsDash(val id: String) { fun from(id: String?): StatsDash = entries.firstOrNull { it.id == id } ?: AUTO - /** A tap on the dash moves on from the one on screen, so Auto never looks like a tap did nothing. */ fun tapped(shown: StatsDash): StatsDash = DRAWN[(DRAWN.indexOf(shown) + 1).mod(DRAWN.size)] } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt index 2a940a96f..9503e8e2f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt @@ -7,12 +7,10 @@ import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder -/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */ object SteamDeckRepo { private const val TAG = "SteamDeckRepo" const val BASE = "https://steamdeckrepo.com" - /** The site shows twelve movies a page. */ const val PAGE_SIZE = 12 const val MAX_DOWNLOAD_BYTES = 120L shl 20 private const val MAX_JSON_BYTES = 1 shl 20 @@ -23,7 +21,6 @@ object SteamDeckRepo { TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"), } - /** `null` is every device. */ val DEVICES = listOf("steam_deck", "steam_machine") class Post( @@ -36,7 +33,6 @@ object SteamDeckRepo { object Failed : Page() } - /** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */ private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key) fun parse(json: String): List? { @@ -83,10 +79,6 @@ object SteamDeckRepo { return result } - /** - * The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a - * page past the end is empty and the last one is short. Null when the site cannot be reached. - */ fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p -> (fetch(sort, device, search, p) as? Page.Ok)?.posts?.size }): Int? { @@ -111,7 +103,6 @@ object SteamDeckRepo { return good } - /** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */ internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? { val out = java.io.ByteArrayOutputStream() val buffer = ByteArray(16 * 1024) @@ -142,7 +133,6 @@ object SteamDeckRepo { } } - /** Fetches a small picture such as a cover; null when it cannot be had. */ fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? { if (!url.startsWith("https://")) return null var connection: HttpURLConnection? = null @@ -161,10 +151,6 @@ object SteamDeckRepo { } } - /** - * Downloads a movie to [target] through the site's own download address, so the site counts it like a download from - * its website, and falls back to the video's address. True when a file was saved. - */ fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean { for (url in listOf("$BASE/post/download/${post.id}", post.video)) { if (save(url, target, onProgress)) return true diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt index b8b898457..83b101db4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt @@ -3,7 +3,6 @@ package com.droiddeck.launcher.session import java.io.File import java.io.RandomAccessFile -/** A Steam download or update the client is working on. [total] is 0 while Steam has not sized it yet. */ data class SteamDownload( val appId: String, val name: String, @@ -13,7 +12,6 @@ data class SteamDownload( val paused: Boolean = false, ) -/** Reads Steam's own appmanifests and content log, the same files [SteamDownloadMonitor] watches. */ object SteamDownloads { fun read(runtimeRoot: File, mounts: Map = emptyMap(), client: String? = null): List { val names = HashMap() @@ -32,7 +30,6 @@ object SteamDownloads { return apps + shaders } - /** The arm64 and x86-64 Steam homes can both hold an unfinished manifest of one app; the page keys rows by app. */ internal fun onePerApp(downloads: List): List = downloads.groupBy { it.appId }.values.map { same -> same.maxBy { it.done } } @@ -46,7 +43,6 @@ object SteamDownloads { return SteamDownload(id, name, done, total, paused = (flags and UPDATE_PAUSED) != 0L) } - /** App ids whose latest shader-cache line in the content log says the update is still running. */ internal fun runningShaderUpdates(lines: Sequence): List { val state = LinkedHashMap() for (line in lines) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamLibraries.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamLibraries.kt new file mode 100644 index 000000000..2d1ea267f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamLibraries.kt @@ -0,0 +1,100 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.storage.StorageManager +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.AddedGames +import java.io.File +import org.json.JSONArray +import org.json.JSONObject + +/** Steam libraries the user added beside the internal one and the card library, each a folder of its own. */ +object SteamLibraries { + const val GUEST_ROOT = "/mnt/droiddeck-lib" + // Each library costs a bind per redirected tool and prefix folder; the PRoot fast path holds 256 in all. + const val MAX = 4 + private val lock = Any() + + class Entry(val id: String, val path: String, val label: String) { + val host: File get() = File(path) + val guest: String get() = "$GUEST_ROOT/$id" + val x64Host: File get() = File(path, GameStorage.X64_CARD_DIR) + val x64Guest: String get() = "$guest/${GameStorage.X64_CARD_DIR}" + } + + fun list(context: Context): List = parse(SessionPrefs.steamLibraries(context)) + + internal fun parse(json: String): List { + if (json.isBlank()) return emptyList() + val array = runCatching { JSONArray(json) }.getOrNull() ?: return emptyList() + return (0 until array.length()).mapNotNull { i -> + val o = array.optJSONObject(i) ?: return@mapNotNull null + val id = o.optString("id") + val path = o.optString("path") + if (!id.matches(Regex("[0-9]{1,6}")) || path.isEmpty()) null else Entry(id, path, clean(o.optString("label")).ifEmpty { File(path).name }) + }.distinctBy { it.id } + } + + private fun save(context: Context, entries: List) { + val array = JSONArray() + for (e in entries) array.put(JSONObject().put("id", e.id).put("path", e.path).put("label", e.label)) + SessionPrefs.setSteamLibraries(context, array.toString()) + } + + /** Steam's libraryfolders.vdf stores labels between quotes with no escaping DroidDeck relies on. */ + internal fun clean(label: String): String = label.replace('"', ' ').replace('\\', ' ').replace(Regex("\\s+"), " ").trim() + + sealed class AddResult { + class Added(val entry: Entry) : AddResult() + class Refused(val reason: String) : AddResult() + } + + fun add(context: Context, path: String, label: String = defaultLabel(context, File(path))): AddResult { + val entries = list(context) + if (entries.size >= MAX) return AddResult.Refused(context.getString(R.string.libraries_full, MAX)) + val host = canonical(File(path)) + val taken = entries.map { canonical(it.host) } + listOfNotNull( + GameStorage.effective(context)?.let { canonical(File(it.path)) }, + canonical(SessionPrefs.steamRoot(context, "arm64")), + ) + if (taken.any { overlaps(it, host) }) return AddResult.Refused(context.getString(R.string.libraries_overlap)) + // Adding a Games folder itself is fine; a child of one would make it overlap and hide the rest of that folder. + if (AddedGames.roots(context).any { host.startsWith(canonical(it.host) + "/") }) return AddResult.Refused(context.getString(R.string.libraries_overlap)) + GameStorage.prepare(context, path)?.let { return AddResult.Refused(it) } + synchronized(lock) { + val current = list(context) + if (current.size >= MAX) return AddResult.Refused(context.getString(R.string.libraries_full, MAX)) + if (current.any { overlaps(canonical(it.host), host) }) return AddResult.Refused(context.getString(R.string.libraries_overlap)) + // A removed library's mount stays registered with Steam until its next session, so ids are never reused. + val next = maxOf(SessionPrefs.steamLibraryNextId(context), (current.maxOfOrNull { it.id.toInt() } ?: 0) + 1) + val entry = Entry(next.toString(), path, clean(label).ifEmpty { File(path).name }) + save(context, current + entry) + SessionPrefs.setSteamLibraryNextId(context, next + 1) + return AddResult.Added(entry) + } + } + + fun rename(context: Context, id: String, label: String) { + val name = clean(label).takeIf { it.isNotEmpty() } ?: return + synchronized(lock) { save(context, list(context).map { if (it.id == id) Entry(it.id, it.path, name) else it }) } + } + + /** Forgets the library; its folder and games stay where they are. */ + fun remove(context: Context, id: String) { + synchronized(lock) { save(context, list(context).filterNot { it.id == id }) } + } + + fun defaultLabel(context: Context, folder: File): String { + val volume = try { + context.getSystemService(StorageManager::class.java)?.getStorageVolume(folder)?.getDescription(context) + } catch (e: Exception) { + null + } + val name = folder.name.ifEmpty { context.getString(R.string.gstore_folder) } + return clean(if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume)) + } + + internal fun overlaps(a: String, b: String): Boolean = a == b || a.startsWith("$b/") || b.startsWith("$a/") + + private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath) +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamWebLogin.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamWebLogin.kt index f9075857f..356df06f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamWebLogin.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamWebLogin.kt @@ -8,20 +8,12 @@ import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec -/** A cookie for one https host, in the form CookieManager.setCookie takes. */ data class WebCookie(val host: String, val header: String) -/** - * The web sign-in the Steam client in the session already has, so the bottom-screen chat opens signed in. - * Steam's built-in browser keeps it in its Chromium cookie store under config/htmlcache. With no keyring - * (SteamOS and DroidDeck) Chromium encrypts values as "v10": AES-128-CBC, key PBKDF2-SHA1("peanuts", - * "saltysalt", 1 round), IV of 16 spaces. Values are never logged. - */ object SteamWebLogin { val HOSTS = listOf("steamcommunity.com", "steam-chat.com") private const val COOKIES = "config/htmlcache/Default/Cookies" private const val V10 = "v10" - // From cookie store version 24 Chromium puts the SHA-256 of the host before each value. private const val HOST_HASH_VERSION = 24 private const val HOST_HASH_BYTES = 32 @@ -39,19 +31,16 @@ object SteamWebLogin { String(value, Charsets.UTF_8) }.getOrNull() - /** Steam's own pages send the sign-in with its "||" separator percent-encoded. */ internal fun header(name: String, value: String, httpOnly: Boolean): String { val v = if (name == "steamLoginSecure") value.replace("||", "%7C%7C") else value return "$name=$v; Path=/; Secure" + if (httpOnly) "; HttpOnly" else "" } - /** Empty when the client has no web sign-in, for example before the first login. */ fun read(runtimeRoot: File, client: String?, scratch: File): List { val homes = client?.let { listOf(if (it == "x86_64") SteamDownloadMonitor.STEAM_HOMES[1] else SteamDownloadMonitor.STEAM_HOMES[0]) } ?: SteamDownloadMonitor.STEAM_HOMES val store = homes.map { File(runtimeRoot, "$it/$COOKIES") }.filter { it.isFile }.maxByOrNull { it.lastModified() } ?: return emptyList() - // A copy, journal included: Steam keeps the store open and may be in the middle of a write. scratch.deleteRecursively() if (!scratch.mkdirs()) return emptyList() return try { diff --git a/app/src/main/java/com/droiddeck/launcher/session/TelemetryOutput.kt b/app/src/main/java/com/droiddeck/launcher/session/TelemetryOutput.kt index afd60b02d..b3d884148 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/TelemetryOutput.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/TelemetryOutput.kt @@ -4,17 +4,11 @@ import android.content.Context import com.droiddeck.launcher.frontend.Library import java.io.File -/** - * Codemasters games keep their UDP / motion output in My Games//hardwaresettings/hardware_settings_config.xml - * inside the game's Proton prefix and read it when the game starts. Turning it on points that element at the - * In-game stats listener; the file as it was is kept beside it, and its element is put back when turned off. - */ object TelemetryOutput { data class Game(val name: String, val prefixId: String, val file: File, val on: Boolean) data class MenuGame(val name: String, val setting: String, val port: Int) - /** Games whose output is only a setting in the game's own menus, by Steam app id. */ val MENU_GAMES: Map = mapOf( 1293830 to MenuGame("Forza Horizon 4", "Data Out", GameTelemetry.FORZA_PORT), 1551360 to MenuGame("Forza Horizon 5", "Data Out", GameTelemetry.FORZA_PORT), @@ -28,7 +22,6 @@ object TelemetryOutput { private const val ADDRESS = "127.0.0.1" private const val EXTRADATA = "3" private val TAGS = listOf("udp", "motion") - // Byte-for-byte: every byte maps to one char and back, whatever the file's own encoding is. private val BYTES = Charsets.ISO_8859_1 fun scan(compatdata: List): List = compatdata.asSequence() @@ -43,7 +36,6 @@ object TelemetryOutput { .sortedBy { it.name.lowercase() } .toList() - /** Turns the game's output on or off; false when the file could not be read or written. */ fun set(game: Game, on: Boolean): Boolean = runCatching { val file = game.file val original = File(file.path + ORIGINAL_SUFFIX) @@ -62,7 +54,6 @@ object TelemetryOutput { true }.getOrDefault(false) - /** Automatic: turns the output on for every found game the user has not switched off and that is not running. */ fun applyAutomatic(context: Context, running: String?) { if (!SessionPrefs.telemetryAuto(context)) return automaticTargets(scan(Library.compatdataDirs(context)), SessionPrefs.telemetryOff(context), running).forEach { set(it, true) } @@ -73,11 +64,9 @@ object TelemetryOutput { internal fun automaticTargets(games: List, off: Set, running: String?): List = games.filter { !it.on && key(it) !in off && !isRunning(it.prefixId, running) } - /** Games DroidDeck changed, which go back to their own setting when Automatic is switched off. */ internal fun restoreTargets(games: List, running: String?): List = games.filter { File(it.file.path + ORIGINAL_SUFFIX).isFile && !isRunning(it.prefixId, running) } - /** Whether [running] (the focused Steam app id, or another key for non-Steam programs) may be the game in [prefixId]. */ internal fun isRunning(prefixId: String, running: String?): Boolean { val id = running?.toLongOrNull() ?: return running != null return prefixId == id.toString() || prefixId == id.toInt().toString() diff --git a/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt b/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt index 2f0d03c28..e38d1c454 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt @@ -10,17 +10,9 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** - * Source games state VAC-secure or insecure only in their console, which reaches a file (console.log in the - * mod folder) only with -condebug. The report quotes the lines it found and says inconclusive rather than guessing. - */ object VacDiagnostics { private const val TAG = "VacDiagnostics" - /** - * Checked by the launch wrapper. Lines: session start (epoch seconds), the Steam client it ran, then - * "lengthcanonical path" for each console.log already there. - */ private const val MARKER = "root/.droiddeck-vac-diagnostics" private const val LAUNCHES = "root/.droiddeck-vac-launches" @@ -30,7 +22,6 @@ object VacDiagnostics { private const val MAX_QUOTED = 40 private val INSECURE = Regex("insecure mode", RegexOption.IGNORE_CASE) - // "Connecting to" is only an attempt: L4D2 refuses a VAC server with a dialog that never reaches the console. private val CONNECTED = Regex("^Connected to", RegexOption.IGNORE_CASE) private val ATTEMPTED = Regex("^Connecting to", RegexOption.IGNORE_CASE) private val NOTABLE = Regex("\\bVAC\\b|secure|Connected to|Connecting to|Disconnect|challenge|game coordinator", RegexOption.IGNORE_CASE) @@ -45,7 +36,6 @@ object VacDiagnostics { File(root, LAUNCHES).delete() if (enabled(context)) { val arch = SessionPrefs.steamClientArch(context) - // -condebug appends, so each console.log's length now is where this session's lines start. val sizes = consoleLogs(context, arch).joinToString("") { "${it.length()}\t${canonical(it)}\n" } marker.writeText("${System.currentTimeMillis() / 1000}\n$arch\n$sizes") } else marker.delete() @@ -57,7 +47,6 @@ object VacDiagnostics { private const val SNAPSHOT_MARKER = ".vac-marker" private const val SNAPSHOT_LAUNCHES = ".vac-launches" - /** Call when a session stops, before the next [arm] replaces its marker and launch list. */ fun snapshot(context: Context, dir: File) { val root = LinuxRuntime.rootDir(context) try { @@ -145,7 +134,6 @@ object VacDiagnostics { "Inconclusive - the game never connected to a server. Join a VAC-secured server, then check again." } - /** Source writes //console.log, Source 2 /game//console.log. */ private fun consoleLogs(context: Context, arch: String): List { val found = LinkedHashMap() for (library in libraryRoots(context, arch)) { @@ -176,7 +164,6 @@ object VacDiagnostics { return roots.filter { it.isDirectory }.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.path) } } - /** Two libraries can hold the same game and mod folder; a repeated name takes a tag from the log's path. */ internal fun consoleNames(logs: List): List { val used = HashSet() return logs.map { log -> diff --git a/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt b/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt index 8e4b24730..b7cc20a43 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt @@ -4,7 +4,6 @@ import android.util.Log import com.droiddeck.launcher.core.SessionPart import java.io.File -/** Steam's Remote Play V4L2 encoder in the guest talks to this MediaCodec encoder (venc.c, venc_server.cpp). */ class VideoEncodeBridge(val socket: File) : SessionPart() { override fun start() { socket.parentFile?.mkdirs() diff --git a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt index 4b355caa4..21398bc4c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt @@ -103,11 +103,6 @@ object WinComponents { const val VCREDIST_ARM64 = "vcredist2022_arm64" - /** - * The ARM64 Visual C++ 2015-2022 runtime as registry values only (the version a 14.44.35211 installer writes, in both - * registry views). Xbox games' launchers running as ARM64EC check for it; Wine's own CRT does the work. Same approach as - * MGamer404's Bannerlator #627. Used until the catalog carries a component of this name. - */ internal fun builtInArm64Runtime(): Component { fun value(view: String, name: String, type: String, data: String) = Step("set_register_key", JSONObject() .put("action", "set_register_key").put("key", "HKLM\\Software\\${view}Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64") @@ -388,7 +383,6 @@ object WinComponents { val archiveName = archive.optString("name") val url = c.snapshot.substringBeforeLast('/') + "/" + archiveName val file = File(work, "${c.name}.files.tar.xz") - // The snapshot came straight from github.com, so its digest may vouch for a mirror's copy of the archive. val sha = archive.optString("sha256").takeIf { it.matches(Regex("(?i)[0-9a-f]{64}")) } if (!Downloader.downloadFile(url, file, false, sha) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) { return null to "download failed: $archiveName" diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt index 9697addd6..de3648e83 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt @@ -48,7 +48,6 @@ object SteamStorage { ) } - /** The folder alone does not mean installed: Decky's CEF marker creates it. */ fun x64ClientPresent(root: File): Boolean = File(root, "steamrt64/steam").isFile || File(root, "package/droiddeck-installed").isFile || File(root, "steamapps").isDirectory @@ -58,7 +57,6 @@ object SteamStorage { 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 @@ -91,8 +89,6 @@ object SteamStorage { 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 { @@ -146,7 +142,6 @@ object SteamStorage { private val ARM_IN_NAME = Regex("arm64|aarch64") - // The x86-64 client ignores tools named for arm64; droiddeck-steam-x64 names its wrappers with a64 instead. fun x64ToolName(name: String): String = name.replace(ARM_IN_NAME, "a64") class ToolUse(val mappings: Map, val installed: Set, val x64: Boolean = false) { diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt index aa0a76cb2..9c5fff91a 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt @@ -89,7 +89,6 @@ object StorageScan { fun measure(file: File): Long = size(file).also { known[file.path] = it } } - // Proton tools' files live only in the arm64 client's root; the x86-64 client has wrappers of them. fun steamRoot(context: Context) = SessionPrefs.steamRoot(context, "arm64") fun scan(context: Context): StorageReport { @@ -113,7 +112,6 @@ object StorageScan { Library.steamLibraries(context, arch).map { (root, label) -> val internal = canonical(root) == primary val data = if (internal) root else SecondaryLibrary.privateRoot(files, root) - // The x86-64 card folder and its steamapps appear only with that client's first session and library write. val listing = SteamStorage.manifests(root) ?: SteamStorage.Listing(emptyList(), emptySet(), true).takeIf { x64 && (if (internal) !File(root, "steamapps").exists() else !root.exists() && root.parentFile?.isDirectory == true) @@ -127,7 +125,6 @@ object StorageScan { .filter { !SteamStorage.isTool(it) && it.installed }.map { it.appId.toString() }.toSet() val armUse = runCatching { SteamStorage.compatMappings(File(steamRoot, "config/config.vdf").readText()) }.getOrNull() ?.let { SteamStorage.ToolUse(it, installedGames(false)) } - // An x86-64 client without a config.vdf has never mapped a game to anything. val x64Vdf = x64Root?.let { File(it, "config/config.vdf") } val x64Use = if (x64Vdf == null || !x64Vdf.exists()) SteamStorage.ToolUse(emptyMap(), emptySet(), x64 = true) else runCatching { SteamStorage.ToolUse(SteamStorage.compatMappings(x64Vdf.readText()), installedGames(true), x64 = true) }.getOrNull() diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt index f7086336e..503e769be 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt @@ -115,7 +115,6 @@ object StorageState { }, "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 @@ -134,7 +133,6 @@ object StorageState { }, "storage-$name").start() } - // A game can be installed in both Steam clients, and an id can be left over in several libraries. fun clearShaders(context: Context, appId: Int, x64: Boolean = false) = act(context, "shaders-$appId", { _, fresh -> val dir = fresh.games.firstOrNull { it.appId == appId && it.x64 == x64 }?.shaderDir ?: return@act false FileOps.delete(dir) @@ -204,7 +202,6 @@ object StorageState { } } - // 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) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt index 9dfc6b665..2b52fe358 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt @@ -12,10 +12,6 @@ object CloudSavePaths { class GogCloudLocation(val name: String, val template: String) - /** - * cloudStorage.enabled with an empty locations list is Galaxy SDK IStorage, whose objects the - * API names under a location called `__default`. - */ fun gogSaveFromRemote(enabled: Boolean, locations: List>, clientId: String): GogCloudLocation? { if (!enabled) return null val named = locations.filter { it.second.isNotEmpty() } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt index 659ae7005..ace330415 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt @@ -131,10 +131,6 @@ object CloudSaves { } } - /** - * The selected cloud location's name: GOG prefixes every cloud file with it, and the local folder - * is the location itself. Older builds had no name cached; fetch it once for those. - */ private fun gogLocationName(context: Context, id: String, folder: File): String? { val p = GogPrefs.get(context) p.getString("cloud_location_$id", null)?.ifEmpty { null }?.let { return it } @@ -194,11 +190,6 @@ object CloudSaves { internal class Migration(val baseline: Map, val moved: Int, val cleaned: Int, val failed: Int) - /** - * Moves only files whose `"/"` key proves the old mapping wrote them; anything - * the game itself created under that name is left where it is. Identical duplicates are removed - * only when they prove the same. The name must be one path segment: it comes from remote config. - */ internal fun migrateNested(dir: File, name: String, base: Map): Migration? { if (name.isEmpty() || name == "." || name == ".." || name.contains('/') || name.contains('\\')) return null val nested = File(dir, name) @@ -254,8 +245,6 @@ object CloudSaves { private fun migrateGogNested(context: Context, id: String, folder: File, dir: File) { val p = GogPrefs.get(context) - // Run once per game: after this the flat layout is authoritative, even if the game itself - // keeps a folder named like the cloud location. if (p.getInt("cloud_layout_$id", 0) >= CLOUD_LAYOUT) return val name = gogLocationName(context, id, folder) ?: return val base = baseline(context, Store.GOG, id).orEmpty() @@ -281,9 +270,6 @@ object CloudSaves { fun upload(context: Context, store: Store, id: String, force: Boolean = false): Result = sync(context, store, id, "up") { dir, cloud -> upWith(context, store, id, dir, cloud, force) } - fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true) - fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true) - fun downloadOrDefer(context: Context, store: Store, id: String): Result = downloadOrDefer(context, store, id, Executor { Thread(it, "cloud-deferred").start() }) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt index 9c32bd4c1..3d46a28da 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt @@ -47,7 +47,6 @@ internal fun gunzip(data: ByteArray): ByteArray? = try { java.util.zip.GZIPInputStream(data.inputStream()).use { it.readBytes() } } catch (e: Exception) { null } -/** Same gzip GOG uses on PUT: deflate level 6, mtime 0, OS Unix. */ internal fun gzipGogPut(data: ByteArray): ByteArray { val deflater = Deflater(6, true) val raw = ByteArrayOutputStream() @@ -98,17 +97,14 @@ internal fun isoDate(s: String?): Long { class NoCloudSaves : Exception("no cloud saves") -/** GOG cloud names are `/`; the name is not part of the folder. */ internal fun splitGogName(name: String): Pair { val slash = name.indexOf('/') return if (slash <= 0 || slash == name.length - 1) null to name else name.substring(0, slash) to name.substring(slash + 1) } -/** Maps cloud names to paths under the selected location's folder; the chosen location name is returned. */ internal fun gogRelativeNames(files: List, location: String?): Pair> { val locations = files.mapNotNull { splitGogName(it.name).first }.distinct() - // Only infer a location when every name is prefixed; a mixed listing keeps its old shape. val allPrefixed = files.isNotEmpty() && files.all { splitGogName(it.name).first != null } val chosen = when { location != null && (location in locations || allPrefixed) -> location @@ -167,10 +163,6 @@ class GogCloud( return relative(raw) } - /** - * The API names every file `/`. The location's folder is the sync root, so the - * first segment is dropped. Older builds kept it, writing saves one folder too deep. - */ private fun relative(raw: List): List { val (chosen, files) = gogRelativeNames(raw, location) prefix = chosen?.let { "$it/" } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt b/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt index 01c7eebcf..89b393654 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt @@ -12,7 +12,6 @@ import javax.crypto.spec.GCMParameterSpec object CredentialCipher { const val VERSION = 1 - /** Sealed with the hardware-keystore (TEE) key; [VERSION] envelopes keep opening with the older, possibly StrongBox, key. */ const val VERSION_TEE = 2 const val ALG = "AES/GCM" private const val TRANSFORMATION = "AES/GCM/NoPadding" @@ -23,7 +22,6 @@ object CredentialCipher { fun existing(): SecretKey = key() - /** Key for [VERSION] envelopes written before [VERSION_TEE]. */ fun legacy(): SecretKey = existing() val version: Int get() = VERSION @@ -35,8 +33,6 @@ object CredentialCipher { e is android.security.keystore.KeyPermanentlyInvalidatedException || e is KeyMissing || e is org.json.JSONException || e is IllegalArgumentException - // StrongBox took over 10 s per Xbox credential write on a tester's phone, past the backend's - // 10 s reply timeout, so new writes use a TEE key. The old alias stays readable. object Keystore : KeyProvider { private const val LEGACY_ALIAS = "droiddeck-store-credentials" private const val ALIAS = "droiddeck-store-credentials-tee" diff --git a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt index 027ab1243..ced9ae8fb 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt @@ -130,7 +130,6 @@ object SteamLiveShortcuts { if (evaluate(context, js) != null) Log.i(TAG, "live shortcuts: art $assetType for $id") } - /** Moves and resizes a game's logo in the running client; with null the client's default position comes back. */ fun setLogoPosition(context: Context, appId: Long, position: LogoPosition.Value?) { if (!clientRunning()) return val id = appId and 0xFFFFFFFFL diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt index f69380f7c..5de9b5e1a 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt @@ -95,7 +95,6 @@ object StoreAccounts { return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label } } - /** The name the Stores page shows. Xbox shows the gamertag, else the sign-in email with its name part masked. */ fun displayName(context: Context, store: Store): String? { if (store != Store.XBOX) return signedInAs(context, store) val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt index 14415fd9c..2b86be8b2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt @@ -29,7 +29,6 @@ object StoreArt { return writeLogo(get(url) ?: return false, dst) } - /** A logo is drawn over the hero, so it stays a PNG with its transparency instead of going through [place]. */ internal fun writeLogo(bytes: ByteArray, dst: File): Boolean { if (dst.isFile && dst.length() > 0) return true val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return false diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt index 967e56ca7..ad59e511a 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -26,7 +26,6 @@ data class CatalogItem( val logoUrl: String? = null, ) { val accessible: Boolean get() = owned || subscription - /** Licensed now, or a supported package the account's subscription can install. */ val installable: Boolean get() = accessible || includedWith.isNotBlank() val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank() val key: String get() = "${store.id}:$id" @@ -95,7 +94,6 @@ internal fun retainOrder(items: List, previous: List, key: (T) -> Stri return previous.map { byKey.getValue(key(it)) } } -/** A detail page opens for search results too; the library wins when it already knows the product. */ fun detailCatalogItem( store: Store, id: String, @@ -114,11 +112,6 @@ fun mergeItems(old: List?, new: List): List?, patched: List): List { if (old.isNullOrEmpty()) return patched val byKey = patched.associateBy { it.key } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt index d1acc8937..c26bcd17d 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt @@ -39,7 +39,6 @@ object StoreInstallRoot { } catch (e: Exception) { StoresState.logLine("storage: StatFs failed for store install target (${e.javaClass.simpleName})") try { - // File's zero also means an unavailable measurement; only StatFs establishes zero. usableBytes(existing).takeIf { it > 0L } ?: SPACE_UNKNOWN } catch (fallback: Exception) { StoresState.logLine("storage: usable-space fallback failed for store install target (${fallback.javaClass.simpleName})") @@ -51,19 +50,12 @@ object StoreInstallRoot { @JvmStatic fun insufficientSpace(required: Long, available: Long): Boolean = required > 0L && available >= 0L && available < required - /** - * The catalog size is the known minimum for an install. Epic's actual chunk-cache requirement - * is determined from its manifest before chunk downloads begin. - */ fun estimatedBytesNeeded(item: CatalogItem): Long = StoreSizes.size(item).coerceAtLeast(0L) - /** Pre-install storage check to detect insufficient space before starting. */ fun checkStorage(context: Context, item: CatalogItem, targetRoot: File): StorageCheck { val size = estimatedBytesNeeded(item) val existing = existingFolder(context, item.store, item.id, item.title) val freeTarget = availableBytes(existing ?: targetRoot) - // Backends account for completed files on resume; the catalog cannot describe remaining work. - // Comparing the full catalog size to remaining free space would reject valid resumes. if (item.store == Store.EPIC || existing != null) return StorageCheck(enough = true, requiredBytes = size, availableBytes = freeTarget) if (size <= 0L) return StorageCheck(enough = true, requiredBytes = 0L, availableBytes = freeTarget) if (insufficientSpace(size, freeTarget)) { @@ -93,7 +85,6 @@ object StoreInstallRoot { fun customTarget(context: Context): Target? { val folder = defaultStorageFolder(context) ?: return null - // Writes are probed when the folder is chosen; File.canWrite() is unreliable on FUSE volumes. if (!folder.isDirectory) return null val label = SessionPrefs.defaultStorageFolderLabel(context).ifBlank { folderLabel(context, folder) } return Target(File(folder, ON_VOLUME), label, removable = true, folder = true) @@ -163,7 +154,6 @@ object StoreInstallRoot { } } - /** Xodus has its own PRoot mount table and cannot see the selected Steam library's SD bind. */ fun externalRootsForXodus(context: Context): List> { val roots = externalRoots(context) val selectedLibrary = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt index 2f4df83f2..c29ea4165 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt @@ -19,7 +19,6 @@ object StoreInstalls { StoreGameSidecar(store, id, title, exe = "", cover = cover, hero = hero, state = StoreGameSidecar.STATE_INSTALLING).write(folder) } - /** Epic commits verified files individually, not a whole-game transaction. Keep updates resumable. */ @JvmStatic fun markUpdating(folder: File) { val before = StoreGameSidecar.read(folder) ?: return diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLanguage.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLanguage.kt index bc9afe3c2..c8ab32800 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLanguage.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLanguage.kt @@ -6,18 +6,9 @@ import com.droiddeck.launcher.session.SessionPrefs import org.json.JSONArray import java.util.Locale -/** - * Language for a third-party store install ([Store.GOG], [Store.EPIC]): the app/system locale by - * default, or the code the user picked in the install dialog / store settings. - * - * GOG depots and Epic install tags use overlapping but not identical codes (`en` vs `en-US`, - * `nb` vs `no`). Matching is on the language part plus a few aliases, so a Polish phone selects - * `pl-PL` depots without the user having to know the store's spelling. - */ object StoreLanguage { const val EXTRA = "installLanguage" - /** Codes the settings list and Epic's tag table share; GOG often uses the same with a region. */ val KNOWN: List = listOf( "en", "pl", "de", "fr", "es", "es-419", "pt", "pt-BR", "it", "ru", "uk", "cs", "hu", "nl", "sv", "da", "fi", "nb", "ro", "bg", "el", "tr", "ar", "th", "vi", "ja", "ko", @@ -36,7 +27,6 @@ object StoreLanguage { fun selectable(store: Store): Boolean = store == Store.GOG || store == Store.EPIC - /** Tag actually sent to the store: settings override, else the app/system locale. */ @JvmStatic fun preferredCode(context: Context): String { val chosen = SessionPrefs.storesInstallLanguage(context) @@ -68,10 +58,6 @@ object StoreLanguage { fun fallbackList(store: Store): List = if (selectable(store)) KNOWN else emptyList() - /** - * One code from [available]. An exact normalized code wins (`pl-PL` over `pl`). A base-language - * match is used only when that exact code is not offered, then English, then the first code. - */ @JvmStatic fun pick(available: List, preferred: String): String { if (available.isEmpty()) return preferred @@ -82,11 +68,6 @@ object StoreLanguage { ?: available.first() } - /** - * True when a GOG depot whose `languages` array is [listed] overlaps [chosen], including a - * base-language match (`pl` accepts `pl-PL`). Shared depots (`*`, empty, missing) always match. - * Install selection uses [depotMatchesResolved] after [pick] has chosen a single code. - */ @JvmStatic fun depotAccepted(listed: Array?, chosen: String): Boolean { if (listed.isNullOrEmpty()) return true @@ -97,10 +78,6 @@ object StoreLanguage { } } - /** - * True when [listed] is shared, or contains the one code [resolved] already selected. - * `pl-PL` does not also keep a `pl` depot. - */ @JvmStatic fun depotMatchesResolved(listed: Array?, resolved: String): Boolean { if (listed.isNullOrEmpty()) return true @@ -109,7 +86,6 @@ object StoreLanguage { return listed.any { primary(it) == want } } - /** Normalized code without collapsing to the base language. `pl-PL` stays `pl-pl`. */ internal fun primary(code: String): String? { val raw = code.trim().lowercase().replace('_', '-') if (raw.isEmpty()) return null diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLogFiles.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLogFiles.kt index bd94cfc3b..95cd69ebd 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLogFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLogFiles.kt @@ -26,7 +26,6 @@ object StoreLogFiles { private val CLOCK = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) - /** Appends [text] with a time stamp. Works in any of the app's processes. */ fun stamped(context: Context, text: String) = append(context, "${synchronized(CLOCK) { CLOCK.format(Date()) }} $text") fun append(context: Context, line: String) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt index 37169cf96..91d96f6ec 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt @@ -183,10 +183,6 @@ 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 @@ -221,7 +217,6 @@ 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 diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index ff44ca763..cf774f47b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -145,7 +145,6 @@ object StoresState { private val libraryAsked = java.util.concurrent.ConcurrentHashMap() - /** Once per store, again after a minute when the last sync never produced a library (offline, expired sign-in). */ fun loadLibrary(context: Context, store: Store) { if (!isSignedIn(store) || library.containsKey(store)) return val now = android.os.SystemClock.elapsedRealtime() @@ -205,7 +204,6 @@ object StoresState { } var openDownloads by mutableStateOf(false) - /** A "store:id" key another page asks the Stores page to open. */ var openGameRequest by mutableStateOf(null) private fun askForNotifications(context: Context) { @@ -323,10 +321,6 @@ object StoresState { 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 { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt index baae64b3c..379161ebf 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt @@ -142,7 +142,6 @@ object DownloadQueue { advance(app) } - /** Immediately records a failed download (e.g. from pre-flight storage check). */ fun recordFailed(context: Context, entry: DownloadEntry, error: String, retry: (() -> Unit)? = null) { val app = context.applicationContext val now = System.currentTimeMillis() @@ -196,7 +195,6 @@ object DownloadQueue { renumber() publishLocked() } - // A preflight retry rechecks storage and enqueues the real backend, never a running placeholder. if (retry != null) { retry(); return } StoresState.logLine("resumed \"${entry(key)?.name}\"") advance(app) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt index ce5afa91b..394164c4c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt @@ -47,7 +47,6 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { if (!quiet) StoresState.status[store] = app.getString(R.string.stores_sync_library) } val result = EpicLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } } - // Throttled means the cache is fresh, possibly written by another sync after this one read it. val fresh = if (result == EpicLibrary.SyncResult.Throttled) EpicLibrary.cached(app).map { EpicLibrary.toCatalogItem(app, it) } else cached StoresState.post { StoresState.status.remove(store) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt index 9332bb986..6a1e5f65c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt @@ -47,8 +47,6 @@ object GogAuth { Log.i(TAG, "signed in") } - fun storedToken(context: Context): String? = StoreAccounts.read(context, Store.GOG)?.optString("access_token", "")?.ifEmpty { null } - @JvmStatic @Synchronized fun validToken(context: Context): String? { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt index 59a370775..fe2153193 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt @@ -42,12 +42,9 @@ object GogBackend : StoreBackend { if (!quiet) StoresState.status[store] = app.getString(com.droiddeck.launcher.R.string.stores_sync_library) } val result = GogLibrary.sync(app, force, onStatus = { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }, onList = { list -> - // Mapped here, off the main thread: the listing paints the tab before any product is asked about. val items = list.map { GogLibrary.toCatalogItem(app, it) } - // An empty listing is not news: posting it would drop the cards already on screen. if (items.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], items) } }, onItems = { changed -> - // Only the products that came back are touched: a card already on screen keeps its object, and a product the grid has not seen yet is added where its title belongs. val items = changed.map { GogLibrary.toCatalogItem(app, it) } StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.patchItems(StoresState.library[store], items) } }) @@ -95,7 +92,6 @@ object GogBackend : StoreBackend { private fun fetchLogo(id: String): String? = runCatching { StoreNet.get("https://api.gog.com/v2/games/$id?locale=en-US")?.let { logoOf(JSONObject(it)) } }.getOrNull() - /** `images.logo2x` from the products API is an opaque banner, not a logo; the v2 `logo` link is a transparent PNG. */ internal fun logoOf(v2: JSONObject): String? = GogStoreCatalog.absolutize(v2.optJSONObject("_links")?.optJSONObject("logo")?.optString("href")).takeIf { it.startsWith("https://") } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java index 9b66b95de..0dafce41c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java @@ -62,7 +62,6 @@ public final class GogDownloadManager { public final long bytes; public List dependencies = new ArrayList<>(); public boolean dependenciesRead = false; - /** Language actually written, after the manifest's depot list resolved the request. Null for builds that have no language depots. */ public String language; Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; } } @@ -71,7 +70,6 @@ public final class GogDownloadManager { InstallException(String message) { super(message); } } - /** A failure of the offline-installer fallback; its partial files can be discarded. */ public static final class InstallerException extends InstallException { InstallerException(String message) { super(message); } } @@ -311,7 +309,6 @@ public final class GogDownloadManager { return StoreLanguage.depotMatchesResolved(listed, resolved); } - /** Language codes listed on this product's Windows gen2 depots, excluding `*`. */ public static List listLanguages(Context ctx, String gameId) { try { String token = GogAuth.validToken(ctx); @@ -497,12 +494,6 @@ public final class GogDownloadManager { } } - /** - * GOG's Windows installer is a 32-bit Inno Setup stub. On ARM64 it runs through - * Proton/FEX, where its path strings are corrupted before CreateFileW (observed on - * Wine 11 + FEX: "Path not found"). Extracting the same files with innoextract in - * the Linux guest avoids that path entirely. - */ private static void extractInstaller(Context ctx, File installPath, String setupName, Callback cb, AtomicBoolean cancelled) throws InstallException { String guestDir = AddedGames.INSTANCE.guestPath(ctx, installPath); if (guestDir == null || guestDir.isEmpty()) { @@ -531,11 +522,6 @@ public final class GogDownloadManager { InstallerPart(String url, String filename, long size) { this.url = url; this.filename = filename; this.size = size; } } - /** - * Every file of the first usable Windows installer. GOG lists a setup stub and its data parts as - * separate files, often without a filename; the parts are named after the stub so the installer - * finds them when launched (setup_<id>.exe, setup_<id>-1.bin, ...). - */ static List installerParts(JSONObject downloads, String gameId) { List out = new ArrayList<>(); if (downloads == null) return out; diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt index 0e76d2fab..8f48740ee 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt @@ -21,13 +21,9 @@ object GogLibrary { private const val LAST_SYNC_KEY = "library_synced_at" private const val SKIPPED_KEY = "library_skipped" private const val THROTTLE_MS = 6L * 60L * 60L * 1000L - // mediaType=1 leaves the movies and soundtracks out, hiddenFlag=0 the hidden ones. private const val LIST_URL = "https://embed.gog.com/account/getFilteredProducts?mediaType=1&hiddenFlag=0&sortBy=title" private const val LIST_MAX_PAGES = 100 - // The listing's image is a bare hash, and the CDN serves it only through Galaxy's own formatters: the - // vertical cover is the one card art it carries. The wide background only the per-game fetch knows. private const val COVER_SUFFIX = "_glx_vertical_cover.webp" - // The older account endpoint: owned ids only. The fallback for a listing that cannot be read. private const val OWNED_URL = "https://embed.gog.com/user/data/games" private val syncing = AtomicBoolean(false) @@ -62,28 +58,22 @@ object GogLibrary { val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn onStatus(app.getString(R.string.stores_sync_game_list)) val listing = ownedListing(token) - // The listing is the only source of titles: without it the owned ids still sync, just without the early paint. val listed: List = listing?.games ?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") } ?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable)) val named = listing != null StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids") - // A page that failed leaves games unnamed: they are still owned, so their cards and cached details stay. val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId } val cachedList = cached(app) val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS val heavy = force || cachedList.isEmpty() || stale val cachedIds = cachedList.mapTo(HashSet()) { it.gameId } - // A product the last sync found uninstallable stays out of the list while it is re-checked. val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty()) val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped } val merged = LinkedHashMap() - // The cache's own entry stays even when the game is re-fetched: its details beat the listing's. for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in skipped) merged[g.gameId] = g - // A game the sync has not reached keeps the listing's entry, so its card is there from the start. for (g in listed) if (g.gameId !in merged && g.gameId !in skipped) merged[g.gameId] = g - // Only products that were asked about are cached: a bare listing entry is asked about again. val detailed = HashSet(cachedIds) if (named) onList(sorted(merged)) if (idsToFetch.isEmpty()) { @@ -101,15 +91,12 @@ object GogLibrary { for ((idx, f) in futures.withIndex()) { val g = runCatching { f.get() }.getOrNull() if (g != null) { - // The cover the card already shows stays, so the wide art lands over it instead of over a gap. val filled = g.withCover(merged[g.gameId]?.verticalCover) merged[g.gameId] = filled; detailed.add(g.gameId); fetched++; skipped.remove(g.gameId); onItems(listOf(filled)) } if ((idx + 1) % 5 == 0) onStatus(app.getString(R.string.stores_sync_progress, idx + 1, futures.size)) - // Saved as it goes: a sync the user closes the app on still leaves a library to paint next launch. if ((idx + 1) % 20 == 0) saveCache(p, merged.values.filter { it.gameId in detailed }) } - // A product this round found uninstallable leaves when the round is over, not in the middle of it. for (id in skipped) merged.remove(id) val finalList = merged.values.toList() saveCache(p, finalList.filter { it.gameId in detailed }) @@ -132,11 +119,9 @@ object GogLibrary { var page = 1 var pages = 1 while (page <= pages && page <= LIST_MAX_PAGES) { - // A page that cannot be read ends the listing; the pages already read still name most of the library. val listed = StoreNet.get("$LIST_URL&page=$page", bearer = token, userAgent = GogAuth.GALAXY_UA)?.let { parseListing(it) } if (listed == null) { StoresState.logLine("GOG: listing page $page of $pages unreadable") - // The unread pages hold games too: without their ids those games would leave the library and the cache. owned.addAll(ownedIds(token) ?: return null) break } @@ -145,7 +130,6 @@ object GogLibrary { pages = listed.totalPages page++ } - // Not one page read: the caller falls back to the owned ids. return all.ifEmpty { null }?.let { Listing(it, owned) } } @@ -166,7 +150,6 @@ object GogLibrary { val games = ArrayList(arr.length()) for (i in 0 until arr.length()) { val o = arr.optJSONObject(i) ?: continue - // 1801418160 is GOG's own "Galaxy" entry, in every library. val id = o.optLong("id", 0L).toString() if (id == "0" || id == "1801418160") continue if (o.optBoolean("isMovie", false) || !o.optBoolean("isGame", true)) continue diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt index d4dcf1983..3896091e3 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt @@ -135,7 +135,6 @@ internal class ItchInstaller( private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long, handle: DownloadQueue.JobHandle) { - // A partial file is only resumed when it came from this exact upload build. val origin = File(target.path + ".from") val stamp = "$uploadId $version $expected" if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) { @@ -169,7 +168,6 @@ internal class ItchInstaller( if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over") var done = if (append) have else 0L val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected - // The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput. handle.progress(done, total) val buffer = ByteArray(128 * 1024) conn.inputStream.use { input -> @@ -293,7 +291,6 @@ internal class ItchInstaller( return if (path == root || path.startsWith(root + File.separator)) file else null } - // No JVM library reads RAR5; the runtime's bsdtar (libarchive) does. private fun unrar(archive: File, handle: DownloadQueue.JobHandle, kind: String) { if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, kind)) val binds = if (listOf(app.filesDir, app.cacheDir).any { work.absolutePath.startsWith(it.absolutePath + "/") }) null @@ -437,8 +434,6 @@ internal class ItchInstaller( FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } } } - // Streaming readers stop silently on zips whose data descriptors disagree with the local headers; - // the central directory is authoritative. private fun unzip(archive: File, handle: DownloadQueue.JobHandle) { val total = archive.length() var written = 0L diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt index d4a16c409..6f50a0303 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -10,7 +10,6 @@ import java.time.Instant internal object XboxCatalog { private val productId = Regex("[A-Z0-9]{12}") private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD") - /** Plans whose store offers advertise PC games. Library entitlements under any active plan count, as the backend resolves a PC package for them. */ private val pcPlans = mapOf( "CFQ7TTC0KGQ8" to "PC Game Pass", "CFQ7TTC0KHS0" to "Game Pass Ultimate", @@ -137,7 +136,6 @@ internal object XboxCatalog { private const val CLOUD_GAME_PASS = "29a81209-df6f-41fd-a528-2ae6b91f719c" private const val CLOUD_ALL = "ce573635-7c18-4d0c-9d68-90b932393470" - /** Microsoft's public cloud lists (catalog.gamepass.com "sigls"; ids as Better xCloud by redphx, MIT, uses them). */ fun cloudCatalog(market: String = Xodus.market()): XboxCloudCatalog = XboxCloudCatalog(cloudList(CLOUD_GAME_PASS, market), cloudList(CLOUD_ALL, market)) @@ -169,7 +167,6 @@ val CatalogItem.xboxPc: Boolean get() = extra["pc"] != "0" val CatalogItem.xboxCloud: Boolean get() = extra["cloud"] == "1" val CatalogItem.xboxCloudId: String get() = extra["cloudId"]?.takeIf { it.isNotEmpty() } ?: id -/** Microsoft lists console and PC editions as separate products; a cloud-only one with a PC game's title folds into that card. */ internal fun mergeEditions(games: List): List { fun key(title: String) = title.lowercase().replace(Regex("[^a-z0-9]+"), "") val cloudOnly = games.filter { !it.xboxPc && it.xboxCloud }.groupBy { key(it.title) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt index 1fa7bdfe5..66f2a1fc8 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt @@ -27,11 +27,6 @@ import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature -/** - * Microsoft's web player (xbox.com/play) in its own process and WebView data directory: the store sign-in - * clears its own web data after each attempt, so the player keeps a separate Microsoft session. Signing out of - * the store clears it ([clearWebSignIn]). - */ class XboxCloudActivity : ComponentActivity() { private var web: WebView? = null private var custom: View? = null @@ -61,7 +56,6 @@ class XboxCloudActivity : ComponentActivity() { loadWithOverviewMode = true userAgentString = chromeUserAgent(userAgentString) } - // No "X-Requested-With: " header: it marks the page as embedded in an app. if (WebViewFeature.isFeatureSupported(WebViewFeature.REQUESTED_WITH_HEADER_ALLOW_LIST)) WebSettingsCompat.setRequestedWithHeaderOriginAllowList(view.settings, emptySet()) Log.i(TAG, "UA ${view.settings.userAgentString}") @@ -107,7 +101,6 @@ class XboxCloudActivity : ComponentActivity() { intent.getStringExtra("url")?.takeIf { url -> runCatching { java.net.URI(url) }.getOrNull()?.let { it.scheme == "https" && it.host == "www.xbox.com" && it.userInfo == null && it.port == -1 } == true } ?: START_URL - // Without the WebView markers the page treats this as Chrome for Android. private fun chromeUserAgent(default: String): String = default.replace("; wv)", ")").replace(Regex(" Version/\\d+(\\.\\d+)*"), "") @@ -124,7 +117,6 @@ class XboxCloudActivity : ComponentActivity() { if (hasFocus) hideBars() } - // A controller's unused B arrives as BACK; ask before leaving so a stray press never ends a stream. override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { val fromController = event.source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt index 10b034eaa..1f8dd66e6 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt @@ -14,7 +14,6 @@ import java.io.File import java.net.HttpURLConnection import java.net.URL -/** Xbox games added to the Games tab to stream before (or without) installing them. */ object XboxCloudGames { class Entry(val id: String, val title: String, val poster: String?, val hero: String?) @@ -69,7 +68,6 @@ object XboxCloudGames { }.distinctBy { it.id } }.getOrDefault(emptyList()) - /** Games-tab rows for the added games that are not installed (an installed copy has its own row). */ fun rows(context: Context, installedIds: Set): List = entries.filter { it.id !in installedIds }.map { e -> val dir = artDir(context, e.id) val appId = rowId(e.id) @@ -100,10 +98,6 @@ object XboxCloudGames { private val prefetching = java.util.concurrent.atomic.AtomicBoolean(false) - /** - * Fetches the streaming tokens in the background while a cloud game is on screen (about 20 s), so Stream starts at - * once; both players take them from this process (the native one through XboxCloudTokenService). - */ fun prefetch(context: Context) { val app = context.applicationContext if (!prefetching.compareAndSet(false, true)) return @@ -116,7 +110,6 @@ object XboxCloudGames { }, "xcloud-prefetch").start() } - /** Streaming runs outside the Linux session; the session owns the screen, audio and controllers while it runs. */ fun stream(context: Context, id: String, title: String) { if (SessionState.running) { android.widget.Toast.makeText(context, "Close the Steam session to stream Xbox games.", android.widget.Toast.LENGTH_LONG).show() @@ -125,7 +118,6 @@ object XboxCloudGames { context.startActivity(XboxCloudPlayerActivity.playerIntent(context, id, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - /** Whether the signed-in library says this game streams (cached library; reads disk). */ fun streams(context: Context, id: String): Boolean = runCatching { StoresState.library[Store.XBOX]?.firstOrNull { it.id == id }?.let { return it.xboxCloud } val app = context.applicationContext diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt index 9b8d11c0d..354850932 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt @@ -43,7 +43,6 @@ import com.droiddeck.launcher.stores.StoresState import org.json.JSONObject import java.util.concurrent.Executors -/** Runs the WebRTC client page (tools/xcloud-player); signalling and tokens stay on this side of the bridge. */ class XboxCloudPlayerActivity : ComponentActivity() { private lateinit var root: FrameLayout private var web: WebView? = null @@ -57,7 +56,6 @@ class XboxCloudPlayerActivity : ComponentActivity() { @Volatile private var webUrl: String? = null @Volatile private var settings = XboxCloudSettings() @Volatile private var sessionSignals: XboxCloudSignalling? = null - /** The settings this stream started with (stream-start settings changed since then need a restart). */ @Volatile private var startedWith: XboxCloudSettings? = null private var menu: androidx.compose.ui.platform.ComposeView? = null @@ -106,7 +104,6 @@ class XboxCloudPlayerActivity : ComponentActivity() { allowContentAccess = false } view.setBackgroundColor(Color.BLACK) - // The Gamepad API only sees controllers while the WebView has input focus. view.isFocusable = true view.isFocusableInTouchMode = true view.addJavascriptInterface(Bridge(), "DDCloud") @@ -225,7 +222,6 @@ class XboxCloudPlayerActivity : ComponentActivity() { private fun isGuideKey(event: KeyEvent): Boolean = event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE - /** One press is the stream's Xbox button; two quick presses or a hold open the player menu. */ private fun guideKey(event: KeyEvent): Boolean { if (!isGuideKey(event)) return false if (event.action == KeyEvent.ACTION_DOWN) { @@ -256,7 +252,6 @@ class XboxCloudPlayerActivity : ComponentActivity() { web?.evaluateJavascript("window.__ddButton && window.__ddButton('Nexus', $value)", null) } - // Back opens the player menu so a stray press never ends a stream. override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (menu != null) { if (isGuideKey(event)) { @@ -274,7 +269,6 @@ class XboxCloudPlayerActivity : ComponentActivity() { if (event.action == KeyEvent.ACTION_UP) { if (notice == null) openMenu() else finish() } return true } - // Reporting controller buttons handled stops Android turning an unused one into a fallback key. val handled = super.dispatchKeyEvent(event) val fromController = event.source and android.view.InputDevice.SOURCE_GAMEPAD == android.view.InputDevice.SOURCE_GAMEPAD || event.source and android.view.InputDevice.SOURCE_JOYSTICK == android.view.InputDevice.SOURCE_JOYSTICK @@ -389,7 +383,6 @@ class XboxCloudPlayerActivity : ComponentActivity() { fun intent(context: Context, productId: String, title: String): Intent = Intent(context, XboxCloudPlayerActivity::class.java) .putExtra("product", productId.uppercase()).putExtra("title", title) - /** The player the settings pick: the native engine (frame generation) where it can run, else the web player. */ fun playerIntent(context: Context, productId: String, title: String): Intent { val native = XboxNativePlayerActivity.supported() && XboxCloudSettings.load(context).playerEngine == "native" return if (!native) intent(context, productId, title) else Intent(context, XboxNativePlayerActivity::class.java) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt index ca2120278..835a55166 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt @@ -6,7 +6,6 @@ import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL -/** Streaming-service tokens stay in memory only. Request shapes follow Greenlight / xal-node by unknownskl (MIT). */ internal object XboxCloudSession { class Region(val name: String, val baseUri: String, val isDefault: Boolean) @@ -22,7 +21,6 @@ internal object XboxCloudSession { @Volatile private var cached: Session? = null - /** One fetch at a time (about 20 s: two ticket exchanges); a second caller waits and gets the same tokens. */ @Synchronized fun get(app: Context, account: String): Session { cached?.takeIf { it.account == account && it.fresh }?.let { return it } @@ -39,10 +37,6 @@ internal object XboxCloudSession { fun clear() { cached = null; titleIds = null } - /** - * For the native player's own process: the main process's tokens (it keeps them, and fetches them ahead while a - * cloud game's page is open), handed over in memory through XboxCloudTokenService; null if that is not reachable. - */ fun fromMainProcess(app: Context, account: String, timeoutSeconds: Long = 90): Session? { cached?.takeIf { it.account == account && it.fresh }?.let { return it } val reply = XboxCloudTokenService.request(app, account, timeoutSeconds) ?: return null diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt index 7b8806a36..247b1b033 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt @@ -4,43 +4,41 @@ import android.content.Context import org.json.JSONObject data class XboxCloudSettings( - val resolution: String = "1080p", // 720p | 1080p | 1080p_hq - val region: String = "", // "" = Microsoft's choice - val language: String = "", // "" = the device's language - val codec: String = "default", // default | high | normal | low - val bitrateMbps: Int = 0, // 0 = no cap + val resolution: String = "1080p", + val region: String = "", + val language: String = "", + val codec: String = "default", + val bitrateMbps: Int = 0, val preventDrops: Boolean = false, - val statsLevel: Int = 0, // 0 off, 1 fps, 2 basic, 3 detailed, 4 full + val statsLevel: Int = 0, val statsLayout: String = "horizontal", - val statsCorner: String = "right", // right | left - val volume: Int = 100, // 100..300 % - val rumble: Int = 100, // 0..100 % + val statsCorner: String = "right", + val volume: Int = 100, + val rumble: Int = 100, val maxFps: Int = 0, val brightness: Int = 0, val contrast: Int = 0, val gamma: Float = 1f, val saturation: Int = 100, val sharpen: Int = 0, - val sharpenMode: String = "cas", // cas | usm + val sharpenMode: String = "cas", val deband: Int = 0, val fxaa: Boolean = false, val toon: Boolean = false, val crt: Boolean = false, val ntsc: Boolean = false, - val playerEngine: String = "web", // web | native (WebRTC library + DroidDeck's compositor, frame generation) - val frameGen: String = "ddfg", // native engine: off | ddfg (2x) | lsfg | lsfg3 | lsfg4 (needs Lossless.dll) - val fpsLimit: Int = 0, // native engine target: 0 = the screen's rate, else 30/45/60/120/144/165 - val fpsCustomOn: Boolean = false, // use fpsCustom instead of the presets - val fpsCustom: Int = 60, // 10..165 - val instantInput: Boolean = true, // native: send controller changes at once (else every 16 ms) - val smallBuffer: Boolean = false, // native: WebRTC shows frames as soon as decoded (no playout delay) - val fastDecoder: Boolean = true, // native: the hardware decoder's low-latency mode - val zeroCopy: Boolean = false, // native, frame generation off: decode straight onto the screen + val playerEngine: String = "web", + val frameGen: String = "ddfg", + val fpsLimit: Int = 0, + val fpsCustomOn: Boolean = false, + val fpsCustom: Int = 60, + val instantInput: Boolean = true, + val smallBuffer: Boolean = false, + val fastDecoder: Boolean = true, + val zeroCopy: Boolean = false, ) { - /** The native engine's frame-rate target (0 = the screen's own rate). */ val fpsTarget get() = if (fpsCustomOn) fpsCustom else fpsLimit - /** The service picks the stream resolution from the reported OS (Better xCloud's mapping). */ val osName get() = when (resolution) { "720p" -> "android"; "1080p_hq" -> "tizen"; else -> "windows" } fun toJson(): String = JSONObject() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt index 1131fb124..b4f9ab9cb 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt @@ -52,18 +52,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -/** Shared by the Xbox cloud settings page and the in-player menu; [live] changes reach the running stream. */ @Composable fun XboxCloudSettingsPanel( settings: XboxCloudSettings, regions: List, live: Boolean, - /** "stream", "picture" or "overlay" shows that section only; null shows all. */ section: String? = null, - /** The row to select when the panel opens (the last one used), or null for no initial selection. */ restoreRow: Int? = null, onRowFocused: (Int) -> Unit = {}, - /** The settings the running stream started with; rows that differ say they need a restart (null: no stream). */ startedWith: XboxCloudSettings? = null, onChange: (XboxCloudSettings) -> Unit, ) { @@ -78,7 +74,6 @@ fun XboxCloudSettingsPanel( } } -/** Gives each settings row its place in the page, so the last-used row can be selected again. */ private class PanelFocus { var next = 0 var count = 0 @@ -99,7 +94,6 @@ private fun PanelContent( startedWith: XboxCloudSettings?, onChange: (XboxCloudSettings) -> Unit, ) { - // Stream-start settings changed during a stream wait for a restart; back at the starting value, no note. fun pending(differs: (XboxCloudSettings) -> Boolean) = startedWith != null && differs(startedWith) if (section == null || section == "stream") Section("Stream", if (live) "Applies to the next stream." else "Applies when a stream starts.") { @@ -116,7 +110,6 @@ private fun PanelContent( "apply to the web engine.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp, bottom = 6.dp)) } - // The service picks the size from the reported device profile (the PC profile can send 1440p). Choice("Resolution", listOf("720p" to "720p (phone)", "1080p" to "Best (PC)", "1080p_hq" to "1080p HQ (TV)"), settings.resolution, pending { it.resolution != settings.resolution }) { onChange(settings.copy(resolution = it)) } Choice("Server region", listOf("" to "Automatic") + regions.map { it to it }, settings.region, @@ -176,7 +169,6 @@ private fun PanelContent( settings.zeroCopy) { onChange(settings.copy(zeroCopy = it)) } } - // "overlay-native": the native player's page (its sound has no volume boost). if (section == null || section == "overlay" || section == "overlay-native") Section("Overlay and sound", null) { Choice("Performance overlay", listOf(0 to "Off", 1 to "FPS only", 2 to "Basic", 3 to "Detailed", 4 to "Full"), settings.statsLevel) { onChange(settings.copy(statsLevel = it)) @@ -202,10 +194,6 @@ private fun Section(title: String, note: String?, content: @Composable ColumnSco } } -/** - * One settings row, driven by a controller: it takes focus as a whole (highlighted), Left / Right change its - * value, A / Enter selects; the − / + buttons and switches inside stay for touch only. - */ @Composable private fun SettingRow( onLeft: (() -> Unit)?, @@ -236,7 +224,6 @@ private fun SettingRow( if (action != null) { action(); true } else false } .focusable() - // A tap selects too, without a second focus stop (a clickable would be one). .pointerInput(Unit) { detectTapGestures { currentSelect?.invoke() } } .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -257,7 +244,6 @@ private fun Step(label: String, value: String, onMinus: () -> Unit, onPlus: () - } } -/** A value slider (touch) that a controller moves with Left / Right in steps of 1 (hold to repeat). */ @Composable private fun SliderRow(label: String, value: Int, min: Int, max: Int, onValue: (Int) -> Unit) { SettingRow(onLeft = { onValue((value - 1).coerceAtLeast(min)) }, onRight = { onValue((value + 1).coerceAtMost(max)) }, onSelect = null) { @@ -293,7 +279,6 @@ private fun Toggle(label: String, note: String, checked: Boolean, needsRestart: color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) } -/** One entry of the cloud players' menu list (highlighted when selected; A or a tap opens it). */ @Composable internal fun XboxMenuEntry(title: String, note: String?, focus: FocusRequester? = null, onFocused: () -> Unit = {}, onClick: () -> Unit) { val cs = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt index a5e7587af..8488de6e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt @@ -3,7 +3,6 @@ package com.droiddeck.launcher.stores.xbox import org.json.JSONArray import org.json.JSONObject -/** Request shapes follow Microsoft's web player as Greenlight (MIT) implements them. */ internal class XboxCloudSignalling( private val session: XboxCloudSession.Session, private val titleId: String, @@ -62,7 +61,6 @@ internal class XboxCloudSignalling( private fun version(min: Int, max: Int) = JSONObject().put("minVersion", min).put("maxVersion", max) - /** The exchange answers 204 until the console has replied. */ private fun poll(path: String): XboxCloudSession.Reply { repeat(60) { val reply = XboxCloudSession.request(session, "GET", path) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt index 03df4c19d..1568406ed 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt @@ -16,10 +16,6 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * Hands the main process's Xbox Cloud Gaming tokens to the native player's process (:xcloudnative) over Binder, so - * they stay in memory (not exported, same app only) and a stream does not wait for a second ~20 s token fetch. - */ class XboxCloudTokenService : Service() { private val worker = Executors.newSingleThreadExecutor() private val messenger = Messenger(Handler(Looper.getMainLooper()) { message -> @@ -42,7 +38,6 @@ class XboxCloudTokenService : Service() { private const val REQUEST = 1 private const val REPLY = 2 - /** Blocks until the main process answers (its tokens, or "error"), or returns null when it cannot be reached. */ fun request(context: Context, account: String, timeoutSeconds: Long): Bundle? { val app = context.applicationContext val done = CountDownLatch(1) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt index f969c20eb..c8b126519 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt @@ -14,7 +14,6 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str val name = "droiddeck-xbox-${UUID.randomUUID()}" private val generation = lifecycle(app, expectedGeneration) { it } private val server = LocalServerSocket(name) - // Some devices refuse the abstract socket from inside PRoot; the backend then falls back to this keyed loopback port. private val loopback = java.net.ServerSocket(0, 8, java.net.InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1))) val port: Int get() = loopback.localPort val key: String = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) } @@ -38,7 +37,6 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str return false } - /** Also written to the stores log: sign-in runs in its own process, where only that file reaches a shared log. */ private fun note(text: String) { android.util.Log.w("XboxCredentials", text) com.droiddeck.launcher.stores.StoreLogFiles.stamped(app, "Xbox credentials: $text") @@ -84,7 +82,6 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str } } } - /** Each connection gets its own thread; an accept error is logged and retried instead of silently ending the loop. */ private fun listen(route: String, accept: () -> S, serve: (S) -> Unit) = Thread({ var failures = 0 while (!closed) { @@ -101,7 +98,6 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str } }, "xbox-credentials-$route").start() - /** Logs requests that take 2 s or more, and the stage a request is still in after 8 s (the backend waits 30 s). */ private fun tracked(route: String, block: (step: (String) -> Unit) -> JSONObject): JSONObject { val started = android.os.SystemClock.elapsedRealtime() val stage = java.util.concurrent.atomic.AtomicReference("auth") @@ -183,7 +179,6 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str } } - /** The connecting socket must belong to this app's user; an unreadable table (newer Android) leaves the key as the check. */ private fun loopbackOwner(peerPort: Int): Boolean { val uid = android.os.Process.myUid() val rows = listOf("/proc/net/tcp", "/proc/net/tcp6").mapNotNull { runCatching { java.io.File(it).readLines().drop(1) }.getOrNull() }.flatten() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt index b065e8baf..c6e62596b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt @@ -4,15 +4,12 @@ import android.content.Context import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreAccounts import com.droiddeck.launcher.stores.StoresState -/** Keeps the account's Xbox gamertag in its record so the Stores page can show it instead of the sign-in email. */ internal object XboxGamertag { - /** `xodus-cli profile` allows itself 45 s, and starting it through PRoot adds to that. */ private const val TIMEOUT_SECONDS = 75L private val triedGeneration = java.util.concurrent.atomic.AtomicReference(null) internal fun valid(value: String): Boolean = value.isNotEmpty() && value.length <= 64 && value.none { it.isISOControl() } - /** Reads the gamertag with `xodus-cli profile` and stores it; false when Xbox didn't return one. */ fun refresh(app: Context, credentialGeneration: String? = null): Boolean = try { val gamertag = Xodus.run(app, listOf("profile", "--json"), timeout = TIMEOUT_SECONDS, credentialGeneration = credentialGeneration) .optString("gamertag").trim() @@ -29,7 +26,6 @@ internal object XboxGamertag { false } - /** Fetches a missing gamertag once per signed-in account, on its own thread so it never holds up a library sync. */ fun ensure(app: Context) { Thread({ runCatching { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt index 39840b260..530eca4e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt @@ -55,8 +55,6 @@ class XboxLoginActivity : ComponentActivity() { Thread({ if (closed) return@Thread try { - // A record without a username is left from a sign-in that never finished; starting clean - // also avoids reading it back with an older, slow Keystore key. progress("preparing the sign-in", "Preparing Microsoft sign-in…") val leftover = StoreAccounts.read(applicationContext, Store.XBOX) if (leftover != null && leftover.optString("username").isBlank()) { @@ -73,7 +71,6 @@ class XboxLoginActivity : ComponentActivity() { Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process -> child = process if (closed) return@use - // StoresState.logLine writes no file in this process, so the reason would miss the shared log. Xodus.diagnostics(process) { line -> logStep(line) line.removePrefix("NEXUS_CREDENTIAL_ERROR_V1 ").takeIf { it != line && it !in codes && codes.size < 6 }?.let(codes::add) @@ -164,7 +161,6 @@ class XboxLoginActivity : ComponentActivity() { private fun progress(name: String, text: String) { step = name; show(text) } - /** Written to the stores log, which the shared log zip carries; the main process's log helpers don't run in this process. */ private fun logStep(line: String) { val text = StoreLog.redactLine("Xbox sign-in: $line") Log.i(TAG, text) @@ -230,7 +226,6 @@ class XboxLoginActivity : ComponentActivity() { companion object { private const val TAG = "XboxLogin" - /** Our own check messages are safe to show; anything else is named by its type, never its text, which may hold sign-in data. */ internal fun failure(e: Exception): String { val message = e.message.orEmpty() return if (e is IllegalStateException && message.isNotBlank() && message != "Check failed." && message.length <= 160) message diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt index 661023d54..e42c2ebaf 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt @@ -4,14 +4,8 @@ import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent -/** - * Controller state for the native cloud player, from Android key and motion events (no Gamepad API). Button - * bits follow the xCloud input report (Greenlight's packet.ts): Nexus 2, Menu 4, View 8, A 16, B 32, X 64, - * Y 128, D-pad 256..2048, shoulders 4096 / 8192, stick clicks 16384 / 32768. - */ internal class XboxNativePad { data class State(val buttons: Int, val lx: Float, val ly: Float, val rx: Float, val ry: Float, val lt: Float, val rt: Float) { - /** Greenlight's rule: any button change, a stick moved past 0.08 or held past 0.15, a trigger past 0.01. */ fun differsFrom(o: State): Boolean = buttons != o.buttons || listOf(lx to o.lx, ly to o.ly, rx to o.rx, ry to o.ry).any { (a, b) -> Math.abs(a - b) > 0.08f || Math.abs(a) > 0.15f } || Math.abs(lt - o.lt) > 0.01f || Math.abs(rt - o.rt) > 0.01f @@ -22,15 +16,12 @@ internal class XboxNativePad { @Volatile private var lx = 0f; @Volatile private var ly = 0f @Volatile private var rx = 0f; @Volatile private var ry = 0f @Volatile private var lt = 0f; @Volatile private var rt = 0f - /** Called after any change (instant controller input). */ @Volatile var onChanged: (() -> Unit)? = null - /** Buttons pressed on behalf of the app (the menu's Xbox button), OR-ed in. */ @Volatile var overrideButtons = 0 fun snapshot() = State(buttons or hatButtons or overrideButtons, lx, ly, rx, ry, lt, rt) - /** True when the event was a controller button this pad tracks. */ fun onKey(event: KeyEvent): Boolean { val bit = when (event.keyCode) { KeyEvent.KEYCODE_BUTTON_A -> 16 @@ -47,7 +38,6 @@ internal class XboxNativePad { KeyEvent.KEYCODE_BUTTON_THUMBR -> 32768 KeyEvent.KEYCODE_BUTTON_START -> 4 KeyEvent.KEYCODE_BUTTON_SELECT -> 8 - // Digital triggers on pads without analog axes. KeyEvent.KEYCODE_BUTTON_L2 -> { lt = if (event.action == KeyEvent.ACTION_DOWN) 1f else 0f; onChanged?.invoke(); return true } KeyEvent.KEYCODE_BUTTON_R2 -> { rt = if (event.action == KeyEvent.ACTION_DOWN) 1f else 0f; onChanged?.invoke(); return true } else -> return false @@ -57,7 +47,6 @@ internal class XboxNativePad { return true } - /** True when the event came from a joystick (sticks, triggers, hat). */ fun onMotion(event: MotionEvent): Boolean { if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK || event.action != MotionEvent.ACTION_MOVE) return false val device = event.device diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt index 1333be117..2125bf802 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt @@ -56,13 +56,6 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * Native Xbox Cloud Gaming player (opt-in, Android 10+): Google's WebRTC library instead of the web page, the - * xCloud channels ported from Greenlight (XboxNativeProtocol), controller from Android input events, and the - * picture through DroidDeck's own compositor (XboxNativeVideo) so DDFG or LSFG can generate frames. Runs in - * its own process (:xcloudnative): that compositor instance is never the Linux session's. - * Sign-in and session requests are the web player's (XboxCloudSession, XboxCloudSignalling). - */ class XboxNativePlayerActivity : ComponentActivity() { private lateinit var root: FrameLayout private lateinit var surfaceView: SurfaceView @@ -79,9 +72,7 @@ class XboxNativePlayerActivity : ComponentActivity() { @Volatile private var signalling: XboxCloudSignalling? = null @Volatile private var settings = XboxCloudSettings() @Volatile private var closed = false - /** The stream reached the screen at least once (a later failure is a lost connection, not a failed start). */ @Volatile private var connectedOnce = false - /** The session was ended because the player stayed in the background (Android freezes the app soon after). */ @Volatile private var endedInBackground = false private var backgroundEnd: Runnable? = null private var notice: androidx.compose.ui.platform.ComposeView? = null @@ -93,14 +84,8 @@ class XboxNativePlayerActivity : ComponentActivity() { private var menuPage by androidx.compose.runtime.mutableStateOf("") private var lastMainEntry = 0 private val lastRow = mutableMapOf() - /** The settings this stream started with (stream-start settings changed since then need a restart). */ private var startedWith: XboxCloudSettings? = null - /** - * The picture's route right now: true = zero-copy (decoder straight onto the screen), false = WebRTC's texture - * through the Vulkan compositor. Zero-copy whenever it is chosen and frame generation is off; switched live. - */ @Volatile private var zeroCopy = false - /** The SurfaceView's surface while it exists (UI thread). */ private var screen: android.view.Surface? = null private var screenW = 0 private var screenH = 0 @@ -108,7 +93,6 @@ class XboxNativePlayerActivity : ComponentActivity() { @Volatile private var decoders: org.webrtc.DroidDeckLowLatencyDecoderFactory? = null private var lastZeroCopyFrames = 0L private var lastSubmitted = 0L - /** The release of the Guide press that opened the menu must not close it again. */ private var swallowGuideUp = false override fun onCreate(savedInstanceState: Bundle?) { @@ -138,7 +122,6 @@ class XboxNativePlayerActivity : ComponentActivity() { override fun surfaceCreated(holder: SurfaceHolder) {} override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { screen = holder.surface; screenW = width; screenH = height - // Zero-copy: the decoder owns this surface (back on it after a stand-in); the Vulkan compositor must not connect to it. if (zeroCopy) { decoders?.setZeroCopySurface(holder.surface); return } @Suppress("DEPRECATION") nativeVideo.setSurface(holder.surface, width, height, windowManager.defaultDisplay.refreshRate) @@ -166,12 +149,10 @@ class XboxNativePlayerActivity : ComponentActivity() { webPlayer = true) } - /** One popup at a time, in the app's dialog style; A or Enter picks the selected button, Back closes the player. */ private fun showNotice(title: String, text: String, webPlayer: Boolean) = runOnUiThread { if (notice != null || isFinishing || isDestroyed) return@runOnUiThread closeMenu() show("") - // The stream is over: stale numbers must not stay on screen. overlayOn = false overlay.visibility = android.view.View.GONE val view = androidx.compose.ui.platform.ComposeView(this) @@ -207,13 +188,9 @@ class XboxNativePlayerActivity : ComponentActivity() { root.addView(view, FrameLayout.LayoutParams(-1, -1)) } - // ---- background ---- - override fun onStop() { super.onStop() if (closed || isChangingConfigurations || signalling == null || endedInBackground) return - // Android freezes a background app about a minute after it leaves the screen; the connection then dies - // without a goodbye. End the session cleanly before that and offer a restart on return. log("in the background") val end = Runnable { backgroundEnd = null; endInBackground() } backgroundEnd = end @@ -243,15 +220,12 @@ class XboxNativePlayerActivity : ComponentActivity() { runCatching { pc?.close() } } - // ---- session ---- - private fun run() { val account = com.droiddeck.launcher.stores.StoreAccounts.signedInAs(applicationContext, com.droiddeck.launcher.stores.Store.XBOX) ?: error("Sign in to the Xbox store first.") val requested = intent.getStringExtra("product").orEmpty() val cached = XboxLibraryCache.read(applicationContext, account, Xodus.market())?.games?.firstOrNull { it.id == requested } val product = cached?.extra?.get("cloudId")?.takeIf { it.isNotEmpty() } ?: requested - // The main process's tokens when it has them (no second ~20 s fetch); else this process fetches its own. val shared = runCatching { XboxCloudSession.fromMainProcess(applicationContext, account) } .onFailure { log("tokens from the main process: ${it.message?.take(160)}") }.getOrNull() log(if (shared != null) "tokens from the main process" else "tokens fetched here") @@ -280,7 +254,6 @@ class XboxNativePlayerActivity : ComponentActivity() { if (closed) return show("Connecting…") - // Small network buffer: no playout delay, frames go to the decoder and screen as soon as they are complete. val trials = if (settings.smallBuffer) "WebRTC-ForcePlayoutDelay/min_ms:0,max_ms:0/WebRTC-ZeroPlayoutDelay/min_pacing:0ms,max_decode_queue_size:8/" else "" PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(applicationContext) .setFieldTrials(trials).createInitializationOptions()) @@ -290,7 +263,6 @@ class XboxNativePlayerActivity : ComponentActivity() { surfaceView.holder.surface.takeIf { it.isValid } } else null if (zeroSurface != null) { - // The compositor may already hold the screen; it must let go before the decoder takes it. val ready = CountDownLatch(1) runOnUiThread { zeroCopy = true; video?.setSurface(null, 0, 0, 60f); ready.countDown() } ready.await(2, TimeUnit.SECONDS) @@ -336,7 +308,6 @@ class XboxNativePlayerActivity : ComponentActivity() { return reply } - /** Opus stereo (Greenlight), H.264 profile order (Main first unless the user picked one) and the bitrate cap. */ private fun tweakOffer(sdp: String): String { var out = sdp.replace("useinbandfec=1", "useinbandfec=1; stereo=1") val prefix = when (settings.codec) { "low" -> "420"; "normal" -> "42e"; else -> "4d" } @@ -360,7 +331,6 @@ class XboxNativePlayerActivity : ComponentActivity() { return out } - /** The server's candidates, plus the IPv4 behind a Teredo address (Greenlight's ice.ts / teredo.ts). */ private fun addRemoteCandidate(peer: PeerConnection, c: JSONObject) { val line = c.optString("candidate").removePrefix("a=") if (line.isEmpty() || line == "end-of-candidates") return @@ -410,11 +380,9 @@ class XboxNativePlayerActivity : ComponentActivity() { private fun attach(receiver: RtpReceiver) = synchronized(this) { val track = receiver.track() - // Always attached: in zero-copy WebRTC delivers no frames, and after a live switch back they flow again. if (track is VideoTrack && !videoAttached) { videoAttached = true; video?.let { track.addSink(it) }; log("video track") } } - /** LSFG needs the Lossless.dll and its shader cache built; otherwise DDFG. */ private fun effectiveFrameGen(): String { if (!XboxNativeVideo.isLsfg(settings.frameGen)) return settings.frameGen val status = com.droiddeck.launcher.gpu.Lossless.sync(applicationContext) @@ -439,8 +407,6 @@ class XboxNativePlayerActivity : ComponentActivity() { override fun onSetFailure(error: String) = done(Result.failure(IllegalStateException(error))) } - // ---- input, rumble, overlay ---- - private var guideDown = false private var guideDownAt = 0L private var lastGuideUp = 0L @@ -454,7 +420,6 @@ class XboxNativePlayerActivity : ComponentActivity() { override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (menu != null) { - // Guide closes the menu; Back goes up one page, then closes it. if (isGuide(event)) { if (event.action == KeyEvent.ACTION_UP) { if (swallowGuideUp) swallowGuideUp = false else closeMenu() }; return true } if (event.keyCode == KeyEvent.KEYCODE_BACK) { if (event.action == KeyEvent.ACTION_UP) { if (menuPage.isNotEmpty()) menuPage = "" else closeMenu() } @@ -462,7 +427,6 @@ class XboxNativePlayerActivity : ComponentActivity() { } return super.dispatchKeyEvent(event) } - // Guide / the Input Controls menu button: once = the Xbox button, twice or held = the player menu. if (isGuide(event)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { val pending = pendingNexus @@ -482,7 +446,6 @@ class XboxNativePlayerActivity : ComponentActivity() { return super.dispatchKeyEvent(event) } - // With the menu open, sticks and the hat go to Android unhandled, which turns them into D-pad keys for the menu. override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean = (menu == null && pad.onMotion(event)) || super.dispatchGenericMotionEvent(event) @@ -495,7 +458,6 @@ class XboxNativePlayerActivity : ComponentActivity() { runCatching { vibrator.vibrate(VibrationEffect.createOneShot(durationMs.coerceAtMost(1000).toLong(), amplitude)) } } - /** The overlay is remembered with the other cloud settings (on = overlay level above 0). */ @Volatile private var overlayOn = false private var lastJitterDelay = 0.0 @@ -507,12 +469,6 @@ class XboxNativePlayerActivity : ComponentActivity() { private var lastLost = 0.0 private var lastReceived = 0.0 - /** - * The overlay, at the web player's levels and layout (settings statsLevel / statsLayout): 1 fps and frame - * generation base -> shown; 2 + resolution, ping; 3 + bitrate, jitter buffer, decode time, packet loss; 4 + frame - * generation's added delay, the estimated picture delay (one-way network + buffer + decode + frame generation + - * one screen refresh), codec and server. - */ private fun updateOverlay() { val level = settings.statsLevel if (!overlayOn || level <= 0) return @@ -549,7 +505,6 @@ class XboxNativePlayerActivity : ComponentActivity() { (m["currentRoundTripTime"] as? Number)?.toDouble()?.let { rttMs = it * 1000 } } } - // Zero-copy: WebRTC never sees a decoded frame, so size and decode time come from the decoder itself. if (zeroCopy) decoders?.let { d -> d.videoSize()?.let { size = it } val ms = d.takeDecodeMs() @@ -561,8 +516,6 @@ class XboxNativePlayerActivity : ComponentActivity() { val fgGpuMs = if (fgOn && fg!![5] > 0f) fg[5].toDouble() else 0.0 @Suppress("DEPRECATION") val refreshMs = 1000.0 / windowManager.defaultDisplay.refreshRate val total = (if (rttMs >= 0) rttMs / 2 else 0.0) + bufferMs + decodeMs + fgWaitMs + fgGpuMs + refreshMs - // What reaches the screen: the decoder's own count in zero-copy, the presented rate with frame generation, - // else the frames handed to the compositor (a frame-rate target drops some of WebRTC's decoded frames). val submitted = video?.frames ?: 0L val handed = (submitted - lastSubmitted).toInt().also { lastSubmitted = submitted } val shownFps = when { @@ -591,9 +544,6 @@ class XboxNativePlayerActivity : ComponentActivity() { } } - // ---- menu ---- - - /** The player menu (as the web player's): Resume, Xbox menu, Frame generation, Stream quality, overlay, Leave. */ private fun openMenu() { if (menu != null) return pad.reset() @@ -607,7 +557,6 @@ class XboxNativePlayerActivity : ComponentActivity() { val entries = androidx.compose.runtime.remember { List(7) { androidx.compose.ui.focus.FocusRequester() } } val choices = androidx.compose.runtime.remember { List(5) { androidx.compose.ui.focus.FocusRequester() } } androidx.compose.runtime.LaunchedEffect(menuPage) { - // Wait a frame so the entries are attached before one of them takes the selection. androidx.compose.runtime.withFrameNanos { } runCatching { if (menuPage.isEmpty()) entries[lastMainEntry.coerceIn(0, 6)].requestFocus() @@ -615,8 +564,6 @@ class XboxNativePlayerActivity : ComponentActivity() { } } val cs = androidx.compose.material3.MaterialTheme.colorScheme - // A full-size box first: the root of a ComposeView gets fixed full-screen constraints, so the panel's - // width only holds inside it (as the web player's menu does). androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.fillMaxSize()) { androidx.compose.foundation.layout.Column( androidx.compose.ui.Modifier.fillMaxHeight().width(400.dp).background(cs.surface.copy(alpha = 0.95f)) @@ -695,11 +642,6 @@ class XboxNativePlayerActivity : ComponentActivity() { view.requestFocus() } - /** - * Applies a settings change from any menu page at once: frame generation, the frame-rate target, zero-copy, instant - * input and the overlay. Resolution, region, language, codec, bitrate, the network buffer, the decoder mode and the - * engine belong to the cloud session and need a restart (the page says so). - */ private fun applyLive(before: XboxCloudSettings, changed: XboxCloudSettings) { settings = changed XboxCloudSettings.save(this, changed) @@ -709,10 +651,6 @@ class XboxNativePlayerActivity : ComponentActivity() { applyOverlay() } - /** - * Zero-copy draws every decoded frame straight onto the screen, so it is used only when nothing has to touch the - * frames: frame generation off and no frame-rate target below the stream's 60 fps (the compositor drops frames for that). - */ private fun zeroCopyWanted(s: XboxCloudSettings) = s.zeroCopy && s.frameGen == XboxNativeVideo.FG_OFF && (s.fpsTarget == 0 || s.fpsTarget >= 60) @@ -720,7 +658,6 @@ class XboxNativePlayerActivity : ComponentActivity() { overlayOn = settings.statsLevel > 0 overlay.visibility = if (overlayOn) android.view.View.VISIBLE else android.view.View.GONE overlay.textSize = 12f - // Top left keeps clear of the clock Xbox draws top right while its guide is open. val left = settings.statsCorner == "left" overlay.layoutParams = (overlay.layoutParams as FrameLayout.LayoutParams).apply { gravity = Gravity.TOP or (if (left) Gravity.START else Gravity.END) @@ -729,7 +666,6 @@ class XboxNativePlayerActivity : ComponentActivity() { overlay.gravity = if (left) Gravity.START else Gravity.END } - /** Stream-start settings need a new cloud session: end this one, then open the player again. */ private fun restartStream() { val signals = signalling closed = true @@ -756,10 +692,6 @@ class XboxNativePlayerActivity : ComponentActivity() { worker.execute { video?.setFrameGeneration(effectiveFrameGen()) } } - /** - * Live route switch (UI thread): zero-copy when chosen and frame generation is off, else the compositor. - * The screen surface takes one producer at a time, so the old one lets go before the new one connects. - */ private fun applyRoute() { val d = decoders ?: return val want = zeroCopyWanted(settings) @@ -783,10 +715,6 @@ class XboxNativePlayerActivity : ComponentActivity() { video?.setSurface(target, screenW, screenH, windowManager.defaultDisplay.refreshRate) } - /** - * Ask Android for the display mode whose refresh rate fits the target (at least the target when possible, - * else the fastest; the fastest for "the screen's rate"), so generated frames are not held back at 60 Hz. - */ private fun applyDisplayRate(target: Int) { @Suppress("DEPRECATION") val display = windowManager.defaultDisplay val current = display.mode @@ -799,10 +727,6 @@ class XboxNativePlayerActivity : ComponentActivity() { private val rehideBars = Runnable { if (window.decorView.hasWindowFocus()) hideBars() } - /** - * Bars that come back while the player has focus (return from recents or another app, a swipe) are hidden - * again after a moment; focus alone missed the return from the background (10-24). - */ private fun watchBars() { androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets -> if (insets.isVisible(WindowInsetsCompat.Type.navigationBars()) || insets.isVisible(WindowInsetsCompat.Type.statusBars())) { @@ -844,11 +768,9 @@ class XboxNativePlayerActivity : ComponentActivity() { } companion object { - /** How long a stream may stay in the background (Android froze the app ~70 s after it left the screen, 10-24). */ private const val BACKGROUND_GRACE_MS = 45_000L private val FG_MODES = listOf(XboxNativeVideo.FG_OFF, XboxNativeVideo.FG_DDFG, XboxNativeVideo.FG_LSFG, XboxNativeVideo.FG_LSFG3, XboxNativeVideo.FG_LSFG4) - /** True when the native engine can run here (ImageReader GPU buffers need Android 10). */ fun supported(): Boolean = Build.VERSION.SDK_INT >= 29 } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt index 348079f52..cb44b43fe 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt @@ -1,3 +1,4 @@ +// The xCloud data-channel protocol is ported from Greenlight by UnknownSKL (Jim Kroon), MIT; see tools/xcloud-player/NOTICE.md. package com.droiddeck.launcher.stores.xbox import org.json.JSONObject @@ -11,12 +12,6 @@ import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit -/** - * The xCloud data-channel protocol for the native player, ported from Greenlight's player (UnknownSKL, MIT; - * tools/xcloud-player/greenlight/lib/channel): chat / control / input / message channels, the message - * handshake, authorization and gamepad announcements, and the binary input reports. The controller state - * comes from Android input events ([pad]); rumble reports come back through [onRumble]. - */ internal class XboxNativeProtocol( pc: PeerConnection, private val pad: XboxNativePad, @@ -81,12 +76,10 @@ internal class XboxNativeProtocol( "HandshakeAck" -> { handshakeDone = true log("handshake done") - // Greenlight's order: authorization (announces controller 0 and removes it), input start, config. send(control, JSONObject().put("message", "authorizationRequest").put("accessKey", "4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E").toString()) gamepadChanged(0, true); gamepadChanged(0, false) startInput() sendConfig() - // Our controller, after the authorization's add/remove. gamepadChanged(0, true) send(control, JSONObject().put("message", "videoKeyframeRequested").put("ifrRequested", true).toString()) } @@ -101,7 +94,6 @@ internal class XboxNativeProtocol( complete(data.optString("id"), "") onServerDisconnect() } - // The game's own dialogs: accept (Greenlight shows a confirm; the native player has no page). "/streaming/systemUi/messages/ShowMessageDialog" -> complete(data.optString("id"), JSONObject().put("Result", 0)) else -> {} } @@ -134,18 +126,14 @@ internal class XboxNativeProtocol( } private fun startInput() { - // Client metadata: max touch points. send(input, header(8, 15).apply { put(1.toByte()); flip() }) loop = timer.scheduleWithFixedDelay({ runCatching { tick() } }, 16, 16, TimeUnit.MILLISECONDS) } - /** Instant controller input: send a report now if the state changed (the 16 ms loop stays as the heartbeat). */ fun inputChanged() { - // At most one extra report every 4 ms (stick motion arrives far faster than that). if (handshakeDone && loop != null && System.nanoTime() - lastSentAt > 4_000_000L) timer.execute { runCatching { tick() } } } - /** Greenlight's queue: a gamepad frame when the state changed (sticks past a small threshold) or every 33 ms. */ @Synchronized private fun tick() { val state = pad.snapshot() @@ -154,13 +142,13 @@ internal class XboxNativeProtocol( if (!changed && now - lastSentAt < 33_000_000L) return lastSent = state; lastSentAt = now val packet = header(2, 14 + 1 + 23).apply { - put(1.toByte()) // one frame - put(0.toByte()) // gamepad index + put(1.toByte()) + put(0.toByte()) putShort(state.buttons.toShort()) putShort(axis(state.lx)); putShort(axis(-state.ly)); putShort(axis(state.rx)); putShort(axis(-state.ry)) putShort(trigger(state.lt)); putShort(trigger(state.rt)) - putInt(1) // physical physicality - order(ByteOrder.BIG_ENDIAN); putInt(1); order(ByteOrder.LITTLE_ENDIAN) // virtual physicality (Greenlight writes it big-endian) + putInt(1) + order(ByteOrder.BIG_ENDIAN); putInt(1); order(ByteOrder.LITTLE_ENDIAN) flip() } send(input, packet) @@ -171,7 +159,7 @@ internal class XboxNativeProtocol( private fun onInputData(buffer: DataChannel.Buffer) { val data = buffer.data.order(ByteOrder.LITTLE_ENDIAN) - if (data.remaining() < 13 || (data.get(0).toInt() and 0xFF) != 128) return // vibration report + if (data.remaining() < 13 || (data.get(0).toInt() and 0xFF) != 128) return val strong = (data.get(4).toInt() and 0xFF) / 100f val weak = (data.get(5).toInt() and 0xFF) / 100f val duration = data.getShort(8).toInt() and 0xFFFF diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt index dc3657dbe..193f4e58b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt @@ -20,13 +20,6 @@ import org.webrtc.VideoSink import java.io.File import java.util.ArrayDeque -/** - * The native player's picture: WebRTC's hardware-decoded frames are drawn (GL, WebRTC's EglRenderer) into an - * ImageReader whose images are GPU-shareable HardwareBuffers, and each one goes to DroidDeck's own compositor in - * stream mode (WaylandCompositor.nativeStreamFrame) as the whole scene, where DroidDeck's frame generation (DDFG, - * or LSFG with the Lossless.dll) multiplies it. The player runs in its own process (:xcloudnative), so this - * compositor is never the one a Linux session uses. Android 10+ (ImageReader usage flags). - */ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, private val log: (String) -> Unit) : VideoSink { companion object { const val FG_OFF = "off" @@ -40,7 +33,6 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p fun modeName(mode: String) = when (mode) { FG_DDFG -> "DDFG 2×"; FG_LSFG -> "LSFG 2×"; FG_LSFG3 -> "LSFG 3×"; FG_LSFG4 -> "LSFG 4×"; else -> "Off" } - /** LSFG can run: the Lossless.dll was imported (or found in Steam) and its shader cache built. */ fun lsfgReady(context: Context) = Lossless.state(context).ready private const val SCALE_FIT = 1 private const val ALIGN_CENTER = 0 @@ -53,19 +45,16 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p private var reader: ImageReader? = null private var readerW = 0 private var readerH = 0 - /** Images the compositor may still read: the oldest goes back to the reader once two newer ones arrived. */ private val held = ArrayDeque() private var surface: Surface? = null private var attached = false @Volatile private var frameGen = FG_OFF @Volatile private var refreshHz = 60f - /** The frame-rate target (0 = the screen's rate): the frame-gen pacer's cap, or a limit on stream frames without it. */ @Volatile private var fpsTarget = 0 private var nextDueNs = 0L @Volatile var frames = 0L private set - /** The screen: the compositor presents to it (null detaches, e.g. for zero-copy or when the surface goes). */ fun setSurface(target: Surface?, width: Int, height: Int, displayRefreshHz: Float) = synchronized(lock) { refreshHz = displayRefreshHz if (target == null) { @@ -75,7 +64,6 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p } surface = target val runtime = File(context.cacheDir, "xcloud-wayland").apply { mkdirs() } - // System Vulkan driver; this process's compositor shows only the stream. val started = CompositorHost.startOrAttach(context, target, runtime.path, null, null, context.applicationInfo.nativeLibraryDir, width, height, displayRefreshHz, 0) if (started) { @@ -89,10 +77,6 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p applyFrameGen() } - /** - * Gives the screen surface up and waits until the compositor has really disconnected from it: the window request - * is applied on the compositor thread at its next tick, and the decoder (zero-copy) can only connect after that. - */ private fun letGo() { WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) CompositorHost.detach(surface) @@ -100,13 +84,11 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p if (!WaylandCompositor.nativeWaitSurface(1000)) log("compositor did not let go of the screen within 1 s") } - /** Frame-rate target, 0 = the screen's rate. Live. */ fun setFpsTarget(fps: Int) { fpsTarget = fps synchronized(lock) { if (attached) applyFrameGen() } } - /** off, ddfg (2x) or lsfg / lsfg3 / lsfg4 (needs the Lossless.dll). Live. */ fun setFrameGeneration(mode: String) { frameGen = mode synchronized(lock) { if (attached) applyFrameGen() } @@ -137,8 +119,6 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p synchronized(lock) { if (r !== reader) { image.close(); return } val buffer = image.hardwareBuffer - // Without frame generation a target below the stream's rate drops stream frames evenly: each frame has a - // due time one interval after the last one's, so 30 of 60 frames (or 45: three of four) reach the screen. val now = System.nanoTime() val target = fpsTarget val skip = frameGen == FG_OFF && target in 1 until 60 && run { @@ -154,7 +134,6 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p } private fun applyFrameGen() { - // The pacer caps generated output at this rate: the user's target, else the screen's. val cap = if (fpsTarget > 0) fpsTarget.toFloat() else refreshHz val mode = when { frameGen == FG_DDFG -> FrameGen.Mode(FrameGen.ENGINE_DDFG, 2) @@ -165,7 +144,6 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p log("native frame generation: $frameGen, target ${if (fpsTarget > 0) fpsTarget else "screen"} fps" + (problem?.let { " ($it)" } ?: "")) } - /** {planned, planned, sourceFps, presentedFps, thermal, -1}, or null while nothing is shown. */ fun stats(): FloatArray? = synchronized(lock) { if (attached) WaylandCompositor.nativeFrameGenStats() else null } fun frameGenProblem(): Int = synchronized(lock) { if (attached) WaylandCompositor.nativeFrameGenProblem() else -1 } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt index 5d6160c44..f803d0641 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt @@ -154,7 +154,6 @@ internal object Xodus { const val SIGN_IN_REJECTED = "Microsoft content licensing rejected sign-in \\(HTTP 401\\)\\. Sign in again in Settings\\." - /** Passes the backend's known stderr diagnostics to [report]; other stderr text never leaves the process. */ fun diagnostics(child: Child, report: (String) -> Unit = { com.droiddeck.launcher.stores.StoresState.logLine("Xbox: $it") }): Thread = Thread({ runCatching { child.process.errorStream.bufferedReader().useLines { lines -> lines.forEach { line -> diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncPrefs.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncPrefs.kt new file mode 100644 index 000000000..a0b1a63ce --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncPrefs.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.sync + +import android.content.Context +import java.security.SecureRandom + +object SyncPrefs { + const val RUN_WHILE_OPEN = "open" + const val RUN_BACKGROUND = "background" + + private fun prefs(context: Context) = context.getSharedPreferences("syncthing", Context.MODE_PRIVATE) + + fun enabled(context: Context): Boolean = prefs(context).getBoolean("enabled", false) + fun setEnabled(context: Context, on: Boolean) { prefs(context).edit().putBoolean("enabled", on).apply() } + + fun runMode(context: Context): String = + prefs(context).getString("run", RUN_WHILE_OPEN).takeIf { it == RUN_BACKGROUND } ?: RUN_WHILE_OPEN + fun setRunMode(context: Context, mode: String) { prefs(context).edit().putString("run", mode).apply() } + + fun wifiOnly(context: Context): Boolean = prefs(context).getBoolean("wifi-only", false) + fun setWifiOnly(context: Context, on: Boolean) { prefs(context).edit().putBoolean("wifi-only", on).apply() } + + fun pauseInGames(context: Context): Boolean = prefs(context).getBoolean("pause-in-games", false) + fun setPauseInGames(context: Context, on: Boolean) { prefs(context).edit().putBoolean("pause-in-games", on).apply() } + + fun userPaused(context: Context): Boolean = prefs(context).getBoolean("paused", false) + fun setUserPaused(context: Context, on: Boolean) { prefs(context).edit().putBoolean("paused", on).apply() } + + fun pauseApplied(context: Context): Boolean = prefs(context).getBoolean("pause-applied", false) + fun setPauseApplied(context: Context, on: Boolean) { prefs(context).edit().putBoolean("pause-applied", on).apply() } + + fun guiPassword(context: Context): String = prefs(context).getString("gui-password", null) ?: randomToken().also { + prefs(context).edit().putString("gui-password", it).apply() + } + + fun clearIdentity(context: Context) { prefs(context).edit().remove("gui-password").apply() } + + internal fun randomToken(length: Int = 32): String { + val alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + val random = SecureRandom() + return String(CharArray(length) { alphabet[random.nextInt(alphabet.length)] }) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/Syncthing.kt b/app/src/main/java/com/droiddeck/launcher/sync/Syncthing.kt new file mode 100644 index 000000000..c7879492f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/Syncthing.kt @@ -0,0 +1,534 @@ +package com.droiddeck.launcher.sync + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.core.HostProcess +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.OrphanReaper +import com.droiddeck.launcher.session.SessionState +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.SecureRandom +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +object Syncthing { + enum class Phase { OFF, STARTING, RUNNING, STOPPING, FAILED } + enum class AutoPause { WIFI, GAME } + + const val FAILURE_MISSING = "binary-missing" + private const val FAILURE_POLICY = "Stopped because a folder outside DroidDeck's storage could not be paused" + private const val FAILURE_PAUSE = "Stopped because Syncthing could not be paused" + private const val FAILURE_STILL_RUNNING = "The previous Syncthing process did not exit" + private const val TAG = "Syncthing" + private const val GRACE_MS = 60_000L + private const val ENFORCE_ATTEMPTS = 3 + private const val LOG_LIMIT = 4L shl 20 + private const val FOLDER_ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789" + + var enabled by mutableStateOf(false); private set + var phase by mutableStateOf(Phase.OFF); private set + var snapshot by mutableStateOf(null); private set + var failure by mutableStateOf(null); private set + var ports by mutableStateOf(null); private set + var apiKeyTail by mutableStateOf(null); private set + var userPaused by mutableStateOf(false); private set + var autoPause by mutableStateOf(null); private set + var devicesPaused by mutableStateOf(false); private set + val pauseRequested get() = userPaused || autoPause != null + var openRequests by mutableStateOf(0) + private set + var openPending = false + + fun requestOpen() { + openPending = true + openRequests++ + } + + val needsAttention get() = phase == Phase.FAILED || snapshot?.needsAttention == true + + @Volatile var onChange: (() -> Unit)? = null + + private val main = Handler(Looper.getMainLooper()) + private val worker = Executors.newSingleThreadExecutor { Thread(it, "syncthing") } + private lateinit var app: Context + @Volatile private var process: Process? = null + @Volatile private var api: SyncthingApi? = null + @Volatile private var generation = 0 + @Volatile private var onWifi = true + private var visible = 0 + private var foreground = false + private val watchers = AtomicInteger() + private val pauseFailures = AtomicInteger() + private val pauseLock = Any() + private val leaveForeground = Runnable { foreground = false; evaluate() } + + fun home(context: Context) = File(context.filesDir, "syncthing") + fun folderRoot(context: Context) = File(context.getExternalFilesDir(null) ?: context.filesDir, "Sync") + fun logFile(context: Context) = File(home(context), "syncthing.log") + private fun config(context: Context) = File(home(context), "config.xml") + private fun guiSocket(context: Context) = File(home(context), "gui.sock") + private fun binary(context: Context) = File(context.applicationInfo.nativeLibraryDir, "libsyncthingnative.so") + + fun storageRoots(context: Context): List = + listOf(context.dataDir) + context.getExternalFilesDirs(null).filterNotNull().mapNotNull { it.parentFile } + + fun guard(context: Context) = SyncthingGuard(folderRoot(context), storageRoots(context), listOf(home(context))) + + fun init(application: Application) { + app = application.applicationContext + userPaused = SyncPrefs.userPaused(app) + enabled = SyncPrefs.enabled(app) + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + runCatching { process?.destroyForcibly() } + previous?.uncaughtException(thread, error) ?: Runtime.getRuntime().halt(2) + } + application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityStarted(activity: Activity) { + visible++ + main.removeCallbacks(leaveForeground) + if (!foreground) { foreground = true; evaluate() } + } + override fun onActivityStopped(activity: Activity) { + visible = (visible - 1).coerceAtLeast(0) + if (visible == 0) main.postDelayed(leaveForeground, GRACE_MS) + } + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + }) + val connectivity = app.getSystemService(ConnectivityManager::class.java) + onWifi = connectivity?.activeNetwork?.let { connectivity.getNetworkCapabilities(it) }?.let(::isWifi) ?: false + runCatching { + connectivity?.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { networkChanged(isWifi(caps)) } + override fun onLost(network: Network) { networkChanged(false) } + }) + } + evaluate() + } + + private fun isWifi(caps: NetworkCapabilities) = + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + + private fun networkChanged(wifi: Boolean) { + if (wifi == onWifi) return + onWifi = wifi + worker.execute { api?.let { applyPause(it) } } + } + + fun evaluate() { + if (Looper.myLooper() != Looper.getMainLooper()) { main.post { evaluate() }; return } + val enabled = SyncPrefs.enabled(app) + val background = SyncPrefs.runMode(app) == SyncPrefs.RUN_BACKGROUND + if (enabled && background) SyncthingService.start(app) else SyncthingService.stop(app) + if (enabled && (background || foreground)) start() else stop() + } + + fun setEnabled(context: Context, on: Boolean, forget: Boolean = false) { + if (on && !LinuxRuntime.isInstalled(context)) return + SyncPrefs.setEnabled(context, on) + enabled = on + if (on && phase == Phase.FAILED) phase = Phase.OFF + evaluate() + if (!on && forget) worker.execute { + if (process != null) return@execute + home(context).deleteRecursively() + SyncPrefs.clearIdentity(context) + SyncPrefs.setPauseApplied(context, false) + } + } + + fun setPaused(context: Context, paused: Boolean) { + SyncPrefs.setUserPaused(context, paused) + userPaused = paused + changed() + worker.execute { api?.let { applyPause(it) } } + } + + fun settingsChanged() { + evaluate() + worker.execute { api?.let { applyPause(it) } } + } + + fun restart() { + stop() + evaluate() + } + + fun reset(context: Context) { + stop() + worker.execute { + if (process != null) return@execute + home(context).deleteRecursively() + SyncPrefs.clearIdentity(context) + SyncPrefs.setPauseApplied(context, false) + main.post { evaluate() } + } + } + + fun regenerateApiKey() { + worker.execute { + val current = api ?: return@execute + val key = SyncPrefs.randomToken() + runCatching { current.setApiKey(key) }.onSuccess { + api = SyncthingApi(current.socket, key) + main.post { apiKeyTail = key.takeLast(4); changed() } + }.onFailure { Log.w(TAG, "API key change failed", it) } + } + } + + fun addFolder(context: Context, path: File, label: String, devices: List, id: String = newFolderId(), onDone: (Boolean) -> Unit) { + worker.execute { + val ok = runCatching { + val client = api ?: error("Syncthing is not running") + check(guard(context).allows(path.path)) { "${path.path} is outside DroidDeck's storage" } + path.mkdirs() + client.addFolder(client.defaultFolder().apply { + put("id", id) + put("label", label) + put("path", path.path) + put("devices", JSONArray(devices.map { JSONObject().put("deviceID", it) })) + }) + }.onFailure { Log.w(TAG, "add folder failed", it) }.isSuccess + main.post { onDone(ok) } + } + } + + private fun newFolderId(): String { + val random = SecureRandom() + fun part() = (1..5).map { FOLDER_ID_CHARS[random.nextInt(FOLDER_ID_CHARS.length)] }.joinToString("") + return "${part()}-${part()}" + } + + fun removeFolder(id: String) { + worker.execute { api?.let { a -> runCatching { a.removeFolder(id) }.onFailure { Log.w(TAG, "remove $id failed", it) } } } + } + + fun webUiProxy(context: Context): SyncthingProxy? { + if (phase != Phase.RUNNING) return null + val token = Base64.encodeToString("${SyncthingConfig.GUI_USER}:${SyncPrefs.guiPassword(context)}".toByteArray(), Base64.NO_WRAP) + return runCatching { SyncthingProxy(guiSocket(context), "Basic $token") }.onFailure { Log.w(TAG, "web UI proxy failed", it) }.getOrNull() + } + + fun watch(): () -> Unit { + watchers.incrementAndGet() + return { watchers.decrementAndGet() } + } + + private fun start() { + if (phase == Phase.STARTING || phase == Phase.RUNNING) return + if (phase == Phase.FAILED && failure == FAILURE_MISSING) return + val gen = ++generation + phase = Phase.STARTING + failure = null + changed() + worker.execute { launch(gen) } + } + + private fun stop() { + if (phase == Phase.OFF) return + val gen = ++generation + phase = if (process != null || phase == Phase.STARTING) Phase.STOPPING else Phase.OFF + changed() + worker.execute { + shutdown() + val stopped = process == null + main.post { + if (gen != generation) return@post + if (stopped) { phase = Phase.OFF; snapshot = null; ports = null } else { phase = Phase.FAILED; failure = FAILURE_STILL_RUNNING } + changed() + } + } + } + + private fun launch(gen: Int) { + val context = app + try { + if (!binary(context).isFile) throw SyncthingFailure(FAILURE_MISSING) + if (process != null) throw SyncthingFailure(FAILURE_STILL_RUNNING) + val name = binary(context).name + OrphanReaper.reap("stale Syncthing") { it.substringAfterLast('/') == name } + home(context).mkdirs() + folderRoot(context).mkdirs() + if (!config(context).isFile) generate(context) + val picked = SyncthingPortPicker.pick() + val guard = guard(context) + val paused = SyncPrefs.userPaused(context) || autoPauseReason() != null + SyncthingConfig.apply(config(context), picked, guiSocket(context), folderRoot(context), paused, guard::allows) + if (paused) SyncPrefs.setPauseApplied(context, true) + val key = SyncthingConfig.apiKey(config(context)) ?: throw SyncthingFailure("config.xml has no API key") + if (gen != generation) return + rotateLog(context) + val child = ProcessBuilder(binary(context).path, "serve", "--no-browser", "--no-restart") + .redirectErrorStream(true) + .also { environment(context, it.environment()) } + .start() + process = child + OrphanReaper.keep(HostProcess.pidOf(child)) + pump(gen, child, logFile(context)) + pauseFailures.set(0) + val client = SyncthingApi(guiSocket(context), key) + val deadline = System.currentTimeMillis() + 60_000 + while (!client.ping()) { + if (gen != generation) return + if (!child.isAlive) throw SyncthingFailure("Syncthing exited during start (${child.exitValue()})") + if (System.currentTimeMillis() > deadline) throw SyncthingFailure("Syncthing did not answer on ${guiSocket(context).name}") + Thread.sleep(250) + } + api = client + main.post { + if (gen == generation) { ports = picked; apiKeyTail = key.takeLast(4); devicesPaused = paused; phase = Phase.RUNNING; changed() } + } + Thread({ poll(gen, guard) }, "syncthing-status").start() + Thread({ enforce(gen, guard) }, "syncthing-guard").start() + } catch (e: Exception) { + Log.w(TAG, "start failed", e) + shutdown() + val reason = (e as? SyncthingFailure)?.message ?: e.toString() + main.post { if (gen == generation) { phase = Phase.FAILED; failure = reason; changed() } } + } + } + + private fun generate(context: Context) { + val output = File(home(context), "generate.log") + val child = ProcessBuilder(binary(context).path, "generate", "--gui-user", SyncthingConfig.GUI_USER, "--gui-password", "-") + .redirectErrorStream(true) + .redirectOutput(output) + .also { environment(context, it.environment()) } + .start() + val pid = HostProcess.pidOf(child) + OrphanReaper.keep(pid) + try { + child.outputStream.bufferedWriter().use { it.write(SyncPrefs.guiPassword(context) + "\n") } + if (!child.waitFor(60, TimeUnit.SECONDS) || child.exitValue() != 0 || !config(context).isFile) { + child.destroy() + val last = runCatching { output.readLines().lastOrNull { it.isNotBlank() } }.getOrNull().orEmpty() + throw SyncthingFailure("syncthing generate failed: $last") + } + } finally { + output.delete() + OrphanReaper.release(pid) + } + } + + private fun environment(context: Context, env: MutableMap) { + listOf("LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH").forEach { env.remove(it) } + env["STHOMEDIR"] = home(context).path + env["HOME"] = folderRoot(context).path + env["STNOUPGRADE"] = "1" + env["STNODEFAULTFOLDER"] = "1" + env["STMONITORED"] = "1" + env["SQLITE_TMPDIR"] = context.cacheDir.path + env["STVERSIONEXTRA"] = "DroidDeck" + } + + private fun rotateLog(context: Context) { + val log = logFile(context) + if (log.length() > 0) log.renameTo(File(log.parentFile, "syncthing.old.log")) + } + + private fun pump(gen: Int, child: Process, log: File) { + Thread({ + var last = "" + runCatching { + log.appendText("") + child.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + if (line.isNotBlank()) last = line + if (log.length() < LOG_LIMIT) log.appendText(line + "\n") + } + } + } + val code = runCatching { child.waitFor() }.getOrDefault(-1) + OrphanReaper.release(HostProcess.pidOf(child)) + main.post { + if (process !== child) return@post + process = null + if (gen == generation && phase == Phase.RUNNING) { + api = null + phase = Phase.FAILED; failure = "Syncthing stopped ($code): $last"; snapshot = null; changed() + } + } + }, "syncthing-output").start() + } + + private fun shutdown() { + val child = process ?: return + api?.let { runCatching { it.shutdown() } } + api = null + if (!child.waitFor(10, TimeUnit.SECONDS)) { + child.destroy() + if (!child.waitFor(3, TimeUnit.SECONDS)) { + child.destroyForcibly() + child.waitFor(3, TimeUnit.SECONDS) + } + } + if (child.isAlive) { + Log.w(TAG, "Syncthing did not exit") + return + } + process = null + } + + private fun poll(gen: Int, guard: SyncthingGuard) { + var previous: Pair>? = null + var myId: String? = null + while (gen == generation) { + val client = api ?: return + applyPause(client) + try { + val id = myId ?: client.myId().also { myId = it } + val connections = client.connections() + val total = connections.optJSONObject("total") + val now = System.currentTimeMillis() + val bytes = (total?.optLong("inBytesTotal") ?: 0L) to (total?.optLong("outBytesTotal") ?: 0L) + val rates = previous?.let { (at, was) -> + val seconds = ((now - at) / 1000.0).coerceAtLeast(0.5) + ((bytes.first - was.first).coerceAtLeast(0) / seconds).toLong() to ((bytes.second - was.second).coerceAtLeast(0) / seconds).toLong() + } ?: (0L to 0L) + previous = now to bytes + val snap = collect(client, guard, id, connections, rates) + main.post { if (gen == generation) { snapshot = snap; changed() } } + } catch (e: PolicyFailure) { + failClosed(gen, e) + return + } catch (e: Exception) { + Log.d(TAG, "status poll failed: $e") + } + Thread.sleep(if (watchers.get() > 0) 2_000 else 10_000) + } + } + + private fun collect(client: SyncthingApi, guard: SyncthingGuard, myId: String, connections: org.json.JSONObject, rates: Pair): SyncSnapshot { + val names = SyncthingParse.deviceNames(client.devices(), myId) + val config = client.folders() + val folders = (0 until config.length()).map { config.getJSONObject(it) }.map { f -> + val id = f.getString("id") + val path = f.optString("path") + val blocked = !guard.allows(path) + if (blocked && !f.optBoolean("paused")) pauseFolder(client, id) + val paused = blocked || f.optBoolean("paused") + val status = if (paused) null else runCatching { client.folderStatus(id) }.getOrElse { SyncthingParse.unreadableStatus() } + SyncthingParse.folder(f, status, blocked, names) + } + val conns = connections.optJSONObject("connections") + val stats = runCatching { client.deviceStats() }.getOrNull() + val devices = names.map { (id, name) -> + val c = conns?.optJSONObject(id) + val seen = stats?.optJSONObject(id)?.optString("lastSeen")?.let(::parseTime) + SyncDevice(id, name, c?.optBoolean("connected") == true, c?.optString("address")?.takeIf { it.isNotEmpty() }, seen) + } + return SyncSnapshot( + myId, folders, devices, + runCatching { SyncthingParse.pendingFolders(client.pendingFolders(), names) }.getOrDefault(emptyList()), + runCatching { SyncthingParse.pendingDevices(client.pendingDevices()) }.getOrDefault(emptyList()), + rates.first, rates.second, + ) + } + + private fun parseTime(s: String): Long? = runCatching { + java.time.OffsetDateTime.parse(s).toInstant().toEpochMilli().takeIf { it > 86_400_000L } + }.getOrNull() + + private fun enforce(gen: Int, guard: SyncthingGuard) { + var since = 0L + var pending = true + var failures = 0 + while (gen == generation) { + val client = api ?: return + try { + if (!pending) { + val (last, saved) = client.waitForConfigSaved(since, 60) + since = last + pending = saved + } + if (pending && gen == generation) { + applyPause(client) + val folders = client.folders() + for (i in 0 until folders.length()) { + val f = folders.getJSONObject(i) + if (!f.optBoolean("paused") && !guard.allows(f.optString("path"))) pauseFolder(client, f.getString("id")) + } + pending = false + } + failures = 0 + } catch (e: PolicyFailure) { + failClosed(gen, e) + return + } catch (e: Exception) { + if (pending && ++failures >= ENFORCE_ATTEMPTS) { + failClosed(gen, PolicyFailure(e)) + return + } + Thread.sleep(2_000) + } + } + } + + private fun pauseFolder(client: SyncthingApi, id: String) { + try { + client.pauseFolder(id) + } catch (e: Exception) { + throw PolicyFailure(e) + } + } + + private fun failClosed(gen: Int, cause: Exception, reason: String = FAILURE_POLICY) { + Log.w(TAG, reason, cause) + worker.execute { + if (gen != generation) return@execute + shutdown() + main.post { if (gen == generation) { phase = Phase.FAILED; failure = reason; snapshot = null; changed() } } + } + } + + private fun applyPause(client: SyncthingApi) = synchronized(pauseLock) { + val context = app + val auto = autoPauseReason() + val want = SyncPrefs.userPaused(context) || auto != null + var held = SyncPrefs.pauseApplied(context) && !(want && hasUnpausedPeer(client)) + if (want != held) { + runCatching { if (want) client.pauseAll() else client.resumeAll() } + .onSuccess { SyncPrefs.setPauseApplied(context, want); held = want; pauseFailures.set(0) } + .onFailure { Log.w(TAG, "pause change failed", it); if (want) pauseFailures.incrementAndGet() } + } + if (auto != autoPause || held != devicesPaused) main.post { autoPause = auto; devicesPaused = held; changed() } + if (want && !held && pauseFailures.get() >= ENFORCE_ATTEMPTS) failClosed(generation, IllegalStateException(FAILURE_PAUSE), FAILURE_PAUSE) + } + + private fun hasUnpausedPeer(client: SyncthingApi): Boolean = runCatching { + val myId = client.myId() + val devices = client.devices() + (0 until devices.length()).map { devices.getJSONObject(it) } + .any { it.getString("deviceID") != myId && !it.optBoolean("paused") } + }.getOrDefault(true) + + private fun autoPauseReason(): AutoPause? = when { + SyncPrefs.wifiOnly(app) && !onWifi -> AutoPause.WIFI + SyncPrefs.pauseInGames(app) && SessionState.running -> AutoPause.GAME + else -> null + } + + private fun changed() { onChange?.invoke() } + + private class SyncthingFailure(message: String) : Exception(message) + private class PolicyFailure(cause: Exception) : Exception(cause) +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingApi.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingApi.kt new file mode 100644 index 000000000..7a7eaab24 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingApi.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.sync + +import android.net.LocalSocket +import android.net.LocalSocketAddress +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.URLEncoder + +class SyncthingApi(val socket: File, private val apiKey: String) { + fun ping(): Boolean = runCatching { get("/rest/system/ping").optString("ping") == "pong" }.getOrDefault(false) + fun myId(): String = get("/rest/system/status").getString("myID") + fun folders(): JSONArray = getArray("/rest/config/folders") + fun devices(): JSONArray = getArray("/rest/config/devices") + fun folderStatus(id: String): JSONObject = get("/rest/db/status?folder=${enc(id)}") + fun connections(): JSONObject = get("/rest/system/connections") + fun deviceStats(): JSONObject = get("/rest/stats/device") + fun pendingDevices(): JSONObject = get("/rest/cluster/pending/devices") + fun pendingFolders(): JSONObject = get("/rest/cluster/pending/folders") + + fun defaultFolder(): JSONObject = get("/rest/config/defaults/folder") + fun addFolder(folder: JSONObject) = send("POST", "/rest/config/folders", folder) + fun pauseFolder(id: String) = merge("/rest/config/folders/${enc(id)}") { put("paused", true) } + fun removeFolder(id: String) { send("DELETE", "/rest/config/folders/${enc(id)}", null) } + fun pauseAll() { send("POST", "/rest/system/pause", null) } + fun resumeAll() { send("POST", "/rest/system/resume", null) } + fun setApiKey(key: String) = merge("/rest/config/gui") { put("apiKey", key) } + fun shutdown() { send("POST", "/rest/system/shutdown", null) } + + private fun merge(path: String, change: JSONObject.() -> Unit) = send("PUT", path, get(path).apply(change)) + + fun waitForConfigSaved(since: Long, timeoutSeconds: Int): Pair { + val events = JSONArray(request("GET", "/rest/events?events=ConfigSaved&since=$since&timeout=$timeoutSeconds", null, (timeoutSeconds + 10) * 1000)) + if (events.length() == 0) return since to false + return events.getJSONObject(events.length() - 1).getLong("id") to true + } + + private fun get(path: String) = JSONObject(request("GET", path, null, TIMEOUT_MS)) + private fun getArray(path: String) = JSONArray(request("GET", path, null, TIMEOUT_MS)) + private fun send(method: String, path: String, body: JSONObject?) { request(method, path, body, TIMEOUT_MS) } + + private fun request(method: String, path: String, body: JSONObject?, timeoutMs: Int): String = LocalSocket().use { s -> + s.connect(LocalSocketAddress(socket.path, LocalSocketAddress.Namespace.FILESYSTEM)) + s.soTimeout = timeoutMs + val headers = listOf("X-API-Key" to apiKey) + if (body != null) listOf("Content-Type" to "application/json") else emptyList() + SyncthingHttp.writeRequest(s.outputStream, method, path, headers, body?.toString()?.toByteArray()) + val (code, text) = SyncthingHttp.readResponse(s.inputStream.buffered()) + if (code !in 200..299) throw SyncthingApiException(code, text) + text + } + + private fun enc(s: String) = URLEncoder.encode(s, "UTF-8") + + companion object { private const val TIMEOUT_MS = 5_000 } +} + +class SyncthingApiException(val code: Int, message: String) : Exception("HTTP $code: $message") diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingConfig.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingConfig.kt new file mode 100644 index 000000000..b367d5908 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingConfig.kt @@ -0,0 +1,81 @@ +package com.droiddeck.launcher.sync + +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.File +import java.io.IOException +import java.io.StringReader +import java.io.StringWriter +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import org.xml.sax.InputSource + +object SyncthingConfig { + const val GUI_USER = "droiddeck" + private const val RELAYS = "dynamic+https://relays.syncthing.net/endpoint" + + fun apiKey(file: File): String? = runCatching { apiKey(file.readText()) }.getOrNull() + + internal fun apiKey(xml: String): String? = + parse(xml).documentElement.child("gui")?.child("apikey")?.textContent?.trim()?.takeIf { it.isNotEmpty() } + + fun apply(file: File, ports: SyncthingPorts, guiSocket: File, folderRoot: File, pauseDevices: Boolean, allows: (String) -> Boolean) { + val staged = File(file.parentFile, "${file.name}.tmp") + try { + staged.writeText(apply(file.readText(), ports, guiSocket.path, folderRoot.path, pauseDevices, allows)) + if (!staged.renameTo(file)) throw IOException("could not replace ${file.name}") + } finally { + staged.delete() + } + } + + internal fun apply(xml: String, ports: SyncthingPorts, guiSocket: String, folderRoot: String, pauseDevices: Boolean = false, allows: (String) -> Boolean): String { + val doc = parse(xml) + val root = doc.documentElement + val gui = root.child("gui") ?: doc.createElement("gui").also { root.appendChild(it) } + gui.setAttribute("tls", "false") + gui.set("address", guiSocket) + gui.set("unixSocketPermissions", "600") + val options = root.child("options") ?: doc.createElement("options").also { root.appendChild(it) } + options.setList("listenAddress", listOf("tcp://:${ports.sync}", "quic://:${ports.sync}", RELAYS)) + options.set("localAnnouncePort", ports.discovery.toString()) + options.set("urAccepted", "-1") + options.set("crashReportingEnabled", "false") + options.set("autoUpgradeIntervalH", "0") + options.set("startBrowser", "false") + val defaults = root.child("defaults") ?: doc.createElement("defaults").also { root.appendChild(it) } + val folder = defaults.child("folder") ?: doc.createElement("folder").also { defaults.appendChild(it) } + folder.setAttribute("path", folderRoot) + root.children("folder").filter { !allows(it.getAttribute("path")) }.forEach { it.set("paused", "true") } + if (pauseDevices) root.children("device").forEach { it.set("paused", "true") } + return write(doc) + } + + private fun parse(xml: String): Document = + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(InputSource(StringReader(xml))) + + private fun write(doc: Document): String = StringWriter().also { + TransformerFactory.newInstance().newTransformer().apply { setOutputProperty(OutputKeys.INDENT, "no") } + .transform(DOMSource(doc), StreamResult(it)) + }.toString() + + private fun Element.children(name: String): List = + (0 until childNodes.length).map { childNodes.item(it) }.filterIsInstance().filter { it.tagName == name } + + private fun Element.child(name: String): Element? = children(name).firstOrNull() + + private fun Element.set(name: String, value: String) = setList(name, listOf(value)) + + private fun Element.setList(name: String, values: List) { + val existing = children(name) + val anchor = existing.lastOrNull()?.nextSibling + existing.forEach { removeChild(it) } + values.forEach { value -> + val e = ownerDocument.createElement(name).apply { textContent = value } + if (anchor != null) insertBefore(e, anchor) else appendChild(e) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingGuard.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingGuard.kt new file mode 100644 index 000000000..ae27645f6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingGuard.kt @@ -0,0 +1,28 @@ +package com.droiddeck.launcher.sync + +import java.io.File + +class SyncthingGuard(private val home: File, roots: List, forbidden: List) { + private val roots = roots.map { it.canonicalFile } + private val forbidden = forbidden.map { it.canonicalFile } + + fun allows(path: String): Boolean { + val trimmed = path.trim() + if (trimmed.isEmpty()) return false + val expanded = when { + trimmed == "~" -> home + trimmed.startsWith("~/") -> File(home, trimmed.removePrefix("~/")) + else -> File(trimmed) + } + if (!expanded.isAbsolute) return false + val target = runCatching { expanded.canonicalFile }.getOrNull() ?: return false + return roots.any { within(target, it) } && forbidden.none { within(target, it) || within(it, target) } + } + + fun browsable(dir: File): Boolean { + val target = runCatching { dir.canonicalFile }.getOrNull() ?: return false + return roots.any { within(target, it) } && forbidden.none { within(target, it) } + } + + private fun within(target: File, root: File) = target == root || target.path.startsWith(root.path + File.separator) +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingHttp.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingHttp.kt new file mode 100644 index 000000000..b2ebcb3c9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingHttp.kt @@ -0,0 +1,80 @@ +package com.droiddeck.launcher.sync + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +internal object SyncthingHttp { + private const val LINE_LIMIT = 16 shl 10 + private const val HEADER_LIMIT = 100 + + fun writeRequest(out: OutputStream, method: String, path: String, headers: List>, body: ByteArray?) { + val head = StringBuilder("$method $path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n") + headers.forEach { (name, value) -> head.append(name).append(": ").append(value).append("\r\n") } + head.append("Content-Length: ").append(body?.size ?: 0).append("\r\n\r\n") + out.write(head.toString().toByteArray(Charsets.ISO_8859_1)) + body?.let(out::write) + out.flush() + } + + fun readResponse(input: InputStream): Pair { + val status = readLine(input) ?: throw IOException("no response") + val code = status.split(' ').getOrNull(1)?.toIntOrNull() ?: throw IOException("bad status line: $status") + val headers = readHeaders(input) + fun header(name: String) = headers.lastOrNull { it.first.equals(name, ignoreCase = true) }?.second + val body = when { + header("Transfer-Encoding")?.contains("chunked", ignoreCase = true) == true -> chunked(input) + header("Content-Length") != null -> exactly(input, header("Content-Length")!!.trim().toInt()) + else -> input.readBytes() + } + return code to String(body, Charsets.UTF_8) + } + + fun readLine(input: InputStream): String? { + val line = ByteArrayOutputStream() + while (true) { + val b = input.read() + if (b == -1) return if (line.size() == 0) null else throw IOException("truncated line") + if (b == '\n'.code) break + if (line.size() >= LINE_LIMIT) throw IOException("line too long") + line.write(b) + } + return line.toString("ISO-8859-1").removeSuffix("\r") + } + + fun readHeaders(input: InputStream): List> { + val headers = mutableListOf>() + while (true) { + val line = readLine(input) ?: throw IOException("truncated headers") + if (line.isEmpty()) return headers + if (headers.size >= HEADER_LIMIT) throw IOException("too many headers") + val colon = line.indexOf(':') + if (colon <= 0) throw IOException("bad header: $line") + headers += line.substring(0, colon).trim() to line.substring(colon + 1).trim() + } + } + + private fun chunked(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + while (true) { + val size = readLine(input)?.substringBefore(';')?.trim()?.toIntOrNull(16) ?: throw IOException("bad chunk size") + if (size == 0) break + out.write(exactly(input, size)) + readLine(input) + } + while (readLine(input)?.isNotEmpty() == true) Unit + return out.toByteArray() + } + + private fun exactly(input: InputStream, size: Int): ByteArray { + val bytes = ByteArray(size) + var read = 0 + while (read < size) { + val n = input.read(bytes, read, size - read) + if (n == -1) throw IOException("truncated body") + read += n + } + return bytes + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingPorts.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingPorts.kt new file mode 100644 index 000000000..671a98d0b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingPorts.kt @@ -0,0 +1,29 @@ +package com.droiddeck.launcher.sync + +import java.net.DatagramSocket +import java.net.InetSocketAddress +import java.net.ServerSocket + +data class SyncthingPorts(val sync: Int, val discovery: Int) + +object SyncthingPortPicker { + internal val RESERVED = setOf(8384, 8385, 22000, 22001, 21027, 21028) + internal const val SYNC = 22100 + internal const val DISCOVERY = 21127 + private const val SPAN = 50 + + fun pick(): SyncthingPorts = pick(::syncFree, ::udpFree) + + internal fun pick(syncFree: (Int) -> Boolean, discoveryFree: (Int) -> Boolean) = + SyncthingPorts(first(SYNC, syncFree), first(DISCOVERY, discoveryFree)) + + private fun first(base: Int, free: (Int) -> Boolean): Int = + (base until base + SPAN).firstOrNull { it !in RESERVED && free(it) } ?: error("no free port in $base..${base + SPAN - 1}") + + private fun tcpFree(port: Int): Boolean = runCatching { + ServerSocket().use { it.reuseAddress = false; it.bind(InetSocketAddress(port)) } + }.isSuccess + + private fun udpFree(port: Int) = runCatching { DatagramSocket(port).close() }.isSuccess + private fun syncFree(port: Int) = tcpFree(port) && udpFree(port) +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingProxy.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingProxy.kt new file mode 100644 index 000000000..e4487f1e9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingProxy.kt @@ -0,0 +1,121 @@ +package com.droiddeck.launcher.sync + +import android.net.LocalSocket +import android.net.LocalSocketAddress +import java.io.Closeable +import java.io.File +import java.io.FilterInputStream +import java.io.InputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.security.MessageDigest +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Semaphore + +class SyncthingProxy(private val socket: File, private val auth: String) : Closeable { + private val server = ServerSocket(0, 16, InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1))) + private val token = SyncPrefs.randomToken() + val url = "http://127.0.0.1:${server.localPort}/" + val cookie = "$COOKIE=$token" + private val workers = Semaphore(MAX_WORKERS) + private val open: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) + + init { + Thread(::accept, "syncthing-proxy").apply { isDaemon = true }.start() + } + + override fun close() { + runCatching { server.close() } + open.forEach { runCatching { it.close() } } + } + + private fun track(c: Closeable) { + open += c + if (server.isClosed) runCatching { c.close() } + } + + private fun accept() { + while (true) { + val client = runCatching { server.accept() }.getOrNull() ?: return + if (!workers.tryAcquire()) { + runCatching { client.close() } + continue + } + val worker = runCatching { + track(client) + Thread({ try { serve(client) } finally { open -= client; workers.release() } }, "syncthing-proxy-request").apply { isDaemon = true }.start() + } + if (worker.isFailure) { open -= client; workers.release(); runCatching { client.close() } } + } + } + + private fun serve(client: Socket) = client.use { + runCatching { + val deadline = HeadDeadline(client, client.getInputStream(), System.nanoTime() + HEAD_TIMEOUT_MS * 1_000_000L) + val input = deadline.buffered() + val request = SyncthingHttp.readLine(input) ?: return@runCatching + val headers = SyncthingHttp.readHeaders(input) + if (!authorized(headers)) { + client.getOutputStream().write(FORBIDDEN) + return@runCatching + } + deadline.lift() + LocalSocket().use { upstream -> + track(upstream) + try { relay(client, input, request, headers, upstream) } finally { open -= upstream } + } + } + } + + private fun relay(client: Socket, input: InputStream, request: String, headers: List>, upstream: LocalSocket) { + upstream.connect(LocalSocketAddress(socket.path, LocalSocketAddress.Namespace.FILESYSTEM)) + val head = StringBuilder(request).append("\r\n") + headers.filter { it.first.lowercase() !in DROPPED }.forEach { (name, value) -> + val kept = if (name.equals("Cookie", ignoreCase = true)) withoutToken(value) else value + if (kept.isNotEmpty()) head.append(name).append(": ").append(kept).append("\r\n") + } + head.append("Authorization: ").append(auth).append("\r\nConnection: close\r\n\r\n") + upstream.outputStream.write(head.toString().toByteArray(Charsets.ISO_8859_1)) + Thread({ runCatching { input.copyTo(upstream.outputStream) } }, "syncthing-proxy-body").apply { isDaemon = true }.start() + upstream.inputStream.copyTo(client.getOutputStream()) + } + + private fun cookies(header: String) = header.split(';').map { it.trim() } + + private fun authorized(headers: List>): Boolean = headers + .filter { it.first.equals("Cookie", ignoreCase = true) } + .flatMap { cookies(it.second) } + .any { MessageDigest.isEqual(it.toByteArray(), cookie.toByteArray()) } + + private fun withoutToken(header: String) = cookies(header).filter { !it.startsWith("$COOKIE=") }.joinToString("; ") + + private class HeadDeadline(private val client: Socket, input: InputStream, private val until: Long) : FilterInputStream(input) { + @Volatile private var lifted = false + + fun lift() { + lifted = true + client.soTimeout = 0 + } + + override fun read(): Int { arm(); return super.read() } + override fun read(b: ByteArray, off: Int, len: Int): Int { arm(); return super.read(b, off, len) } + + private fun arm() { + if (lifted) return + val left = (until - System.nanoTime()) / 1_000_000L + if (left <= 0) throw SocketTimeoutException("request head took too long") + client.soTimeout = left.toInt() + } + } + + private companion object { + const val COOKIE = "droiddeck-proxy" + const val HEAD_TIMEOUT_MS = 10_000 + const val MAX_WORKERS = 16 + val DROPPED = setOf("authorization", "x-api-key", "connection", "proxy-connection", "keep-alive", "upgrade") + val FORBIDDEN = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingService.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingService.kt new file mode 100644 index 000000000..9b47b0329 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingService.kt @@ -0,0 +1,104 @@ +package com.droiddeck.launcher.sync + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import com.droiddeck.launcher.FrontEndActivity +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.download.StoreDownloadService + +class SyncthingService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_PAUSE -> Syncthing.setPaused(this, !Syncthing.userPaused) + ACTION_OFF -> { Syncthing.setEnabled(this, false); return START_NOT_STICKY } + } + if (!SyncPrefs.enabled(this) || SyncPrefs.runMode(this) != SyncPrefs.RUN_BACKGROUND) { + stopSelf() + return START_NOT_STICKY + } + val n = notification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) startForeground(ID, n, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + else startForeground(ID, n) + Syncthing.onChange = { getSystemService(NotificationManager::class.java)?.notify(ID, notification()) } + Syncthing.evaluate() + return START_STICKY + } + + override fun onDestroy() { + Syncthing.onChange = null + super.onDestroy() + } + + private fun notification(): Notification { + channel(this) + val snap = Syncthing.snapshot + val title = getString(when { + Syncthing.phase == Syncthing.Phase.FAILED -> R.string.sync_status_failed + Syncthing.phase != Syncthing.Phase.RUNNING -> R.string.sync_status_starting + Syncthing.pauseRequested && Syncthing.devicesPaused -> R.string.sync_status_paused + Syncthing.pauseRequested -> R.string.sync_status_pausing + Syncthing.devicesPaused -> R.string.sync_status_resuming + snap?.needsAttention == true -> R.string.sync_status_attention + snap?.syncing == true -> R.string.sync_status_syncing + else -> R.string.sync_status_up_to_date + }) + val text = snap?.let { + getString(R.string.sync_summary, it.folders.size, it.devices.count { d -> d.connected }, it.devices.size) + }.orEmpty() + val open = PendingIntent.getActivity(this, 0, Intent(this, FrontEndActivity::class.java) + .putExtra(StoreDownloadService.EXTRA_NAV, NAV).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, CHANNEL) else @Suppress("DEPRECATION") Notification.Builder(this) + return builder.setSmallIcon(R.drawable.ic_stat_session) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(open) + .setOngoing(true) + .setOnlyAlertOnce(true) + .addAction(Notification.Action.Builder(null, getString(if (Syncthing.userPaused) R.string.sync_resume else R.string.sync_pause), action(ACTION_PAUSE, 1)).build()) + .addAction(Notification.Action.Builder(null, getString(R.string.sync_turn_off), action(ACTION_OFF, 2)).build()) + .build() + } + + private fun action(name: String, code: Int) = PendingIntent.getService(this, code, + Intent(this, SyncthingService::class.java).setAction(name), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + + companion object { + const val NAV = "setup:sync" + private const val CHANNEL = "syncthing" + private const val ID = 9203 + private const val ACTION_PAUSE = "droiddeck.sync.PAUSE" + private const val ACTION_OFF = "droiddeck.sync.OFF" + @Volatile private var running = false + + private fun channel(context: Context) { + val nm = context.getSystemService(NotificationManager::class.java) ?: return + if (nm.getNotificationChannel(CHANNEL) == null) { + nm.createNotificationChannel(NotificationChannel(CHANNEL, context.getString(R.string.sync_notification_channel), NotificationManager.IMPORTANCE_LOW).apply { + setShowBadge(false) + }) + } + } + + fun start(context: Context) { + if (running) return + running = true + runCatching { context.startForegroundService(Intent(context, SyncthingService::class.java)) }.onFailure { running = false } + } + + fun stop(context: Context) { + if (!running) return + running = false + context.stopService(Intent(context, SyncthingService::class.java)) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingStatus.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingStatus.kt new file mode 100644 index 000000000..c07439e0e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingStatus.kt @@ -0,0 +1,71 @@ +package com.droiddeck.launcher.sync + +import org.json.JSONArray +import org.json.JSONObject + +data class SyncFolder( + val id: String, val label: String, val path: String, val paused: Boolean, val blocked: Boolean, + val state: String, val globalBytes: Long, val needBytes: Long, val errors: Int, val sharedWith: List, +) { + val syncing get() = !paused && !blocked && state in ACTIVE && needBytes > 0 + val outOfSync get() = !paused && !blocked && (errors > 0 || state == "error" || (state == "idle" && needBytes > 0)) + + companion object { private val ACTIVE = setOf("syncing", "sync-preparing", "sync-waiting") } +} + +data class SyncDevice(val id: String, val name: String, val connected: Boolean, val address: String?, val lastSeen: Long?) + +data class SyncPendingFolder(val id: String, val label: String, val offeredBy: String, val deviceId: String) + +data class SyncSnapshot( + val myId: String, + val folders: List, + val devices: List, + val pendingFolders: List, + val pendingDevices: List, + val inBytesPerSecond: Long, + val outBytesPerSecond: Long, +) { + val blocked get() = folders.filter { it.blocked } + val needsAttention get() = blocked.isNotEmpty() || pendingFolders.isNotEmpty() || pendingDevices.isNotEmpty() || folders.any { it.outOfSync } + val syncing get() = folders.any { it.syncing } + val needBytes get() = folders.filter { it.syncing }.sumOf { it.needBytes } + + val percent: Int get() { + val active = folders.filter { it.syncing } + val global = active.sumOf { it.globalBytes } + if (global <= 0) return 0 + return (((global - active.sumOf { it.needBytes }).coerceAtLeast(0) * 100) / global).toInt().coerceIn(0, 99) + } +} + +internal object SyncthingParse { + fun unreadableStatus(): JSONObject = JSONObject().put("state", "error").put("errors", 1) + + fun folder(config: JSONObject, status: JSONObject?, blocked: Boolean, names: Map): SyncFolder { + val id = config.getString("id") + val shared = config.optJSONArray("devices")?.let { d -> (0 until d.length()).mapNotNull { names[d.getJSONObject(it).optString("deviceID")] } }.orEmpty() + return SyncFolder( + id, config.optString("label").ifEmpty { id }, config.optString("path"), blocked || config.optBoolean("paused"), blocked, + status?.optString("state").orEmpty(), status?.optLong("globalBytes") ?: 0, status?.optLong("needBytes") ?: 0, + (status?.optInt("errors") ?: 0) + (status?.optInt("pullErrors") ?: 0), shared, + ) + } + + fun deviceNames(devices: JSONArray, myId: String): Map = (0 until devices.length()) + .map { devices.getJSONObject(it) } + .filter { it.getString("deviceID") != myId } + .associate { it.getString("deviceID") to it.optString("name").ifEmpty { it.getString("deviceID").take(7) } } + + fun pendingDevices(json: JSONObject): List = + json.keys().asSequence().map { id -> json.optJSONObject(id)?.optString("name").orEmpty().ifEmpty { id.take(7) } }.toList() + + fun pendingFolders(json: JSONObject, names: Map): List = + json.keys().asSequence().flatMap { folderId -> + val offeredBy = json.getJSONObject(folderId).optJSONObject("offeredBy") ?: JSONObject() + offeredBy.keys().asSequence().map { device -> + val label = offeredBy.getJSONObject(device).optString("label").ifEmpty { folderId } + SyncPendingFolder(folderId, label, names[device] ?: device.take(7), device) + } + }.toList() +} diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingWebActivity.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingWebActivity.kt new file mode 100644 index 000000000..3e036f943 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingWebActivity.kt @@ -0,0 +1,136 @@ +package com.droiddeck.launcher.sync + +import android.annotation.SuppressLint +import android.net.Uri +import android.os.Bundle +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.droiddeck.launcher.R +import com.droiddeck.launcher.ui.BackLink +import com.droiddeck.launcher.ui.DroidDeckTheme +import com.droiddeck.launcher.ui.LocalPalette +import com.droiddeck.launcher.ui.SecondaryButton + +class SyncthingWebActivity : ComponentActivity() { + private var web: WebView? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) { Screen() } } + } + + override fun onDestroy() { + web?.destroy() + web = null + super.onDestroy() + } + + @Composable + private fun Screen() { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val running = Syncthing.phase == Syncthing.Phase.RUNNING + val target = remember(running) { if (running) Syncthing.webUiProxy(this) else null } + DisposableEffect(target) { onDispose { target?.close() } } + LaunchedEffect(Syncthing.phase) { if (Syncthing.phase == Syncthing.Phase.OFF) finish() } + BackHandler { web?.takeIf { it.canGoBack() }?.goBack() ?: finish() } + Column(Modifier.fillMaxSize().background(colors.background)) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().background(colors.surface).padding(horizontal = 16.dp, vertical = 8.dp), + ) { + BackLink(stringResource(R.string.mode_back), compact = true) { finish() } + Text(stringResource(R.string.sync_web_title), fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Box(Modifier.weight(1f)) + target?.let { Text(Uri.parse(it.url).authority.orEmpty(), fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant) } + SecondaryButton(stringResource(R.string.sync_web_reload), compact = true) { web?.reload() } + } + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + if (target == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.sync_not_running), fontSize = 14.sp, color = colors.onSurfaceVariant) + } + } else { + val view = remember(target) { webView(target.url, target.cookie) } + AndroidView({ view }, Modifier.fillMaxSize()) + } + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun webView(url: String, cookie: String): WebView = WebView(this).apply { + web?.destroy() + web = this + setBackgroundColor(0xFF0A0B0D.toInt()) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + val cookies = CookieManager.getInstance() + cookies.setAcceptCookie(true) + val origin = Uri.parse(url).authority + webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? = + if (request.url.authority == origin) null else WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), null) + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = + request.url.authority != origin + + override fun onPageFinished(view: WebView, url: String) { + view.evaluateJavascript(lockFolderPath(getString(R.string.sync_web_path_locked)), null) + } + } + cookies.setCookie(url, "$cookie; HttpOnly; SameSite=Strict") { loadUrl(url) } + } +} + +private fun lockFolderPath(note: String) = """ + (function (note) { + if (window.droiddeckPathLock) return; + window.droiddeckPathLock = true; + function lock() { + var input = document.getElementById('folderPath'); + if (!input) return; + if (!input.readOnly) input.readOnly = true; + if (input.hasAttribute('list')) input.removeAttribute('list'); + if (!document.getElementById('droiddeck-path-note')) { + var p = document.createElement('p'); + p.id = 'droiddeck-path-note'; + p.className = 'help-block'; + p.textContent = note; + input.parentNode.insertBefore(p, input.nextSibling); + } + } + new MutationObserver(lock).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['readonly'] }); + lock(); + })(${org.json.JSONObject.quote(note)}); +""".trimIndent() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt index e000c996a..7e0103ec8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt @@ -287,7 +287,6 @@ private fun ArtOnlyEditor( if (logoOpen && loaded != null) LogoPositionHost(chosen, arts, loaded, onBack = { logoOpen = false }, onCloseAll = { logoOpen = false; close() }) { version++; onChanged() } } -/** The position as last read; null until it has been read, so the screen never opens on a guess and saves it over the real one. */ private class LoadedPosition(val value: LogoPosition.Value?) @Composable @@ -300,7 +299,6 @@ private fun rememberLogoPosition(target: ArtTarget?, version: Int): LoadedPositi return position } -/** The "Adjust logo" screen for [target], over the game's background and logo as they are now. */ @Composable private fun LogoPositionHost( target: ArtTarget, arts: List, start: LoadedPosition, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt index 2c090048d..8e84ae4cb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt @@ -91,16 +91,12 @@ import java.io.File private enum class MovieTab { INSTALLED, IMPORT, REPO, SOUND } -/** Downloads run here rather than in a tab, so switching tabs or closing the settings does not cancel one halfway. */ private val installScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) -/** The downloads under way, by post id and percent; kept here so they are still known when the tab or the settings come back. */ private val installProgress = mutableStateMapOf() -/** What the movie list looks like right now: every movie and the one Steam will play. */ private class MovieSnapshot(val entries: List, val selected: String) -/** The movie list, read again every couple of seconds so a movie picked, added or removed inside Steam shows up here too. */ @Composable private fun rememberMovies(refresh: Int, onSelectionChanged: () -> Unit): MovieSnapshot? { val app = LocalContext.current.applicationContext @@ -143,7 +139,6 @@ private fun TabChip(label: String, selected: Boolean, modifier: Modifier = Modif } } -/** The "Boot movie" settings group: Installed movies, Import your own movies, SteamDeckRepo and Sound. */ @Composable internal fun BootMovieGroup(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) { var tab by rememberSaveable { mutableStateOf(MovieTab.INSTALLED) } @@ -179,7 +174,6 @@ private fun InstalledTab(a: ModeSettingsActions) { val app = LocalContext.current.applicationContext val scope = rememberCoroutineScope() var saving by remember { mutableStateOf(false) } - // The report is for bug reports, so it shows only while Debugging tools is on (read each time this tab opens). val debugging = remember { com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(app) } if (debugging) { SettingsRow(stringResource(R.string.boot_movie_report), stringResource(R.string.boot_movie_report_hint)) { @@ -222,7 +216,6 @@ private fun movieSourceLabel(source: String): String = stringResource( }, ) -/** The installed movies as a list with covers; the one Steam plays is highlighted, and the list follows changes made in Steam. */ @Composable private fun InstalledMoviesDialog(a: ModeSettingsActions, onDismiss: () -> Unit) { val app = LocalContext.current.applicationContext @@ -497,10 +490,8 @@ private fun RepoTab(a: ModeSettingsActions) { } } -/** Downloads [post] and puts it among the installed movies; returns a message when it failed, null when it worked. */ private fun install(app: android.content.Context, post: SteamDeckRepo.Post, onProgress: (Int) -> Unit): String? { val folder = File(app.cacheDir, "boot-movie-download").apply { mkdirs() } - // Leftovers of a download that was cut short are cleared, and every download has a file of its own. folder.listFiles()?.forEach { if (System.currentTimeMillis() - it.lastModified() > 60 * 60 * 1000L) it.delete() } val file = File.createTempFile("movie-", ".webm", folder) return try { @@ -563,7 +554,6 @@ private const val AFTER_OPEN = 3 private const val AFTER_INSTALL = 1 private const val AFTER_USE = 2 -/** Install and Use are different buttons in turn, and the button is disabled while installing; it takes focus back for the controller. */ @Composable private fun RepoAction( installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit, @@ -593,7 +583,6 @@ private fun RepoAction( ) } -/** A movie from SteamDeckRepo played straight from the site, so it can be checked before it is installed. */ @Composable private fun PreviewDialog( post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, @@ -674,7 +663,6 @@ private fun MoviePreview(url: String, modifier: Modifier = Modifier) { } } -/** A dialog with one text box, for a search term or a page number; the Android keyboard comes up with it. */ @Composable internal fun TextEntryDialog( title: String, initial: String, number: Boolean, confirm: String, onSubmit: (String) -> Unit, onDismiss: () -> Unit, @@ -697,7 +685,6 @@ internal fun TextEntryDialog( } } -/** Page buttons around the current page, which opens a box to type another page into; first and last page show their numbers. */ @OptIn(ExperimentalLayoutApi::class) @Composable private fun RepoPager(page: Int, last: Int?, hasMore: Boolean, onPage: (Int) -> Unit, onJump: () -> Unit) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomChatPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomChatPage.kt index e609537b7..8fee83eb9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomChatPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomChatPage.kt @@ -56,23 +56,14 @@ private const val STEAM_CHAT = "https://steamcommunity.com/chat" private const val CHAT_PAGE_SCALE = 0.75f private val STEAM_HOSTS = listOf("steamcommunity.com", "steam-chat.com", "steampowered.com", "steamstatic.com", "steamcontent.com") -/** Steam chat stays on Steam's own sites; anything else is not opened on the bottom screen. */ internal fun steamChatAllows(host: String?): Boolean = host != null && STEAM_HOSTS.any { host == it || host.endsWith(".$it") } -/** The desktop chat fits the bottom panel better than the phone site; keep WebView's Chrome version. */ internal fun desktopUserAgent(webView: String?): String { val chrome = webView?.let { Regex("Chrome/[\\d.]+").find(it)?.value } ?: "Chrome/127.0.6533.103" return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) $chrome Safari/537.36" } -/** - * At the panel's own scale Steam's desktop chat gets about 600x300 CSS pixels and its friends list collapses, so the - * page is laid out [cssWidth] wide and drawn at [scale]. Steam's site header (the rows before .chat_main_flex; its - * class name is generated) is hidden to leave the height to the friends list and the chat. The group chat list keeps - * a fixed 114 px height and an empty-list note that cover the online friends, so it shrinks to its rows (at most a - * quarter of the panel) and the note is hidden. - */ internal fun steamChatFitScript(cssWidth: Int, scale: Float): String { val zoom = "%.2f".format(java.util.Locale.US, scale) return "(function(){var m=document.querySelector('meta[name=viewport]');" + @@ -92,7 +83,6 @@ fun BottomChatPage() { val context = LocalContext.current val colors = MaterialTheme.colorScheme var chatOpen by remember { mutableStateOf(true) } - // Set when Android ended the page's renderer; the chat opens again once DroidDeck is back in front. var stopped by remember { mutableStateOf(false) } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { @@ -105,7 +95,6 @@ fun BottomChatPage() { lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } } - // null while the Steam client's sign-in is being copied, then whether there was one. var fromSteam by remember { mutableStateOf(null) } LaunchedEffect(chatOpen) { if (!chatOpen) { @@ -145,7 +134,6 @@ fun BottomChatPage() { } } -/** With [onClose] set the chat is open: the row also carries Close and leaves out the long mic hint. */ @Composable private fun VoiceRow(context: Context, onClose: (() -> Unit)? = null, note: String = "") { val colors = MaterialTheme.colorScheme @@ -153,7 +141,6 @@ private fun VoiceRow(context: Context, onClose: (() -> Unit)? = null, note: Stri val max = remember { audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) } var volume by remember { mutableIntStateOf(audio.getStreamVolume(AudioManager.STREAM_MUSIC)) } fun step(direction: Int) { - // No flags: Android's volume panel would cover the game on the main screen. audio.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, 0) volume = audio.getStreamVolume(AudioManager.STREAM_MUSIC) } @@ -178,8 +165,6 @@ private fun VoiceRow(context: Context, onClose: (() -> Unit)? = null, note: Stri private fun SteamChatView(onStopped: () -> Unit) { AndroidView(factory = { ctx -> WebView(ctx).apply { - // AndroidView's default WRAP_CONTENT height gives the page a 0 px tall viewport, which collapses Steam - // chat's full-height layout (measured on the Retroid Pocket Duo: html and every container 0 px). layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) setBackgroundColor(0xFF0A0B0D.toInt()) settings.javaScriptEnabled = true @@ -199,8 +184,6 @@ private fun SteamChatView(onStopped: () -> Unit) { if (view.width > 0) view.evaluateJavascript(steamChatFitScript((view.width / scale).roundToInt(), CHAT_PAGE_SCALE), null) } - // Android ends the page's renderer once DroidDeck goes to picture-in-picture ("isolated not needed" - // on the Retroid Pocket Duo); the page cannot come back, so the chat closes and reopens on resume. override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { onStopped() return true diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt index 4943d99d7..d5991b5c0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt @@ -45,7 +45,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R -/** A page the bottom screen can show while the game runs on the main screen. */ class BottomPage( val id: String, @StringRes val label: Int, @@ -56,10 +55,6 @@ class BottomPage( internal fun pickBottomPage(remembered: String?, ids: List): String? = ids.firstOrNull { it == remembered } ?: ids.firstOrNull { it == DEFAULT_PAGE } ?: ids.firstOrNull() -/** - * The tab picked during one session. Each Steam session starts with no pick, so the dashboard opens on Chat; reopened - * later in the same session it shows the tab picked last. - */ internal object SessionBottomTab { private var session: String? = null private var tab: String? = null @@ -74,13 +69,11 @@ internal object SessionBottomTab { } } -/** The page a game start switches to; not remembered as the user's pick. */ internal fun pageForGameStart(ids: List): String? = GAME_PAGE.takeIf { it in ids } private const val DEFAULT_PAGE = "chat" private const val GAME_PAGE = "telemetry" -/** Tabs show their names while every tab still fits at [TAB_WIDTH]; otherwise only the open tab keeps its name. */ internal fun bottomTabsWithLabels(widthDp: Float, tabs: Int): Boolean = tabs * TAB_WIDTH + 2 * BAR_BUTTON <= widthDp private const val TAB_WIDTH = 132f @@ -94,6 +87,7 @@ fun BottomDashboard( onClose: () -> Unit, onMenu: (() -> Unit)?, gameStarts: Int = 0, + strip: (@Composable (String?) -> Unit)? = null, ) { val colors = MaterialTheme.colorScheme var current by remember { mutableStateOf(pickBottomPage(remembered, pages.map { it.id })) } @@ -117,6 +111,7 @@ fun BottomDashboard( } if (onMenu != null) BarButton(Icons.Outlined.Menu, stringResource(R.string.touch_droiddeck_menu), "bottom-menu", onMenu) } + strip?.invoke(page?.id) Box(Modifier.weight(1f).fillMaxWidth()) { if (page != null) page.content() else Text(stringResource(R.string.bottom_dashboard_empty), fontSize = 15.sp, color = colors.onSurfaceVariant, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopPanel.kt new file mode 100644 index 000000000..d6b80ac81 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopPanel.kt @@ -0,0 +1,345 @@ +package com.droiddeck.launcher.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.os.SystemClock +import android.graphics.Typeface +import android.view.Gravity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.view.ViewConfiguration +import android.graphics.RectF +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ComposeView +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ImageView +import android.widget.TextView +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.GuestKeys +import com.droiddeck.launcher.input.InputSpace +import com.droiddeck.launcher.input.KeyboardHost +import com.droiddeck.launcher.input.PadBridge +import com.droiddeck.launcher.input.PointerGestures +import com.droiddeck.launcher.input.TouchpadGestures +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.BottomDesktop + +@SuppressLint("ViewConstructor") +class BottomDesktopPanel( + context: Context, + onClose: () -> Unit, + onMenu: (() -> Unit)?, + steamButtons: Pair<() -> Unit, () -> Unit>?, +) : LinearLayout(context), SurfaceHolder.Callback { + private lateinit var surfaceView: SurfaceView + private val area = FrameLayout(context).apply { clipChildren = true } + private val state = mutableStateOf(BottomDesktop.state) + private val status = ComposeView(context).apply { + setContent { DroidDeckTheme { BottomDesktopStatus(state.value, ::startAgain) } } + } + private val keyboardHost = KeyboardHost(context) { event -> sendKey(event) } + private var directTouch = SessionPrefs.bottomDesktopDirectTouch(context) + private lateinit var touchButton: ImageView + private lateinit var title: TextView + private val trackpad = TouchpadGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(), TrackpadInput()).apply { + desktop = true + } + private var attached = false + /** The surface was given to a compositor process; a new process cannot connect to it again. */ + private var surfaceUsed = false + private var fullWidth = 0 + private var fullHeight = 0 + private var lastTouchY = 0f + private var lastPressMs = 0L + private var scrollRestX = 0f + private var scrollRestY = 0f + private val stateListener: () -> Unit = { showState() } + + init { + orientation = VERTICAL + setBackgroundColor(Color.BLACK) + isFocusable = true + isFocusableInTouchMode = true + addView(bar(onClose, onMenu, steamButtons), LayoutParams(-1, -2)) + area.addView(keyboardHost) + addSurface() + area.addView(status, FrameLayout.LayoutParams(-1, -1)) + addView(area, LayoutParams(-1, 0, 1f)) + keyboardHost.onImeChanged = { pan() } + showTouchMode() + } + + private fun addSurface() { + surfaceView = SurfaceView(context) + surfaceView.holder.addCallback(this) + @Suppress("ClickableViewAccessibility") + surfaceView.setOnTouchListener { _, event -> onSurfaceTouch(event) } + area.addView(surfaceView, 1, FrameLayout.LayoutParams(fullWidth.takeIf { it > 0 } ?: -1, fullHeight.takeIf { it > 0 } ?: -1)) + } + + private fun bar(onClose: () -> Unit, onMenu: (() -> Unit)?, steamButtons: Pair<() -> Unit, () -> Unit>?): View { + val bar = LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(8), dp(6), dp(8), dp(6)) + setBackgroundColor(Color.BLACK) + } + fun gap() = View(context).apply { layoutParams = LayoutParams(dp(6), 1) } + bar.addView(DeckControlsPanel.roundButton(context, "×", context.getString(R.string.deck_close), onClose)) + title = TextView(context).apply { + textSize = 14f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + setTextColor(Color.rgb(0x7A, 0x80, 0x8A)) + setPadding(dp(12), 0, dp(6), 0) + isSingleLine = true + ellipsize = android.text.TextUtils.TruncateAt.END + } + bar.addView(title, LayoutParams(0, -2, 1f)) + touchButton = DeckControlsPanel.iconButton(context, R.drawable.ic_bottom_mouse, "") { setDirectTouch(!directTouch) } + bar.addView(touchButton) + bar.addView(gap()) + bar.addView(DeckControlsPanel.iconButton(context, R.drawable.ic_bottom_keyboard, + context.getString(R.string.second_show_keyboard)) { keyboardHost.toggle() }) + onMenu?.let { + bar.addView(gap()) + bar.addView(DeckControlsPanel.menuButton(context, it)) + } + steamButtons?.let { (steam, qam) -> + bar.addView(gap()) + bar.addView(DeckControlsPanel.roundButton(context, "STEAM", context.getString(R.string.deck_open_steam_menu), steam, wide = true)) + bar.addView(gap()) + bar.addView(DeckControlsPanel.roundButton(context, "•••", context.getString(R.string.drawer_open_qam), qam, wide = true)) + } + return bar + } + + private fun setDirectTouch(on: Boolean) { + trackpad.cancel() + BottomDesktop.sendTouch(3, -1, 0, 0) + directTouch = on + SessionPrefs.setBottomDesktopDirectTouch(context, on) + showTouchMode() + } + + private fun showTouchMode() { + touchButton.setImageResource(if (directTouch) R.drawable.ic_bottom_touch else R.drawable.ic_bottom_mouse) + touchButton.contentDescription = context.getString( + if (directTouch) R.string.bottom_desktop_direct_touch else R.string.bottom_desktop_trackpad) + title.text = context.getString(R.string.bottom_desktop_title, + context.getString(if (directTouch) R.string.bottom_desktop_mode_touch else R.string.bottom_desktop_mode_trackpad)) + BottomDesktop.showCursor(!directTouch) + if (!directTouch && surfaceView.width > 0) { + trackpad.bounds = RectF(0f, 0f, surfaceView.width.toFloat(), surfaceView.height.toFloat()) + trackpad.place(surfaceView.width / 2f, surfaceView.height / 2f) + movePointer(trackpad.x, trackpad.y) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + BottomDesktop.addListener(stateListener) + BottomDesktop.setTextInputListener { focused -> + val tapped = SystemClock.uptimeMillis() - lastPressMs < TEXT_FIELD_TAP_MS + val hardKeyboard = resources.configuration.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO + if (focused && tapped && !hardKeyboard && !keyboardHost.shown) keyboardHost.show() + else if (!focused && keyboardHost.shown) keyboardHost.hide() + } + showState() + post { if (!keyboardHost.shown) requestFocus() } + } + + override fun onDetachedFromWindow() { + BottomDesktop.removeListener(stateListener) + BottomDesktop.setTextInputListener(null) + keyboardHost.takeIf { it.shown }?.hide() + trackpad.cancel() + status.disposeComposition() + super.onDetachedFromWindow() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + if (BottomDesktop.state == BottomDesktop.State.RUNNING && !PadBridge.isControllerKeyEvent(event) && + event.keyCode != KeyEvent.KEYCODE_BACK && sendKey(event)) true + else super.dispatchKeyEvent(event) + + private fun sendKey(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount > 0) return true + if (GuestKeys.send(event, BottomDesktop::sendKey)) return true + val ch = event.unicodeChar + if (event.action == KeyEvent.ACTION_DOWN && ch > 0) { + BottomDesktop.sendText(String(Character.toChars(ch))) + return true + } + return false + } + + private fun startAgain() { + if (surfaceUsed && !BottomDesktop.connected) { + attached = false + surfaceUsed = false + area.removeView(surfaceView) + addSurface() + return + } + if (attached) attach(surfaceView.holder) + } + + private fun showState() { + state.value = BottomDesktop.state + status.visibility = if (state.value == BottomDesktop.State.RUNNING) View.GONE else View.VISIBLE + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + post { holdSurfaceSize() } + } + + // The keyboard shrinks the window; the desktop keeps its size and slides up under it instead of being squeezed. + private fun holdSurfaceSize() { + val w = area.width + val h = area.height + if (w <= 0 || h <= 0) return + if (w != fullWidth || h > fullHeight || !keyboardHost.shown) { + fullWidth = w + fullHeight = h + surfaceView.layoutParams = FrameLayout.LayoutParams(w, h) + } + pan() + } + + private fun pan() { + val visible = area.height + val hidden = (fullHeight - visible).coerceAtLeast(0) + val anchor = if (directTouch) lastTouchY else trackpad.y + surfaceView.translationY = if (hidden == 0 || !keyboardHost.shown) 0f + else -(anchor - visible * 0.4f).coerceIn(0f, hidden.toFloat()) + } + + override fun surfaceCreated(holder: SurfaceHolder) = Unit + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (!attached) attach(holder) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + if (holder !== surfaceView.holder) return + attached = false + surfaceUsed = false + BottomDesktop.detach() + } + + private fun attach(holder: SurfaceHolder) { + val frame = holder.surfaceFrame + val (width, height) = BottomDesktop.outputSizeFor(fullWidth.takeIf { it > 0 } ?: frame.width(), + fullHeight.takeIf { it > 0 } ?: frame.height()) + if (width <= 0 || height <= 0) return + if (frame.width() != width || frame.height() != height) { + holder.setFixedSize(width, height) + return + } + attached = true + surfaceUsed = true + BottomDesktop.attach(context, holder.surface, width, height, display?.refreshRate ?: 60f) + showTouchMode() + } + + private fun onSurfaceTouch(event: MotionEvent): Boolean { + val w = surfaceView.width + val h = surfaceView.height + if (w <= 0 || h <= 0 || BottomDesktop.state != BottomDesktop.State.RUNNING) return true + if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_UP) + lastPressMs = SystemClock.uptimeMillis() + if (directTouch) return forwardTouch(event, w, h) + val bounds = RectF(0f, 0f, w.toFloat(), h.toFloat()) + if (trackpad.bounds != bounds) { + trackpad.bounds = bounds + trackpad.place(trackpad.x, trackpad.y) + } + return trackpad.onTouch(event) + } + + private fun forwardTouch(event: MotionEvent, w: Int, h: Int): Boolean { + val action = when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> 0 + MotionEvent.ACTION_MOVE -> 1 + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> 2 + MotionEvent.ACTION_CANCEL -> { BottomDesktop.sendTouch(3, -1, 0, 0); return true } + else -> return true + } + if (event.actionMasked == MotionEvent.ACTION_DOWN) lastTouchY = event.y + if (action == 1) { + for (i in 0 until event.pointerCount) send(1, event.getPointerId(i), event.getX(i), event.getY(i), w, h) + } else { + val i = event.actionIndex + send(action, event.getPointerId(i), event.getX(i), event.getY(i), w, h) + } + return true + } + + private fun send(action: Int, id: Int, x: Float, y: Float, w: Int, h: Int) = + BottomDesktop.sendTouch(action, id, InputSpace.x(x, w), InputSpace.y(y, h)) + + private fun movePointer(x: Float, y: Float) = + BottomDesktop.sendPointer(InputSpace.x(x, surfaceView.width), InputSpace.y(y, surfaceView.height)) + + private inner class TrackpadInput : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) = movePointer(x, y) + + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { + movePointer(x, y) + BottomDesktop.sendScene(SCENE_BUTTON, button, if (pressed) 1 else 0) + } + + override fun onWheel(steps: Int) = BottomDesktop.sendScene(SCENE_WHEEL, steps) + + override fun onScroll(dx: Float, dy: Float) { + val (outW, outH) = BottomDesktop.outputSizeFor(surfaceView.width, surfaceView.height) + scrollRestX += dx * outW / surfaceView.width.coerceAtLeast(1) * AXIS_UNITS_PER_PIXEL + scrollRestY += dy * outH / surfaceView.height.coerceAtLeast(1) * AXIS_UNITS_PER_PIXEL + val h = scrollRestX.toInt() + val v = scrollRestY.toInt() + if (h == 0 && v == 0) return + scrollRestX -= h + scrollRestY -= v + BottomDesktop.sendScene(SCENE_AXIS, v, h) + } + + override fun onScrollEnd() { + scrollRestX = 0f + scrollRestY = 0f + BottomDesktop.sendScene(SCENE_AXIS, 0, 0) + } + + override fun onZoom(steps: Int) { + BottomDesktop.sendKey(KEY_LEFTCTRL, true) + BottomDesktop.sendScene(SCENE_WHEEL, steps) + BottomDesktop.sendKey(KEY_LEFTCTRL, false) + } + + override fun onThreeFingerTap(): Boolean { + post { keyboardHost.toggle() } + return true + } + + override fun onLongPress() { + performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS) + } + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt() + + private companion object { + const val SCENE_BUTTON = 3 + const val SCENE_WHEEL = 4 + const val SCENE_AXIS = 12 + const val KEY_LEFTCTRL = 29 + const val AXIS_UNITS_PER_PIXEL = 3f + const val TEXT_FIELD_TAP_MS = 1_000L + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopStatus.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopStatus.kt new file mode 100644 index 000000000..88bd56776 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopStatus.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.session.BottomDesktop + +/** What the bottom screen shows while the desktop is not drawing: the session loading screen's throbber and a line of text. */ +@Composable +internal fun BottomDesktopStatus(state: BottomDesktop.State, onStart: () -> Unit) { + val colors = MaterialTheme.colorScheme + val starting = state == BottomDesktop.State.STARTING + Box(Modifier.fillMaxSize().background(colors.background).blockTouches(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + LogoThrobber(Modifier.width(if (starting) 96.dp else 76.dp), running = starting) + Spacer(Modifier.height(24.dp)) + Text( + stringResource(when (state) { + BottomDesktop.State.ENDED -> R.string.bottom_desktop_ended + BottomDesktop.State.FAILED -> R.string.bottom_desktop_failed + BottomDesktop.State.OFF -> R.string.bottom_desktop_off + else -> R.string.bottom_desktop_starting + }), + fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, + textAlign = TextAlign.Center, modifier = Modifier.widthIn(max = 420.dp), + ) + if (!starting) { + Spacer(Modifier.height(20.dp)) + PrimaryButton(stringResource(R.string.bottom_desktop_start), main = true, onClick = onStart) + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDownloadsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDownloadsPage.kt index 0ca56a10b..7ad576f95 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomDownloadsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDownloadsPage.kt @@ -42,7 +42,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -/** Bytes per second between two reads of the same download; 0 when it did not move or restarted. */ internal fun downloadRate(previousDone: Long, previousAt: Long, done: Long, at: Long): Long = if (previousDone < 0L || done <= previousDone || at <= previousAt) 0L else (done - previousDone) * 1000L / (at - previousAt) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt index 379c74b6d..266cb5a59 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt @@ -39,7 +39,6 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.session.BottomNotifications import kotlinx.coroutines.delay -/** DroidDeck's own switch on Android 11 and later; the list of all apps before that. */ internal fun notificationAccessIntent(context: android.content.Context): Intent = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt index da9695cf9..a8884fdec 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt @@ -8,19 +8,17 @@ import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.PhotoLibrary import com.droiddeck.launcher.R -/** What the bottom-screen pages can ask the running session to do. */ class BottomPageActions( val takeScreenshot: ((onDone: (Boolean) -> Unit) -> Unit)? = null, val fps: () -> Float? = { null }, - /** Opens an Android settings page on the bottom screen; the dashboard comes back once [done] is true. */ val openSettings: ((intent: android.content.Intent, done: () -> Boolean) -> Unit)? = null, - /** The focused game's Steam app id as text (or another key for non-Steam programs); null in the Steam menus. */ val runningGame: () -> String? = { null }, ) -/** The pages the bottom-screen dashboard offers during a session, in tab order. */ +internal const val BOTTOM_PAGE_TELEMETRY = "telemetry" + fun sessionBottomPages(actions: BottomPageActions): List = listOf( - BottomPage("telemetry", R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) }, + BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) }, BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() }, BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) }, BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomStrip.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomStrip.kt new file mode 100644 index 000000000..58f1bd76d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomStrip.kt @@ -0,0 +1,85 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.droiddeck.launcher.hud.DroidDeckHudConfig +import com.droiddeck.launcher.hud.FrameStats +import com.droiddeck.launcher.hud.GameFrameFeed +import com.droiddeck.launcher.hud.HudFrames +import com.droiddeck.launcher.hud.HudMetrics +import com.droiddeck.launcher.hud.KeyValueSet +import com.droiddeck.launcher.hud.MangoReadings +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.StripMetric +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.io.File + +private const val STRIP_FRAME_MS = 100L +private const val STRIP_METRICS_MS = 500L +private const val STRIP_TEMP_PLAIN = 0xFFF2F5F9.toInt() + +@Composable +internal fun SessionBottomStrip(pageId: String?, fps: () -> Float?) { + val context = LocalContext.current + val frames = remember { GameFrameFeed(File(LinuxRuntime.rootDir(context), HudFrames.PATH)) } + DisposableEffect(frames) { onDispose { frames.close() } } + val page by rememberUpdatedState(pageId) + var frameTick by remember { mutableIntStateOf(0) } + var device by remember { mutableStateOf(null) } + var clock by remember { mutableStateOf("") } + var onTabs by remember { mutableStateOf(SessionPrefs.bottomHud(context) && SessionPrefs.bottomStripOnTabs(context)) } + var metrics by remember { mutableStateOf(SessionPrefs.bottomStripMetrics(context)) } + var thresholds by remember { mutableStateOf>(emptyMap()) } + val tempDisplay = remember { HudMetrics.TempDisplay.from(KeyValueSet(DroidDeckHudConfig.encode(DroidDeckHudConfig.load(context)))) } + LaunchedEffect(frames) { + while (isActive) { + if (frames.poll(System.nanoTime()) || !frames.live) frameTick++ + delay(STRIP_FRAME_MS) + } + } + LaunchedEffect(Unit) { + val reader = HudMetrics(context).apply { setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) } + thresholds = withContext(Dispatchers.IO) { + HudMetrics.TempSensor.values().mapNotNull { sensor -> runCatching { sensor to reader.resolveThresholds(sensor, tempDisplay) }.getOrNull() }.toMap() + } + while (isActive) { + onTabs = SessionPrefs.bottomHud(context) && SessionPrefs.bottomStripOnTabs(context) + metrics = SessionPrefs.bottomStripMetrics(context) + if (onTabs && page != BOTTOM_PAGE_TELEMETRY) { + if (StripMetric.GPU in metrics || StripMetric.VRAM in metrics) com.droiddeck.launcher.hud.BottomHudPresence.beat() + device = withContext(Dispatchers.IO) { runCatching { reader.snapshot() }.getOrNull() } + clock = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date()) + } + delay(STRIP_METRICS_MS) + } + } + if (!onTabs || pageId == BOTTOM_PAGE_TELEMETRY) return + frameTick.let { + val live = frames.live + val stats = frames.stats + val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) } + LineStrip(StripData( + fps = if (live) stats.fps else fps(), device = device, + fpsHistory = emptyList(), cpuHistory = emptyList(), gpuHistory = emptyList(), wattsHistory = emptyList(), clock = clock, + engine = if (live && !frames.steamFocused) frames.engine else "", + lows = if (live) stats.lows(System.nanoTime()) else null, graph = graph, metrics = metrics, + fahrenheit = tempDisplay.fahrenheit, + tempColor = { sensor, c -> + thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, STRIP_TEMP_PLAIN)) } ?: Color(STRIP_TEMP_PLAIN) + }, + )) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt index c1fb9c2a7..41c690f90 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt @@ -1,9 +1,13 @@ package com.droiddeck.launcher.ui +import android.content.Context import android.content.res.Resources +import android.system.Os +import android.system.OsConstants import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -22,6 +26,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -41,18 +46,30 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.CpuCores +import com.droiddeck.launcher.frontend.AddedGameArt import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.frontend.SteamGameArt +import com.droiddeck.launcher.gpu.GpuInfo +import com.droiddeck.launcher.hud.DroidDeckHudConfig +import com.droiddeck.launcher.hud.DroidDeckHudHost +import com.droiddeck.launcher.hud.FrameStats +import com.droiddeck.launcher.hud.GameFrameFeed +import com.droiddeck.launcher.hud.HudFrames import com.droiddeck.launcher.hud.HudMetrics +import com.droiddeck.launcher.hud.KeyValueSet import com.droiddeck.launcher.hud.MangoReadings import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.GameStatusFiles import com.droiddeck.launcher.session.GameTelemetry +import com.droiddeck.launcher.session.HwmonComponent import com.droiddeck.launcher.input.SteamTouchConfig import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState import com.droiddeck.launcher.session.SpeedUnit import com.droiddeck.launcher.session.StatsDash +import com.droiddeck.launcher.session.StatsHud import com.droiddeck.launcher.session.StatsStrip import com.droiddeck.launcher.session.Telemetry import com.droiddeck.launcher.session.TelemetryOutput @@ -72,16 +89,17 @@ import java.util.concurrent.atomic.AtomicReference private const val DRAW_MS = 66L private const val TELEMETRY_STALE_MS = 3_000L -private const val METRICS_MS = 1_000L +private const val METRICS_MS = 500L private const val THUNDER_MS = 500L private const val THUNDER_RETRY_MS = 3_000L private const val ELITE_MS = 1_000L private const val ELITE_SEARCH_MS = 10_000L private const val OUTPUT_SCAN_MS = 3_000L -/** Top strip history: one reading a second for 30 s. */ -private const val PERF_N = 30 +private const val PERF_N = 60 +private const val FRAME_MS = 100L +private const val MINUTE_NS = 60_000_000_000L +private const val TEMP_PLAIN = 0xFFF2F5F9.toInt() -/** What arrives on one telemetry port; written only by that port's listener. */ private class PortTraffic { @Volatile var packets = 0L @Volatile var length = 0 @@ -126,6 +144,21 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } var perfTick by remember { mutableIntStateOf(0) } var traceTick by remember { mutableIntStateOf(0) } var clock by remember { mutableStateOf("") } + val frames = remember { GameFrameFeed(File(LinuxRuntime.rootDir(context), HudFrames.PATH)) } + DisposableEffect(frames) { onDispose { frames.close() } } + val currentFps = { if (frames.live) frames.stats.fps else fps() } + var hud by remember { mutableStateOf(StatsHud.from(SessionPrefs.statsHud(context))) } + var hudOn by remember { mutableStateOf(SessionPrefs.bottomHud(context)) } + var hudTick by remember { mutableIntStateOf(0) } + var fanRpm by remember { mutableStateOf(null) } + var gpuName by remember { mutableStateOf("") } + var proton by remember { mutableStateOf(null) } + var playedSeconds by remember { mutableStateOf(null) } + var cover by remember { mutableStateOf(null) } + var coreGroups by remember { mutableStateOf>(emptyList()) } + var thresholds by remember { mutableStateOf>(emptyMap()) } + val tempDisplay = remember { HudMetrics.TempDisplay.from(KeyValueSet(DroidDeckHudConfig.encode(DroidDeckHudConfig.load(context)))) } + val gameCores = remember { CpuCores.parse(SessionPrefs.gameCpus(context)) } LaunchedEffect(Unit) { for (port in GameTelemetry.PORTS) launch(Dispatchers.IO) { @@ -169,22 +202,49 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } speedTrace.add(r.speedKmh ?: 0f) traceTick++ } - fpsNow = fps() + fpsNow = currentFps() unknown = traffic.filter { (_, t) -> t.packets > 0 && !t.readable }.map { (port, t) -> UnknownTraffic(port, t.packets, t.length) } delay(DRAW_MS) } } + LaunchedEffect(frames) { + while (isActive) { + if (frames.poll(System.nanoTime())) hudTick++ + delay(FRAME_MS) + } + } LaunchedEffect(Unit) { val metrics = HudMetrics(context).apply { setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) } + val fanPath = withContext(Dispatchers.IO) { runCatching { HwmonComponent.fanSource() }.getOrNull() } + gpuName = withContext(Dispatchers.IO) { runCatching { GpuInfo.detect().displayName(context) }.getOrDefault("") } + coreGroups = withContext(Dispatchers.IO) { coreGroups() } + thresholds = withContext(Dispatchers.IO) { + HudMetrics.TempSensor.values().mapNotNull { sensor -> runCatching { sensor to metrics.resolveThresholds(sensor, tempDisplay) }.getOrNull() }.toMap() + } + var protonPid = 0 while (isActive) { device = withContext(Dispatchers.IO) { runCatching { metrics.snapshot() }.getOrNull() } - fps()?.takeIf { it > 0f }?.let { fpsHistory.add(it) } + fanRpm = fanPath?.let { path -> withContext(Dispatchers.IO) { HwmonComponent.readRpm(path) } } + val pid = if (frames.live && !frames.steamFocused) frames.pid else 0 + if (pid == 0) { + protonPid = 0 + if (!frames.live) proton = null + } else if (pid != protonPid) { + val name = withContext(Dispatchers.IO) { DroidDeckHudHost.protonName(pid) } + proton = name + if (name != null) protonPid = pid + } + playedSeconds = if (pid > 0 && running != null) withContext(Dispatchers.IO) { processSeconds(pid) } else null + val sampledAt = android.os.SystemClock.elapsedRealtime() + currentFps()?.takeIf { it > 0f }?.let { fpsHistory.add(it, sampledAt) } device?.let { s -> - s.cpuPercent?.let { cpuHistory.add(it.toFloat()) } - s.gpuPercent?.let { gpuHistory.add(it.toFloat()) } + s.cpuPercent?.let { cpuHistory.add(it.toFloat(), sampledAt) } + s.gpuPercent?.let { gpuHistory.add(it.toFloat(), sampledAt) } s.battery?.watts?.takeIf { it > 0f }?.let { wattsHistory.add(it) } } clock = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date()) + hudOn = SessionPrefs.bottomHud(context) + if (hudOn) com.droiddeck.launcher.hud.BottomHudPresence.beat() perfTick++ delay(METRICS_MS) } @@ -208,7 +268,6 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } } } val f = file - // Status.json changes while Elite runs; one older than a minute belongs to an earlier session. elite = if (f != null && now - f.lastModified() < 60_000L) { withContext(Dispatchers.IO) { runCatching { GameStatusFiles.elite(f.readText()) }.getOrNull() } } else null @@ -224,6 +283,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } val id = running?.toLongOrNull() gameName = if (id == null) null else withContext(Dispatchers.IO) { runCatching { SteamTouchConfig.gameName(context, id.toInt()) }.getOrNull() } + cover = if (id == null) null else withContext(Dispatchers.IO) { runCatching { gameCover(context, id.toInt()) }.getOrNull() } } LaunchedEffect(rescan) { while (isActive) { @@ -233,96 +293,167 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } } } - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { - perfTick.let { - StatsStripView(strip, StripData(fpsNow, device, fpsHistory.snapshot(), cpuHistory.snapshot(), gpuHistory.snapshot(), wattsHistory.snapshot(), clock, - if (running != null) gameName else steamClient?.let { stringResource(steamClientLabel(it)) })) { - strip = strip.next() - SessionPrefs.setStatsStrip(context, strip.id) - } - } - race?.let { r -> - val shown = dash.resolve(r.source) - traceTick.let { - RaceDashView(shown, r, TraceData(throttleTrace.snapshot(), brakeTrace.snapshot(), speedTrace.snapshot()), shownUnit) { - dash = StatsDash.tapped(shown) - SessionPrefs.setStatsDash(context, dash.id) + val title = if (running != null) gameName else steamClient?.let { stringResource(steamClientLabel(it)) } + val noData = race == null && thunder.isEmpty() && elite == null + BoxWithConstraints(Modifier.fillMaxSize()) { + val viewport = maxHeight + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (!hudOn) Unit + else if (noData) hudTick.let { + perfTick.let { + val now = System.nanoTime() + val live = frames.live + val stats = frames.stats + val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) } + val hz = (frames.refreshMilliHz + 499) / 1000 + val data = HudData( + fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY, + graph = graph, slowPerMinute = if (live) stats.slowFrames(now, MINUTE_NS) else null, + engine = if (live && !frames.steamFocused) frames.engine else "", + resolution = if (frames.outputWidth > 0 && frames.outputHeight > 0) { + "${frames.outputWidth}x${frames.outputHeight}" + (if (hz > 0) " @ ${hz}Hz" else "") + } else "", + refreshHz = hz, proton = proton, device = device, gpuName = gpuName, fanRpm = fanRpm, + title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock, + fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(), + historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()), + coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit, + tempColor = { sensor, c -> + thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN) + }, + redC = { thresholds[it]?.redC }, + ) + StatsHudView(hud, data, Modifier.fillMaxWidth().height(viewport)) { + hud = hud.next() + SessionPrefs.setStatsHud(context, hud.id) + } } - } - } - if (thunder.isNotEmpty()) { - if (FlightLabels.drawable(thunder)) FlightDash(thunder) - else TelemetryCard(stringResource(R.string.bottom_telemetry_war_thunder)) { ValueGrid(thunder.take(GameStatusFiles.WAR_THUNDER_ROWS)) } - } - elite?.let { e -> - if (e.pips != null || e.fuelMain != null) ShipDash(e) - else TelemetryCard(stringResource(R.string.bottom_telemetry_elite)) { - ValueGrid(listOfNotNull( - e.fuelMain?.let { stringResource(R.string.bottom_telemetry_fuel) to "%.1f t".format(it) }, - e.cargo?.let { stringResource(R.string.bottom_telemetry_cargo) to "%.0f t".format(it) }, - e.pips?.takeIf { it.size == 3 }?.let { p -> - stringResource(R.string.bottom_telemetry_pips) to p.joinToString(" · ") { "%.1f".format(it / 2f) } - }, - e.legal?.let { stringResource(R.string.bottom_telemetry_legal) to it }, - e.balance?.let { stringResource(R.string.bottom_telemetry_balance) to "%,d CR".format(it) }, - )) - } - } - if (race == null && thunder.isEmpty() && elite == null) { - Text(stringResource(R.string.bottom_telemetry_help), - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) - } - if (outputs.isNotEmpty() || menuGames.isNotEmpty()) TelemetryCard(stringResource(R.string.bottom_telemetry_send_title)) { - if (outputs.isNotEmpty()) SwitchRow(stringResource(R.string.bottom_telemetry_send_auto), stringResource(R.string.bottom_telemetry_send_auto_hint), auto, true) { want -> - scope.launch { - withContext(Dispatchers.IO) { - SessionPrefs.setTelemetryAuto(context, want) - if (want) TelemetryOutput.applyAutomatic(context, running) - else TelemetryOutput.restoreTargets(outputs, running).forEach { TelemetryOutput.set(it, false) } + } else hudTick.let { + perfTick.let { + val live = frames.live + val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) frames.stats.graph(it) } + val data = StripData( + fpsNow, device, fpsHistory.snapshot(), cpuHistory.snapshot(), gpuHistory.snapshot(), wattsHistory.snapshot(), clock, title, + engine = if (live && !frames.steamFocused) frames.engine else "", + lows = if (live) frames.stats.lows(System.nanoTime()) else null, graph = graph, + metrics = SessionPrefs.bottomStripMetrics(context), fahrenheit = tempDisplay.fahrenheit, + tempColor = { sensor, c -> + thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN) + }, + ) + StatsStripView(strip, data) { + strip = strip.next() + SessionPrefs.setStatsStrip(context, strip.id) } - auto = want - rescan++ } } - for (game in outputs) { - val locked = TelemetryOutput.isRunning(game.prefixId, running) - val hint = if (locked) stringResource(R.string.bottom_telemetry_send_close, game.name) else null - SwitchRow(game.name, hint, game.on, !locked) { want -> + race?.let { r -> + val shown = dash.resolve(r.source) + traceTick.let { + RaceDashView(shown, r, TraceData(throttleTrace.snapshot(), brakeTrace.snapshot(), speedTrace.snapshot()), shownUnit) { + dash = StatsDash.tapped(shown) + SessionPrefs.setStatsDash(context, dash.id) + } + } + } + if (thunder.isNotEmpty()) { + if (FlightLabels.drawable(thunder)) FlightDash(thunder) + else TelemetryCard(stringResource(R.string.bottom_telemetry_war_thunder)) { ValueGrid(thunder.take(GameStatusFiles.WAR_THUNDER_ROWS)) } + } + elite?.let { e -> + if (e.pips != null || e.fuelMain != null) ShipDash(e) + else TelemetryCard(stringResource(R.string.bottom_telemetry_elite)) { + ValueGrid(listOfNotNull( + e.fuelMain?.let { stringResource(R.string.bottom_telemetry_fuel) to "%.1f t".format(it) }, + e.cargo?.let { stringResource(R.string.bottom_telemetry_cargo) to "%.0f t".format(it) }, + e.pips?.takeIf { it.size == 3 }?.let { p -> + stringResource(R.string.bottom_telemetry_pips) to p.joinToString(" · ") { "%.1f".format(it / 2f) } + }, + e.legal?.let { stringResource(R.string.bottom_telemetry_legal) to it }, + e.balance?.let { stringResource(R.string.bottom_telemetry_balance) to "%,d CR".format(it) }, + )) + } + } + if (race == null && thunder.isEmpty() && elite == null && running == null) { + Text(stringResource(R.string.bottom_telemetry_help), + fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) + } + if (outputs.isNotEmpty() || menuGames.isNotEmpty()) TelemetryCard(stringResource(R.string.bottom_telemetry_send_title)) { + if (outputs.isNotEmpty()) SwitchRow(stringResource(R.string.bottom_telemetry_send_auto), stringResource(R.string.bottom_telemetry_send_auto_hint), auto, true) { want -> scope.launch { - val ok = withContext(Dispatchers.IO) { - val key = TelemetryOutput.key(game) - val off = SessionPrefs.telemetryOff(context) - SessionPrefs.setTelemetryOff(context, if (want) off - key else off + key) - TelemetryOutput.set(game, want) + withContext(Dispatchers.IO) { + SessionPrefs.setTelemetryAuto(context, want) + if (want) TelemetryOutput.applyAutomatic(context, running) + else TelemetryOutput.restoreTargets(outputs, running).forEach { TelemetryOutput.set(it, false) } } - outputFailed = if (ok) null else game.name + auto = want rescan++ } } + for (game in outputs) { + val locked = TelemetryOutput.isRunning(game.prefixId, running) + val hint = if (locked) stringResource(R.string.bottom_telemetry_send_close, game.name) else null + SwitchRow(game.name, hint, game.on, !locked) { want -> + scope.launch { + val ok = withContext(Dispatchers.IO) { + val key = TelemetryOutput.key(game) + val off = SessionPrefs.telemetryOff(context) + SessionPrefs.setTelemetryOff(context, if (want) off - key else off + key) + TelemetryOutput.set(game, want) + } + outputFailed = if (ok) null else game.name + rescan++ + } + } + } + for (game in menuGames) { + Text(stringResource(R.string.bottom_telemetry_send_menu, game.name, game.setting, game.port), fontSize = 13.sp, color = colors.onSurfaceVariant) + } + if (outputs.isNotEmpty()) Text(stringResource(R.string.bottom_telemetry_send_note), fontSize = 12.sp, color = colors.onSurfaceVariant) + outputFailed?.let { Text(stringResource(R.string.bottom_telemetry_send_failed, it), fontSize = 12.sp, color = colors.error) } } - for (game in menuGames) { - Text(stringResource(R.string.bottom_telemetry_send_menu, game.name, game.setting, game.port), fontSize = 13.sp, color = colors.onSurfaceVariant) + for (u in unknown) { + Text(stringResource(R.string.bottom_telemetry_unknown_format, u.port, u.packets, u.length), fontSize = 13.sp, + color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) } - if (outputs.isNotEmpty()) Text(stringResource(R.string.bottom_telemetry_send_note), fontSize = 12.sp, color = colors.onSurfaceVariant) - outputFailed?.let { Text(stringResource(R.string.bottom_telemetry_send_failed, it), fontSize = 12.sp, color = colors.error) } + if (busyPorts.isNotEmpty()) { + Text(stringResource(R.string.bottom_telemetry_port_busy, busyPorts.sorted().joinToString()), fontSize = 13.sp, + color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) + } + ChoiceRow(stringResource(R.string.stats_speed_units), + if (speedUnit == SpeedUnit.AUTO) stringResource(R.string.stats_speed_units_auto, shownUnit.label) else speedUnit.label) { + speedUnit = speedUnit.next() + SessionPrefs.setStatsSpeedUnit(context, speedUnit.id) + } + Text(stringResource(R.string.stats_tap_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) } - for (u in unknown) { - Text(stringResource(R.string.bottom_telemetry_unknown_format, u.port, u.packets, u.length), fontSize = 13.sp, - color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) - } - if (busyPorts.isNotEmpty()) { - Text(stringResource(R.string.bottom_telemetry_port_busy, busyPorts.sorted().joinToString()), fontSize = 13.sp, - color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) - } - ChoiceRow(stringResource(R.string.stats_speed_units), - if (speedUnit == SpeedUnit.AUTO) stringResource(R.string.stats_speed_units_auto, shownUnit.label) else speedUnit.label) { - speedUnit = speedUnit.next() - SessionPrefs.setStatsSpeedUnit(context, speedUnit.id) - } - Text(stringResource(R.string.stats_tap_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) } } +private fun processSeconds(pid: Int): Long? = runCatching { + val fields = File("/proc/$pid/stat").readText().substringAfterLast(')').trim().split(' ') + val startTicks = fields[19].toLong() + val uptime = File("/proc/uptime").readText().substringBefore(' ').toDouble() + (uptime - startTicks.toDouble() / Os.sysconf(OsConstants._SC_CLK_TCK)).toLong() +}.getOrNull()?.takeIf { it >= 0 } + +private fun playedText(seconds: Long): String = + if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) else "%d:%02d".format(seconds / 60, seconds % 60) + +private fun gameCover(context: Context, appId: Int): File? = + SteamGameArt.chosenFile(context, appId, AddedGameArt.Slot.COVER) + ?: Library.steamCacheImage(File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache"), appId, listOf("library_capsule.jpg", "library_600x900.jpg")) + +private fun coreGroups(): List { + val out = mutableListOf() + for (core in CpuCores.all) { + val ghz = CpuCores.maxGhz(core) + val last = out.lastOrNull() + if (last != null && last.maxGhz == ghz) out[out.lastIndex] = CoreGroup(last.cores + core, ghz) else out += CoreGroup(listOf(core), ghz) + } + return out +} + private fun httpGet(url: String): String { val c = URL(url).openConnection() as HttpURLConnection c.connectTimeout = 300 @@ -335,7 +466,6 @@ private fun httpGet(url: String): String { } -/** The Steam client the session runs, shown in the strip's top line while no game runs. */ internal fun steamClientLabel(arch: String): Int = if (arch == "x86_64") R.string.stats_steam_client_x86 else R.string.stats_steam_client_arm @Composable diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BuildWatermark.kt b/app/src/main/java/com/droiddeck/launcher/ui/BuildWatermark.kt index f2a9e8f65..648d754aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BuildWatermark.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BuildWatermark.kt @@ -17,7 +17,6 @@ import android.widget.FrameLayout import com.droiddeck.launcher.BuildConfig import com.droiddeck.launcher.QuestXrActivity -/** A faint bottom-right tag on every screen of a test or local build; stable and preview builds draw none. */ object BuildWatermark { private const val TITLE_LIMIT = 80 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt b/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt index b7f5d44db..2a50f3c35 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt @@ -40,10 +40,8 @@ import kotlinx.coroutines.delay import androidx.compose.runtime.setValue import androidx.compose.runtime.getValue -/** A grid of cards with a game page that floods out of the opened card and drains back into it. */ internal val LocalDetailFloor = compositionLocalOf<() -> Float> { { Float.MAX_VALUE } } -/** How far the host reaches past the page's padding, so a game page can paint to the screen edge and still inset its content. */ internal class DetailInset(val horizontal: Dp, val top: Dp) internal val LocalDetailInset = compositionLocalOf { DetailInset(0.dp, 0.dp) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt index d83e1abe1..d8b3c61a2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt @@ -43,7 +43,6 @@ import kotlin.math.atan2 import kotlin.math.hypot import kotlin.math.min -/** A small colour square; pressing it opens the colour picker. */ @Composable internal fun ColorCube(color: Color, description: String, focus: FocusRequester? = null, onClick: () -> Unit) { val pal = LocalPalette.current @@ -59,7 +58,6 @@ internal fun ColorCube(color: Color, description: String, focus: FocusRequester? ) } -/** Colour wheel (touch), hue / saturation / brightness sliders (controller and touch) and a hex box for one colour. */ @Composable internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Unit, onApply: (Int) -> Unit) { val shown = rememberShown(onDismiss) @@ -117,7 +115,6 @@ private fun Swatch(color: Color) { Box(Modifier.size(48.dp).clip(shape).background(color).border(1.dp, LocalPalette.current.line2, shape)) } -/** Hue around the circle and saturation out from the middle, at full brightness; brightness has its own slider. */ @Composable private fun ColorWheel(hue: Float, sat: Float, value: Float, onPick: (hue: Float, sat: Float) -> Unit) { val rim = MaterialTheme.colorScheme.onBackground diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index a934d39cf..a28326a4b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -930,7 +930,6 @@ private fun availableFor(l: String, catalog: List, snapshot: Snapsh return catalog.filter { it.comp == l && ComponentsManager.safeName(it.file) !in stored } } -// The rows to show: each stable row carries its paired nightly (if any), and a paired nightly has no row of its own. private fun availableRows(l: String, catalog: List, snapshot: Snapshot?): List> { val available = availableFor(l, catalog, snapshot) val pairs = ComponentsManager.nightlyPairs(available) @@ -1456,7 +1455,6 @@ private fun GetLine( Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } -// Stable | Nightly in the same box as SmallButton: one focus stop, A (or a tap) flips it. @Composable private fun ChannelToggle(nightly: Boolean, enabled: Boolean, onFlip: () -> Unit) { val colors = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt index 1afa23b71..45862d8ed 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt @@ -254,11 +254,8 @@ class DeckControlsPanel( show() } - /** Whether a finger going down at ([x], [y]) lands on one of the panel's controls. */ fun takesTouchAt(x: Float, y: Float): Boolean = controlUnder(this, x, y) - // Every control here takes the touches it gets: buttons and tabs (clickable), the scrolling tab strip, - // and the drawn surfaces, pads and face buttons. The layouts around them do not. private fun controlUnder(view: View, x: Float, y: Float): Boolean { if (view.visibility != VISIBLE || x < 0f || y < 0f || x >= view.width || y >= view.height) return false if (view.isClickable || view is HorizontalScrollView || view is Surface || view is DirectionPad || view is FaceButtons) return true @@ -715,10 +712,13 @@ class DeckControlsPanel( } } - internal fun menuButton(context: Context, onClick: () -> Unit) = android.widget.ImageView(context).apply { - setImageResource(R.drawable.ic_stat_session) + internal fun menuButton(context: Context, onClick: () -> Unit) = + iconButton(context, R.drawable.ic_stat_session, context.getString(R.string.touch_droiddeck_menu), onClick) + + internal fun iconButton(context: Context, icon: Int, description: String, onClick: () -> Unit) = android.widget.ImageView(context).apply { + setImageResource(icon) setColorFilter(TEXT) - contentDescription = context.getString(R.string.touch_droiddeck_menu) + contentDescription = description scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE val pad = dp(context, 11) setPadding(pad, pad, pad, pad) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt index 7a90ea33d..505bf4493 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt @@ -1,12 +1,18 @@ package com.droiddeck.launcher.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.droiddeck.launcher.R import com.droiddeck.launcher.hud.DroidDeckHudSize +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.StripMetric -/** Bannerlator's DroidDeck HUD settings (DeckOverlaySettings): one row per key of the HUD's config string. */ @Composable internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { val cfg = a.hudConfig @@ -29,7 +35,10 @@ internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (Stri size, chipModifier = track("hud-size")) { a.onHudSetting("hudSize", it.token) } ToggleRow(host, "hud-locked", stringResource(R.string.hud_locked), stringResource(R.string.hud_locked_hint), on("hudLocked"), chipModifier = track("hud-locked")) { a.onHudSetting("hudLocked", flag(it)) } + ToggleRow(host, "hud-snap", stringResource(R.string.hud_snap), stringResource(R.string.hud_snap_hint), on("hudSnap"), + chipModifier = track("hud-snap")) { a.onHudSetting("hudSnap", flag(it)) } } + if (a.dashboardAvailable && a.secondScreenDisplays.isNotEmpty()) BottomStripSettings(host, track) if (!a.hudOn) return SettingsGroup(stringResource(R.string.hud_group_look)) { @@ -95,12 +104,51 @@ internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (Stri private fun flag(on: Boolean) = if (on) "1" else "0" -// The Pill also draws the battery temperature and watts. +@Composable +private fun BottomStripSettings(host: MenuHost, track: (String) -> Modifier) { + val context = LocalContext.current + var hudOn by remember { mutableStateOf(SessionPrefs.bottomHud(context)) } + var onTabs by remember { mutableStateOf(SessionPrefs.bottomStripOnTabs(context)) } + var shown by remember { mutableStateOf(SessionPrefs.bottomStripMetrics(context)) } + SettingsGroup(stringResource(R.string.bottom_hud_group), detail = stringResource(R.string.bottom_hud_group_detail)) { + ToggleRow(host, "bottom-hud", stringResource(R.string.bottom_hud_group), stringResource(R.string.bottom_hud_hint), hudOn, + chipModifier = track("bottom-hud")) { on -> + hudOn = on + SessionPrefs.setBottomHud(context, on) + } + ToggleRow(host, "bottom-strip-tabs", stringResource(R.string.bottom_strip_tabs), stringResource(R.string.bottom_strip_tabs_hint), onTabs, + enabled = hudOn, chipModifier = track("bottom-strip-tabs")) { on -> + onTabs = on + SessionPrefs.setBottomStripOnTabs(context, on) + } + for ((metric, label) in BOTTOM_STRIP_ROWS) { + ToggleRow(host, "bottom-strip-${metric.id}", stringResource(label), null, metric in shown, + enabled = hudOn, chipModifier = track("bottom-strip-${metric.id}")) { on -> + shown = if (on) shown + metric else shown - metric + SessionPrefs.setBottomStripMetric(context, metric, on) + } + } + } +} + +private val BOTTOM_STRIP_ROWS = listOf( + StripMetric.ENGINE to R.string.hud_metric_engine, + StripMetric.GRAPH to R.string.hud_metric_graph, + StripMetric.LOWS to R.string.bottom_strip_lows, + StripMetric.LOW001 to R.string.hud_metric_low001, + StripMetric.GPU to R.string.hud_metric_gpu, + StripMetric.GPU_TEMP to R.string.hud_metric_gpu_temp, + StripMetric.CPU to R.string.hud_metric_cpu, + StripMetric.CPU_TEMP to R.string.bottom_strip_cpu_temp, + StripMetric.VRAM to R.string.hud_metric_vram, + StripMetric.RAM to R.string.hud_metric_ram, + StripMetric.CLOCK to R.string.hud_metric_clock, +) + private val EXTRA = mapOf(DroidDeckHudSize.PILL to setOf("Temp", "Power")) private class HudMetric(val chip: String, val key: String, val label: Int) -// DroidDeckHudSize.supportedChips() names; Wrapper and DX version have nothing to show in DroidDeck. private val METRICS = listOf( HudMetric("FPS", "showFPS", R.string.drawer_hud_fps), HudMetric("FPS graph", "showFPSGraph", R.string.hud_metric_graph), @@ -125,8 +173,6 @@ private val METRICS = listOf( HudMetric("Clock", "showClock", R.string.hud_metric_clock), ) -// Switches a size lists but never draws (DroidDeckHudView): the Pill and Minimal always show the FPS, the -// Pill has no graph, Tiles has no graph and no 0.01% low, the Strip always shows the FPS. private val HIDDEN = mapOf( DroidDeckHudSize.PILL to setOf("FPS", "FPS graph"), DroidDeckHudSize.MINIMAL to setOf("FPS"), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DualScreenPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DualScreenPanel.kt index e6825c415..b46afbb97 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DualScreenPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DualScreenPanel.kt @@ -128,8 +128,6 @@ object DualScreenPanel { val host = context as? ComponentActivity val target = host?.getSystemService(Context.DISPLAY_SERVICE) ?.let { (it as android.hardware.display.DisplayManager).getDisplay(display) } - // A second-display activity makes the system launcher treat its bottom home as lost and bring itself forward; - // a presentation belongs to this task and leaves that home alone. if (host != null && target != null) { val shown = runCatching { DualScreenPresentation(host, target).also { it.show() } } if (shown.isSuccess) { @@ -170,7 +168,6 @@ object DualScreenPanel { return true } - // The panel resumes when an app opens over the owner on the primary screen; pulling the owner back would hide that app. fun yieldTo(task: Int, app: Boolean) { if (ownerTask == task) ownerYielded = app } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt index bb1370c15..92a6d0b36 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -66,7 +66,6 @@ internal class FocusGlide(view: View?) { private set fun accepts(view: View): Boolean { - // DropdownMenu recreates its popup view after closing. if (this.view == null || deferredWindow && this.view !== view && host?.isAttached != true) this.view = view return this.view === view } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index a8f3f829f..45ef20fe8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -74,7 +74,6 @@ internal fun UninstallEpicLauncher(s: FrontEndState, a: FrontEndActions) { } } -/** A skipped sync is not a success: "0 files" used to show for a signed-out store or cloud saves turned off. */ internal fun cloudMessage(context: android.content.Context, r: com.droiddeck.launcher.stores.CloudSaves.Result): String = when (r.result) { "ok" -> when { r.conflicts > 0 -> context.getString(R.string.cloud_conflict_files, r.conflicts) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index dcd1f50ff..ecc597c93 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -203,7 +203,7 @@ internal fun railLabelIcon(key: String): Pair = when (key) { } internal fun setupNeedsAttention(s: FrontEndState): Boolean = !s.ready || (s.available != null && s.available != s.installed) || - PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) + PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) || com.droiddeck.launcher.sync.Syncthing.needsAttention @Composable private fun NavigationItems( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 1ac44a3a3..c557181e1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -298,8 +298,6 @@ internal object Motion { if (scale == 0f) snap() else spring(damping, stiffness) } -private val Shape10 = RoundedCornerShape(10.dp) - internal val Shape12 = RoundedCornerShape(12.dp) internal fun hueOf(name: String) = (name.hashCode().toUInt() % 360u).toFloat() @@ -319,13 +317,6 @@ internal fun Rise(i: Int, modifier: Modifier = Modifier, content: @Composable () ) { content() } } -@Composable -private fun Modifier.staggerIn(i: Int): Modifier { - val t = remember { Animatable(0f) } - LaunchedEffect(Unit) { t.animateTo(1f, Motion.tw(360, i * 30)) } - return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 10.dp.toPx() } -} - @Composable internal fun Modifier.shine(trigger: Boolean, strength: Float = 0.22f): Modifier { val x = remember { Animatable(-1f) } @@ -442,6 +433,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com "stores:downloads" -> { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" } "components" -> a.onComponents(true) "setup:launcher" -> { SetupTabs.openLauncher = true; selected = "setup" } + com.droiddeck.launcher.sync.SyncthingService.NAV -> com.droiddeck.launcher.sync.Syncthing.requestOpen() "setup:integration" -> { SetupTabs.openOverview = true; selected = "setup" } else -> selected = target } @@ -561,6 +553,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com LaunchedEffect(com.droiddeck.launcher.storage.StorageState.openRequests) { if (com.droiddeck.launcher.storage.StorageState.openPending) showRailPage("setup", true) } + LaunchedEffect(com.droiddeck.launcher.sync.Syncthing.openRequests) { + if (com.droiddeck.launcher.sync.Syncthing.openPending) showRailPage("setup", true) + } val onRailFocus: (String) -> Unit = { key -> if (key != railSelection) showRailPage(key, false) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c696ff615..230321f51 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -93,7 +93,8 @@ internal object SetupTabs { var openLauncher = false var openOverview = false } -private const val GLASSES_TAB = 5 +private const val GLASSES_TAB = 6 +private const val SYNC_TAB = 4 @Composable private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) { @@ -190,7 +191,7 @@ internal fun SetupPanel( onDismiss = { editDownloadProxy = false }, onSave = { a.onDownloadProxy(it); editDownloadProxy = 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), stringResource(R.string.setup_tab_glasses)) + 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_sync), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses)) var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) } SetupTabs.openLauncher = false if (SetupTabs.openOverview) { tab = 0; SetupTabs.openOverview = false } @@ -210,6 +211,18 @@ internal fun SetupPanel( a.onRefreshPhantomStatus() } } + LaunchedEffect(com.droiddeck.launcher.sync.Syncthing.openRequests) { + if (com.droiddeck.launcher.sync.Syncthing.openPending) { + com.droiddeck.launcher.sync.Syncthing.openPending = false + tab = SYNC_TAB + } + } + var syncAdvanced by rememberSaveable { mutableStateOf(false) } + if (syncAdvanced) { + androidx.activity.compose.BackHandler { syncAdvanced = false } + SyncAdvancedPage(host) { syncAdvanced = false } + return + } LaunchedEffect(tab) { if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) { androidx.compose.runtime.withFrameNanos { } @@ -524,7 +537,8 @@ internal fun SetupPanel( } } STORAGE_TAB -> StoragePanel(s, a) - 4 -> { + SYNC_TAB -> SyncPanel(host, runtimeOk) { syncAdvanced = true } + 5 -> { 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( @@ -720,7 +734,6 @@ private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.fronte GameSettingsMenus(host, game, state) } -/** The per-game settings menus, opened by setting [MenuHost.open] to [GAME_SETTINGS_MENU]. */ @Composable internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame, state: GameSettingsState) { val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext @@ -929,14 +942,12 @@ private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit } } -/** How fast Inner Gamer cycles its colours. */ @Composable private fun RgbSpeedRow() { val ctx = androidx.compose.ui.platform.LocalContext.current.applicationContext SliderRow(stringResource(R.string.theme_rgb_speed), stringResource(R.string.theme_rgb_speed_hint), Themes.rgbSpeed, 1..Themes.RGB_SPEEDS) { Themes.setRgbSpeed(ctx, it) } } -/** The two colours of the Gradient theme, each a small square that opens the colour picker. */ @Composable private fun GradientColorRows() { val ctx = androidx.compose.ui.platform.LocalContext.current.applicationContext diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 6f1aad142..2ec816eb1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -264,23 +264,3 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo } } -@Composable -internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - var spins by remember { mutableIntStateOf(0) } - val rot by animateFloatAsState((if (hot) 90f else 0f) + spins * 180f, Motion.sp(0.55f), label = "cog") - val pal = LocalPalette.current - val placed = remember { arrayOfNulls(1) } - val cornerPx = with(androidx.compose.ui.platform.LocalDensity.current) { (if (size > 48.dp) 14.dp else 12.dp).toPx() } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("cog").downToFirstTile().size(size).onGloballyPositioned { placed[0] = it }.clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2) - .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current) { - placed[0]?.takeIf { it.isAttached }?.let { PageOrigin.mark(Origin(it.boundsInRoot(), cornerPx)) } - spins++ - onClick() - }, - ) { Icon(Icons.Filled.Settings, stringResource(R.string.drawer_page_settings), tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt index aa193883d..f2f7d5ee9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt @@ -115,7 +115,6 @@ import java.io.File import androidx.compose.runtime.setValue import androidx.compose.runtime.getValue -/** What a game page shows: `app:` for a game in the library, `[app:]:` for a store product. */ internal class GameRef(val appId: Int?, val store: Store?, val storeId: String?) { fun game(games: List): Library.SteamGame? = when { appId != null -> games.firstOrNull { it.appId == appId } @@ -195,7 +194,6 @@ internal fun GameDetail( var inPage by remember(key) { mutableStateOf(false) } var onStrip by remember(key) { mutableStateOf(false) } var dropped by remember(key) { mutableStateOf(0) } - // A tab's content is swapped or refreshed under the focused row; focus goes back to the tabs, not to the rail. LaunchedEffect(dropped) { if (dropped == 0) return@LaunchedEffect androidx.compose.runtime.withFrameNanos { } @@ -319,7 +317,6 @@ private fun GameStatusLine(source: String, game: Library.SteamGame?, item: Catal } } -/** One frosted pill that holds the game's actions and its headline scores. */ @Composable private fun GameCapsule(scores: GameScores?, actions: @Composable () -> Unit) { Row( @@ -500,7 +497,6 @@ internal fun GameActions( } } -/** Xbox Cloud Gaming rows the user added to the Library, for games not installed here. */ @Composable internal fun rememberXboxCloudRows(s: FrontEndState): List { val ctx = LocalContext.current @@ -535,7 +531,6 @@ private fun XboxInstalledStream(id: String, title: String, item: CatalogItem?) { private fun XboxLibraryButton(id: String, item: CatalogItem?) { val ctx = LocalContext.current LaunchedEffect(Unit) { XboxCloudGames.load(ctx) } - // Add and Remove replace each other; the controller focus moves to the new button instead of getting lost. val slot = remember { FocusRequester() } val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current val scope = rememberCoroutineScope() @@ -572,7 +567,6 @@ private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) { } } -/** The pencil opens the editor each kind of game always had: the shortcut editor for added games, art for the rest. */ @Composable private fun rememberEditArt(g: Library.SteamGame): (() -> Unit)? { val ctx = LocalContext.current @@ -593,7 +587,6 @@ private fun EditArtButton(g: Library.SteamGame) { IconAction(Icons.Outlined.Edit, stringResource(R.string.game_edit_art), "game:edit", onClick = open) } -/** Narrow screens keep one More button in the capsule instead of a row of small ones. */ @Composable private fun GameMoreMenu(g: Library.SteamGame, a: FrontEndActions) { var open by remember(g.gameId) { mutableStateOf(false) } @@ -932,7 +925,6 @@ private fun InstallTab(game: Library.SteamGame?, installed: InstalledStoreGame?, } } -/** Text a controller has to land on to bring it into view: focusable, with the focus ring. */ @Composable internal fun Modifier.readable(): Modifier { val pal = LocalPalette.current @@ -953,7 +945,6 @@ internal fun FactRow(label: String, value: String, labelWidth: androidx.compose. } } -/** Store installs are measured by [StoreSizes]; Steam and added games by walking their folder once. */ @Composable internal fun diskSize(installed: InstalledStoreGame?, folder: File): Long? { val ctx = LocalContext.current diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameGlass.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameGlass.kt index dd5668ee8..a2660d1b4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameGlass.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameGlass.kt @@ -55,13 +55,8 @@ import coil.request.ImageRequest import coil.size.Precision import kotlin.math.max -/** Buttons in a capsule render as pills; elsewhere they keep their rounded-rectangle shape. */ internal val LocalPillButtons = compositionLocalOf { false } -/** - * The page's blurred art, shared so frosted surfaces can draw the part of it behind them. - * Coordinates are read when drawing, so a surface and the backdrop always agree on where they are. - */ internal class GlassBackdrop { var painter: Painter? = null var coords: LayoutCoordinates? = null @@ -79,7 +74,6 @@ internal class GlassBackdrop { scope.drawCover(art, size, if (light) LightAmbient else DarkAmbient) } - /** Drawn inside a layer, so it fades what was painted before it toward the page's end. */ fun fadeBottom(scope: DrawScope, size: Size) { val fade = minOf(size.height * 0.5f, with(scope) { 240.dp.toPx() }) if (size.height <= 0f) return @@ -121,7 +115,6 @@ private fun ambientRequest(ctx: android.content.Context, model: Any?): ImageRequ private const val AmbientPixels = 6 -/** How washed-out the art is: average brightness less its saturation, from a tiny decode, so pale art is dimmed while dark and vivid art are left alone. */ @Composable private fun rememberArtBrightness(model: Any?): Float { val ctx = LocalContext.current @@ -148,10 +141,6 @@ private fun rememberArtBrightness(model: Any?): Float { private const val SamplePixels = 8 private const val SaturationWeight = 0.6 -/** - * A page whose game art fills the background: a blurred wash behind everything and the sharp art - * across the top, fading into the page. Surfaces inside draw frosted glass over the wash. - */ @Composable internal fun GameBackdrop( art: Any?, ambient: Any?, fallbackHue: Float, @@ -241,7 +230,6 @@ private fun ArtLayer(model: Any?, sample: Any?, fadeTop: Boolean, onPlaced: (and private val ArtMin = 220.dp private val ArtMax = 340.dp -/** A frosted surface: the page's wash behind it, a dark tint, and a thin rim. Plain surface when there is no backdrop. */ @Composable internal fun Modifier.glass(shape: Shape, lift: Float = 0f): Modifier { val g = LocalGlass.current diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt b/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt index 56643effe..2ada3923e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt @@ -41,7 +41,6 @@ internal fun filterGestureActions(actions: List, query: String, g action.title.contains(word, ignoreCase = true) || action.id.removePrefix("key_").replace('_', ' ').contains(word, ignoreCase = true) } }.sortedWith(compareBy { it.group.ordinal }.thenComparator { a, b -> - // Function keys follow their numeric sequence; names use the UI locale. if (a.group == GestureActionGroup.FUNCTION) { a.id.removePrefix("key_F").toInt().compareTo(b.id.removePrefix("key_F").toInt()) } else collator.compare(a.title, b.title) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPhonePanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPhonePanel.kt index 625c30efb..f5e9138bd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPhonePanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPhonePanel.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R -/** Actions for the phone while the picture is on the glasses. Null hides the button. */ internal class GlassesPhoneActions( val onRecenter: (() -> Unit)?, val onRecenterJoyCons: (() -> Unit)?, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt index fddb98c60..69936548c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -225,7 +225,6 @@ internal fun GpuDriversBody( val scroll = rememberScrollState() val scope = rememberCoroutineScope() val placed = remember { arrayOfNulls(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 { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt index fe0866e8a..482aeb5a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt @@ -112,7 +112,6 @@ import com.droiddeck.launcher.stores.sortGames import kotlinx.coroutines.launch import androidx.compose.runtime.setValue -/** One owned game: installed ones carry their [game], store products not installed yet only their [item]. */ internal class LibraryEntry( val key: String, val source: String, val title: String, val game: Library.SteamGame?, val item: CatalogItem?, val installed: Boolean, @@ -120,7 +119,6 @@ internal class LibraryEntry( val storeKey: String? get() = item?.key ?: game?.storeId?.let { "${game.source}:$it" } } -/** Installed games first claim their store product, so a game owned and installed shows once. */ internal fun libraryEntries( games: List, owned: Map>, installedStore: Set, ): List { @@ -159,7 +157,6 @@ internal fun librarySources(entries: List, order: List): L (listOf(Library.SOURCE_STEAM) + arrangeStoreTabs(order, emptySet()).map { it.store.id } + Library.ADDED) .filter { source -> entries.any { it.source == source } } -/** Survives leaving the page, so Stores can open the Library on one store's owned games. */ internal object LibraryDisplay { val scales = listOf(0.75f, 0.875f, 1f, 1.2f, 1.45f) var level by mutableIntStateOf(2) @@ -797,7 +794,6 @@ internal fun HeaderIconButton(description: String, icon: androidx.compose.ui.gra ) { Icon(icon, null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) } } -/** Choosing Library in the rail lands on the first game, as Play Steam does on the Steam page. */ @Composable internal fun Modifier.paneEntry(): Modifier { val ff = LocalFrontFocus.current ?: return this diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt index 2ddbdbe28..a6501dacf 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt @@ -57,10 +57,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -/** Steam's library hero is 3840 x 1240, so the preview keeps that shape. */ private const val HERO_RATIO = 3840f / 1240f -/** The gap Steam leaves between the pinned logo and the edge of the hero, as a share of the hero's width. */ private const val EDGE = 0.03f @Composable @@ -86,10 +84,6 @@ private fun LogoPosition.Pin.alignment(): Alignment = when (this) { internal fun logoPositionSummary(value: LogoPosition.Value?): String = if (value == null) stringResource(R.string.logo_pos_default) else "${value.pin.label()} · ${value.widthPct}%" -/** - * Where a game's logo sits on its background in Steam's library: the logo box is [Value.widthPct] x [Value.heightPct] of the - * background, pinned to one of five places, and the logo is drawn as large as fits in it. - */ @Composable private fun LogoPreview(hero: File?, logo: File?, value: LogoPosition.Value) { val shape = RoundedCornerShape(10.dp) @@ -130,7 +124,6 @@ private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, on } } -/** The "Adjust logo" screen: the game's background with its logo on it, where to pin the logo and how large to draw it. */ @OptIn(ExperimentalLayoutApi::class) @Composable internal fun LogoPositionDialog( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index d81015d61..63508ba42 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -68,11 +68,13 @@ class ModeSettings( val pipSupported: Boolean = false, val pipAutoEnter: Boolean = false, val oscMode: String?, + val backHold: Boolean = true, val backActionsInverted: Boolean = false, val directAudio: Boolean?, val mic: Boolean?, val gameStorage: String? = null, val storageOptions: List = emptyList(), + val steamLibraries: List = emptyList(), val storageDiagnostics: Boolean = false, val fexPreset: String? = null, val forceSsbs: Boolean = false, @@ -80,9 +82,7 @@ class ModeSettings( val picturePosition: String? = null, val stretch16x9: Boolean? = null, val steamClientArch: String? = null, - /** Steam only: a game launched from the front end returns there when it closes; null hides the row. */ val returnAfterGame: Boolean? = null, - /** Steam only: the client's Shader Pre-Caching setting; null hides the row. */ val shaderPrecache: Boolean? = null, val steamRepairQueued: Boolean = false, val mangoapp: Boolean = true, @@ -118,12 +118,16 @@ class ModeSettingsActions( val onSteamDownloadsInBackground: (Boolean) -> Unit = {}, val onPipAutoEnter: (Boolean) -> Unit = {}, val onOsc: (String) -> Unit, + val onBackHold: (Boolean) -> Unit = {}, val onBackActionsInverted: (Boolean) -> Unit = {}, val onDirectAudio: (Boolean) -> Unit, val onMic: (Boolean) -> Unit, val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> }, val onPickGameStorageFolder: () -> Unit = {}, val onStorageDiagnostics: (Boolean) -> Unit = {}, + val onAddSteamLibrary: () -> Unit = {}, + val onRenameSteamLibrary: (id: String, label: String) -> Unit = { _, _ -> }, + val onRemoveSteamLibrary: (id: String) -> Unit = {}, val onFexPreset: (String) -> Unit = {}, val onForceSsbs: (Boolean) -> Unit = {}, val onSyncBackend: (String) -> Unit = {}, @@ -154,15 +158,12 @@ class ModeSettingsActions( val onDismiss: () -> Unit, ) -/** "2610 r3" from droiddeck-fex-update's FEX_TAG line (fex-2610-r3), the build x86 programs fall back to. */ internal fun fexVersionOf(script: String): String? = Regex("""(?m)^FEX_TAG=(fex-\d+-r\d+)\s*$""").find(script)?.let { fexVersionOfTag(it.groupValues[1]) } -/** "2610 r5" from a tag such as fex-2610-r5. */ internal fun fexVersionOfTag(tag: String): String? = Regex("""fex-(\d+)-r(\d+)""").matchEntire(tag.trim())?.let { "${it.groupValues[1]} r${it.groupValues[2]}" } -/** The build installed in the runtime, else the one fex.json names, else the one the launcher pins. */ @Composable private fun x64FexVersion(context: android.content.Context): String? = remember { val root = com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context) @@ -223,7 +224,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: } if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) || com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) { - val canStretch = s.resolution != com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN && + val canStretch = !com.droiddeck.launcher.session.SessionDisplay.isFollow(s.resolution) && com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize) ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit), stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable), @@ -296,6 +297,10 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.unfolded_controls_with_controller_hint), s.unfoldedControlsWithController, onChange = a.onUnfoldedControlsWithController, ) + if (steam) ToggleRow( + host, "back-hold", stringResource(R.string.back_hold), + stringResource(R.string.back_hold_hint), s.backHold, onChange = a.onBackHold, + ) if (steam) ChoiceRow( host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)), listOf( @@ -303,7 +308,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU), ), s.backActionsInverted, onPick = a.onBackActionsInverted, ) - if (steam && s.steamController in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) { + if (steam && s.backHold && s.steamController in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) { Note(stringResource(R.string.back_native_qam_hint)) } PhysicalShortcutRows() @@ -401,6 +406,34 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: onChange = a.onStorageDiagnostics, ) } + if (s.gameStorage != null) SettingsGroup(stringResource(R.string.libraries_title), detail = stringResource(R.string.libraries_applies_next)) { + var renaming by remember { mutableStateOf(null) } + for (library in s.steamLibraries) { + val attached = library.host.isDirectory + SettingsRow(library.label, if (attached) library.path else stringResource(R.string.libraries_missing, library.path)) { + Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) { + SecondaryButton(stringResource(R.string.libraries_rename), compact = true) { renaming = library } + SecondaryButton(stringResource(R.string.edit_remove), compact = true) { a.onRemoveSteamLibrary(library.id) } + } + } + } + SettingsRow( + stringResource(R.string.libraries_add), + if (s.steamLibraries.size < com.droiddeck.launcher.session.SteamLibraries.MAX) stringResource(R.string.libraries_hint) + else stringResource(R.string.libraries_full, com.droiddeck.launcher.session.SteamLibraries.MAX), + ) { + SecondaryButton( + stringResource(R.string.mode_choose_folder), + enabled = s.steamLibraries.size < com.droiddeck.launcher.session.SteamLibraries.MAX, + onClick = a.onAddSteamLibrary, + ) + } + renaming?.let { library -> + TextEntryDialog(stringResource(R.string.libraries_rename_title), library.label, onDismiss = { renaming = null }) { name -> + a.onRenameSteamLibrary(library.id, name) + } + } + } } if (tab == SheetTab.STEAM && steam) { extras.account(host) @@ -665,3 +698,23 @@ internal fun CustomResolutionDialog(initial: Pair?, onSave: (Pair Unit, onDismiss: () -> Unit) { + var text by remember { mutableStateOf(initial?.let(com.droiddeck.launcher.session.SessionDisplay::formatScale) ?: "") } + val parsed = com.droiddeck.launcher.session.SessionDisplay.parseFollowScale(text) + com.droiddeck.launcher.ui.GlideAlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.display_follow_screen_scaled, "…")) }, + text = { + androidx.compose.material3.OutlinedTextField( + text, { v -> text = v.filter { it.isDigit() || it == '.' || it == ',' }.take(4) }, + label = { Text("÷") }, singleLine = true, isError = parsed == null && text.isNotEmpty(), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal), + modifier = Modifier.focusGlide(), + ) + }, + confirmButton = { androidx.compose.material3.TextButton(enabled = parsed != null, onClick = { parsed?.let(onSave) }, modifier = Modifier.focusGlide(CircleShape)) { Text(stringResource(R.string.common_use)) } }, + dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss, modifier = Modifier.focusGlide(CircleShape)) { Text(stringResource(R.string.common_cancel)) } }, + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PageSwipe.kt b/app/src/main/java/com/droiddeck/launcher/ui/PageSwipe.kt index 94c7a8cf3..3e4b57fb2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PageSwipe.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PageSwipe.kt @@ -21,7 +21,6 @@ internal object PageSwipe { var direction by mutableIntStateOf(0) } -// Runs on the Final pass so carousels, sliders, scrollers and widgets that consumed the drag keep it. @Composable internal fun Modifier.pageSwipe(enabled: () -> Boolean, onSwipe: (Int) -> Unit): Modifier { val enabledNow by rememberUpdatedState(enabled) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialog.kt index 3ad5cea7a..7331d49fb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialog.kt @@ -179,7 +179,6 @@ private fun CaptureShortcutKeys(onKey: (KeyEvent) -> Boolean) { val window = (LocalView.current.parent as DialogWindowProvider).window DisposableEffect(window) { val previous = window.callback - // Capture before dialog navigation so Back and gamepad buttons can be assigned. window.callback = object : Window.Callback by previous { override fun dispatchKeyEvent(event: KeyEvent): Boolean = currentOnKey(event) || previous.dispatchKeyEvent(event) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt b/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt index 8db8910a8..7112d4f39 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt @@ -20,22 +20,34 @@ fun ResolutionRow( val size = SessionDisplay.resolveChoice(panel, choice, portrait) val screen = SessionDisplay.resolveChoice(panel, SessionDisplay.MATCH_SCREEN, portrait) val choices = SessionDisplay.resolutionOptions(panel) - val selected = if (choice == SessionDisplay.FOLLOW_SCREEN) choice + val customFollow = SessionDisplay.isFollow(choice) && choice !in choices + val selected = if (SessionDisplay.isFollow(choice)) choice else if (size == screen) SessionDisplay.MATCH_SCREEN else choice var editCustom by remember { mutableStateOf(false) } + var editFollow by remember { mutableStateOf(false) } val options = choices.map { value -> val dimensions = SessionDisplay.resolveChoice(panel, value, portrait) value to if (value == SessionDisplay.MATCH_SCREEN) stringResource(R.string.display_match_screen, screen.first, screen.second) else if (value == SessionDisplay.FOLLOW_SCREEN) stringResource(R.string.display_follow_screen) + else if (SessionDisplay.isFollow(value)) stringResource(R.string.display_follow_screen_scaled, + SessionDisplay.formatScale(SessionDisplay.followScale(value) ?: 1f)) + " (${dimensions.first}×${dimensions.second})" else if (value == SessionDisplay.DEFAULT_RESOLUTION) stringResource(R.string.mode_res_default, "${dimensions.first}×${dimensions.second}") else "${dimensions.first}×${dimensions.second}" - } + (custom to if (selected in choices) stringResource(R.string.mode_res_custom) + } + listOf(SessionDisplay.FOLLOW_CUSTOM to stringResource(R.string.display_follow_screen_scaled, + if (customFollow) SessionDisplay.formatScale(SessionDisplay.followScale(choice) ?: 1f) else "…" + ) + if (customFollow) " (${size.first}×${size.second})" else "") + (custom to if (selected in choices) stringResource(R.string.mode_res_custom) else stringResource(R.string.mode_res_custom_value, size.first, size.second)) ChoiceRow(host, "resolution", stringResource(R.string.display_resolution), stringResource(R.string.common_applies_next_session), - options, if (selected in choices) selected else custom, chipModifier = chipModifier, - onPick = { value -> if (value == custom) editCustom = true else onPick(value) }) + options, if (selected in choices) selected else if (customFollow) SessionDisplay.FOLLOW_CUSTOM else custom, chipModifier = chipModifier, + onPick = { value -> + if (value == custom) editCustom = true + else if (value == SessionDisplay.FOLLOW_CUSTOM) editFollow = true + else onPick(value) }) if (editCustom) CustomResolutionDialog(size, onSave = { value -> editCustom = false; onPick("${value.first}x${value.second}") }, onDismiss = { editCustom = false }) + if (editFollow) CustomFollowScaleDialog(SessionDisplay.followScale(choice)?.takeIf { it > 1f }, + onSave = { value -> editFollow = false; onPick(SessionDisplay.followScaled(value)) }, + onDismiss = { editFollow = false }) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index 18f54cf5d..df4182e0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -34,6 +34,7 @@ import com.droiddeck.launcher.input.PointerGestures import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.input.TouchpadGestures import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.session.BottomDesktop import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState import com.droiddeck.launcher.session.SessionTerminal @@ -199,7 +200,8 @@ class SecondScreenPresentation( } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val ownKeys = currentMode == SecondScreenMode.TERMINAL || currentMode == SecondScreenMode.DASHBOARD + val ownKeys = currentMode == SecondScreenMode.TERMINAL || currentMode == SecondScreenMode.DASHBOARD || + currentMode == SecondScreenMode.DESKTOP val forward = menuShown || !ownKeys || PadBridge.isControllerKeyEvent(event) if (forward && sessionActivity.dispatchKeyEvent(event)) return true return super.dispatchKeyEvent(event) @@ -231,6 +233,7 @@ class SecondScreenPresentation( val mode = when { currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad -> SecondScreenMode.NONE currentMode == SecondScreenMode.DASHBOARD && dashboard == null -> SecondScreenMode.NONE + currentMode == SecondScreenMode.DESKTOP && !BottomDesktop.available() -> SecondScreenMode.NONE else -> currentMode } if (mode == SecondScreenMode.NONE) return @@ -240,6 +243,11 @@ class SecondScreenPresentation( SecondScreenMode.TERMINAL -> renderTerminal() SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad, onMenu = onMenu), weightParams()) SecondScreenMode.DASHBOARD -> renderDashboard() + SecondScreenMode.DESKTOP -> { + ownComposeViews() + root.addView(BottomDesktopPanel(context, onClose, onMenu, + (onSteamMenu to onQam).takeIf { SessionState.mode == SessionService.MODE_STEAM }), weightParams()) + } SecondScreenMode.NONE -> Unit } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 6ff0ee910..81c084d6e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.Dashboard +import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.AutoFixHigh import androidx.compose.material.icons.outlined.ExpandMore @@ -221,6 +222,8 @@ class DrawerActions( val micAvailable: Boolean = false, val micEnabled: Boolean = false, val onMicEnabled: (Boolean) -> Unit = {}, + val backHold: Boolean = true, + val onBackHold: (Boolean) -> Unit = {}, val backActionsInverted: Boolean, val backDoubleTapMs: Int = 500, val onBackDoubleTapMs: (Int) -> Unit = {}, @@ -242,6 +245,7 @@ class DrawerActions( val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF, val secondScreenMode: SecondScreenMode, val dashboardAvailable: Boolean = false, + val desktopAvailable: Boolean = false, val secondScreenDisplays: List, val selectedSecondScreenDisplay: Int, val onHud: (Boolean) -> Unit, @@ -641,6 +645,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton) } } + if (a.steam) ToggleRow(host, "back-hold", stringResource(R.string.back_hold), + stringResource(R.string.back_hold_hint), a.backHold, + chipModifier = focus.track(page, "back-hold"), onChange = a.onBackHold) if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null, listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)), a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted) @@ -896,6 +903,7 @@ private fun BottomScreenModes(a: DrawerActions, modifier: Modifier, onPick: () - add(SecondScreenMode.KEYBOARD_TRACKPAD to Icons.Outlined.Keyboard) add(SecondScreenMode.TERMINAL to Icons.Outlined.Terminal) if (a.dashboardAvailable) add(SecondScreenMode.DASHBOARD to Icons.Outlined.Dashboard) + if (a.desktopAvailable) add(SecondScreenMode.DESKTOP to Icons.Outlined.DesktopWindows) } Row( verticalAlignment = Alignment.CenterVertically, @@ -1194,7 +1202,8 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) val pal = LocalPalette.current val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) + (if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()) + - (if (a.dashboardAvailable) listOf(SecondScreenMode.DASHBOARD) else emptyList()) + (if (a.dashboardAvailable) listOf(SecondScreenMode.DASHBOARD) else emptyList()) + + (if (a.desktopAvailable) listOf(SecondScreenMode.DESKTOP) else emptyList()) SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) { Column(Modifier.fillMaxWidth().selectableGroup()) { modes.forEach { mode -> @@ -1596,7 +1605,6 @@ internal fun ComponentDownloadsDrawerGroup( } AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = ComponentsManager.LABEL.getValue(comp)) { firstItemFocus -> - // A nightly paired with a stable build rides on that row; Stable/Nightly tabs beside the row pick which one. val pairs = ComponentsManager.nightlyPairs(items) val rows = items.filter { it !in pairs.values } fun shown(row: ComponentsManager.CatalogItem) = pairs[row]?.takeIf { nightlyPicked[row.file] == true } ?: row diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt index 2b9127306..3e5551ab3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt @@ -72,7 +72,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -/** The last game frame, captured as the session left the screen, and where it sat in the overlay. */ class PausedFrame(val image: ImageBitmap, val bounds: android.graphics.Rect) private const val CARD_SCALE = 0.82f @@ -125,7 +124,6 @@ fun SessionPausedOverlay( onFrameNeeded() withTimeoutOrNull(FRAME_WAIT_MS) { snapshotFlow { currentFrame }.first { it != null } } } - // Pausing mostly happens while the screen is off or the app is away, so it appears already settled. press.snapTo(1f) ring.snapTo(0f) lift.snapTo(1f) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 50df03a74..b389992cf 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -489,10 +489,11 @@ fun ChoiceRow( options: List>, selected: T, enabled: Boolean = true, note: String? = null, chipModifier: Modifier = Modifier, hintLines: Int? = null, + stacked: Boolean = false, onPick: (T) -> Unit, ) { val open = host.open == key - SettingsRow(label, hint, highlighted = open, hintLines = hintLines) { + SettingsRow(label, hint, highlighted = open, hintLines = hintLines, stacked = stacked) { Box { ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier.testTag("setting-$key")) { host.open = if (open) null else key } AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus -> diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsDashes.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsDashes.kt index 9f185b824..fc7679943 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StatsDashes.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsDashes.kt @@ -58,12 +58,10 @@ import kotlin.math.roundToInt import kotlin.math.sin private val DASH_H = 290.dp -/** Pedal and speed trace length: 150 readings at the page's 66 ms draw rate, about 10 s. */ internal const val TRACE_N = 150 internal class TraceData(val throttle: List, val brake: List, val speed: List) -/** The racing dash in [style] with speeds in [unit] (km/h or mph); a tap moves to the next one. */ @Composable internal fun RaceDashView(style: StatsDash, t: Telemetry, trace: TraceData, unit: SpeedUnit, onTap: () -> Unit) { Box(Modifier.fillMaxWidth().clickable(onClick = onTap)) { @@ -87,7 +85,6 @@ private const val DIAL_SWEEP = 250f private fun dialAngle(v: Float, max: Float) = DIAL_START + DIAL_SWEEP * (v / max).coerceIn(0f, 1f) -/** A 250° dial face: track, optional red zone from [red], ticks and numbers every [major]. */ private fun DrawScope.dialFace( cx: Float, cy: Float, r: Float, max: Float, major: Float, label: (Float) -> String, measurer: TextMeasurer, labelStyle: TextStyle, tick: Color, minor: Color, track: Color, red: Float?, redColor: Color, @@ -468,7 +465,6 @@ private fun EdgeBar(value: Float?, color: Color) { } } -/** War Thunder's own labels the Flight layout places; others stay in the list. */ internal object FlightLabels { const val IAS = "IAS, km/h" const val ALTITUDE = "H, m" @@ -573,7 +569,6 @@ private fun Pips(pips: List, modifier: Modifier) { val pipH = (size.height - labelH) / 4f - 4.dp.toPx() for (col in 0 until 3) { val x = col * colW + (colW - pipW) / 2f - // Status.json counts half pips: 0..8 per system. val value = pips[col] / 2f for (i in 0 until 4) { val y = size.height - labelH - (i + 1) * (pipH + 4.dp.toPx()) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt new file mode 100644 index 000000000..8caa4e6de --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt @@ -0,0 +1,667 @@ +package com.droiddeck.launcher.ui + +import android.graphics.BlurMaskFilter +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.hud.DroidDeckMark +import com.droiddeck.launcher.hud.FrameStats +import com.droiddeck.launcher.hud.HudMetrics +import com.droiddeck.launcher.session.StatsHud +import java.io.File +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +internal class CoreGroup(val cores: List, val maxGhz: Double?) + +internal class HudData( + val fps: Float?, + val avgFps: Float?, + val lows: FrameStats.Lows, + val graph: FloatArray, + val slowPerMinute: Int?, + val engine: String, + val resolution: String, + val refreshHz: Int, + val proton: String?, + val device: HudMetrics.Snapshot?, + val gpuName: String, + val fanRpm: Long?, + val title: String?, + val cover: File?, + val played: String?, + val clock: String, + val fpsHistory: List, + val cpuHistory: List, + val gpuHistory: List, + val coreGroups: List, + val gameCores: Set, + val fahrenheit: Boolean, + val tempColor: (HudMetrics.TempSensor, Int) -> Color, + val redC: (HudMetrics.TempSensor) -> Int?, + val historySeconds: Int = 0, +) { + val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it } + val hz: Int get() = if (refreshHz > 0) refreshHz else 60 +} + +private object HudInk { + val value = Color(0xFFF2F5F9) + val dim = Color(0xFF9AA4B2) + val low = Color(0xFFE4E8EE) + val vram = Color(0xFFC98BFF) + val sep = Color(0xFF5D6876) + val header = Color(0xFFEAF4FF) + val amber = Color(0xFFFFB74D) + val tile = Color(0x0EFFFFFF) +} + +private val Mono = FontFamily.Monospace +private val HEADER = 40.dp +private const val UNIT = 0.62f + +@Composable +internal fun StatsHudView(style: StatsHud, d: HudData, modifier: Modifier, onTap: () -> Unit) { + val accent = LocalPalette.current.primary + val night = style == StatsHud.NIGHT + Box(modifier.clickable(onClick = onTap)) { + HudPanel(d.clock, if (style == StatsHud.GRAPHS) stringResource(R.string.stats_hud_last_seconds, d.historySeconds) else null, night, accent) { + when (style) { + StatsHud.BIG_FPS -> BigFps(d, accent) + StatsHud.FULL -> Full(d, accent) + StatsHud.TILES -> Tiles(d, accent) + StatsHud.RINGS -> Rings(d, accent) + StatsHud.GRAPHS -> Graphs(d, accent) + StatsHud.CORES -> Cores(d, accent) + StatsHud.HEAT -> Heat(d) + StatsHud.GAME -> GameCard(d, accent) + StatsHud.PACING -> Pacing(d, accent) + StatsHud.NIGHT -> Night(d, night) + StatsHud.MEGA -> Mega(d, accent) + } + } + } +} + +@Composable +private fun HudPanel(clock: String, right: String?, dim: Boolean, accent: Color, content: @Composable ColumnScope.() -> Unit) { + val headerInk = if (dim) Color(0xFF7D8794) else HudInk.header + Column( + Modifier.fillMaxSize() + .drawWithCache { + val w = size.width + val h = size.height + val stroke = 2.dp.toPx() + val corner = 7.dp.toPx() + val radius = min(h / 2f, 64.dp.toPx()) + val headerPx = HEADER.toPx() + val a = accent.toArgb() + val body = dPath(0f, 0f, w, h, radius, corner) + val half = stroke / 2f + val outline = dPath(half, half, w - half, h - half, radius - half, corner) + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = if (dim) argb(245, 0xFF040507.toInt()) else argb(240, 0xFF0E1014.toInt()) } + val band = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient(0f, 0f, w, 0f, argb(if (dim) 20 else 56, a), argb(0, a), Shader.TileMode.CLAMP) + } + val divider = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = argb(if (dim) 38 else 90, a) } + val blur = BlurMaskFilter(4.dp.toPx(), BlurMaskFilter.Blur.NORMAL) + val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = stroke; color = argb(if (dim) 90 else 255, a) } + val markH = min(h, w) * 0.55f + val markX = w - DroidDeckMark.widthFor(markH) - 16.dp.toPx() + val markBody = android.graphics.Color.argb(if (dim) 9 else 20, 255, 255, 255) + val markOrb = argb(if (dim) 9 else 20, a) + onDrawBehind { + drawIntoCanvas { canvas -> + val c = canvas.nativeCanvas + c.drawPath(body, fill) + c.save() + c.clipPath(body) + c.drawRect(0f, 0f, w, headerPx + 1f, band) + c.drawRect(0f, headerPx, w, headerPx + max(1f, density), divider) + DroidDeckMark.draw(c, markX, (h - markH) / 2f, markH, markBody, markOrb) + c.restore() + for (pass in 0..2) { + line.maskFilter = if (pass < 2) blur else null + c.drawPath(outline, line) + } + line.maskFilter = null + } + } + } + .padding(start = 24.dp, end = 34.dp, bottom = 14.dp), + ) { + Row(Modifier.fillMaxWidth().height(HEADER), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DroidDeckLogo(16.dp, body = headerInk, orb = if (dim) accent.copy(alpha = 0.5f) else accent) + Text("DroidDeck", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = headerInk, maxLines = 1) + if (clock.isNotEmpty()) { + Text("·", fontSize = 13.sp, fontFamily = Mono, color = HudInk.sep) + Text(clock, fontSize = 13.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.dim, maxLines = 1) + } + if (right != null) { + Spacer(Modifier.weight(1f)) + Text(right, fontSize = 13.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.dim, maxLines = 1) + } + } + Spacer(Modifier.height(12.dp)) + content() + } +} + +private fun argb(alpha: Int, rgb: Int): Int = (alpha shl 24) or (rgb and 0xFFFFFF) + +private fun dPath(l: Float, t: Float, r: Float, b: Float, rad: Float, corner: Float) = android.graphics.Path().apply { + moveTo(l, t + corner) + lineTo(l, b - corner); quadTo(l, b, l + corner, b) + lineTo(r - rad, b) + arcTo(r - 2f * rad, b - 2f * rad, r, b, 90f, -90f, false) + lineTo(r, t + rad) + arcTo(r - 2f * rad, t, r, t + 2f * rad, 0f, -90f, false) + lineTo(l + corner, t); quadTo(l, t, l, t + corner) + close() +} + +private fun f1(v: Float?): String = v?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–" + +private fun gib(bytes: Long?): String = bytes?.let { "%.1f".format(it / 1073741824.0) } ?: "–" + +private fun AnnotatedString.Builder.num(value: String, unit: String, size: TextUnit, color: Color = HudInk.value, unitScale: Float = UNIT) { + withStyle(SpanStyle(color = color)) { append(value) } + if (unit.isNotEmpty()) withStyle(SpanStyle(color = HudInk.dim, fontSize = size * unitScale)) { append(unit) } +} + +private fun AnnotatedString.Builder.gap() = append(" ") + +private fun tempLabel(d: HudData, c: Int): String = if (d.fahrenheit) "${c * 9 / 5 + 32}°F" else "$c°C" + +private fun AnnotatedString.Builder.temp(d: HudData, sensor: HudMetrics.TempSensor, c: Int?, size: TextUnit) { + if (c == null) { num("–", "", size); return } + val shown = if (d.fahrenheit) (c * 9 / 5 + 32) else c + num(shown.toString(), if (d.fahrenheit) "°F" else "°C", size, d.tempColor(sensor, c)) +} + +@Composable +private fun Big(value: String, unit: String, size: TextUnit, color: Color, glow: Boolean, unitScale: Float = 0.27f, modifier: Modifier = Modifier) { + Text( + buildAnnotatedString { num(value, unit, size, color, unitScale) }, + modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, maxLines = 1, softWrap = false, + style = if (glow) TextStyle(shadow = Shadow(color.copy(alpha = 0.85f), Offset.Zero, 18f)) else TextStyle.Default, + ) +} + +@Composable +private fun Line(text: AnnotatedString, size: TextUnit, modifier: Modifier = Modifier, align: TextAlign = TextAlign.Start) { + Text(text, modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.value, maxLines = 1, + softWrap = false, overflow = TextOverflow.Clip, textAlign = align) +} + +@Composable +private fun Small(text: String, size: TextUnit = 12.sp, color: Color = HudInk.dim, modifier: Modifier = Modifier) { + Text(text, modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean = true): AnnotatedString = buildAnnotatedString { + fun part(label: String, v: Float) { + withStyle(SpanStyle(color = HudInk.dim)) { append("$label ") } + withStyle(SpanStyle(color = accent)) { append(f1(v)) } + } + part("1%", d.lows.low1Fps) + withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") } + part("0.1%", d.lows.low01Fps) + if (deep) { + withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") } + part("0.01%", d.lows.low001Fps) + } +} + +@Composable +private fun Graph(values: List, lo: Float, hi: Float, color: Color, modifier: Modifier) { + Canvas(modifier) { + if (values.size < 2 || hi <= lo) return@Canvas + val path = Path() + values.forEachIndexed { i, v -> + val x = i / (values.size - 1f) * size.width + val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + } +} + +@Composable +private fun FrameGraph(d: HudData, accent: Color, height: Dp) { + Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height)) +} + +@Composable +private fun Rows(rows: List, AnnotatedString>>, size: TextUnit, labelWidth: Dp, gap: Dp) { + Column(verticalArrangement = Arrangement.spacedBy(gap)) { + for ((label, value) in rows) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label.first, modifier = Modifier.width(labelWidth), fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, + color = label.second, maxLines = 1, softWrap = false) + Line(value, size) + } + } + } +} + +private fun row(label: String, color: Color, value: AnnotatedString) = (label to color) to value + +@Composable +private fun RowScope.Tile(key: String, keyColor: Color, value: AnnotatedString, sub: String, glow: Color? = null) { + Column( + Modifier.weight(1f).fillMaxHeight().clip(RoundedCornerShape(9.dp)).background(HudInk.tile).padding(horizontal = 11.dp, vertical = 6.dp), + verticalArrangement = Arrangement.Center, + ) { + Small(key, 12.sp, keyColor) + Text(value, fontSize = 28.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.value, maxLines = 1, softWrap = false, + style = if (glow != null) TextStyle(shadow = Shadow(glow.copy(alpha = 0.85f), Offset.Zero, 16f)) else TextStyle.Default) + Small(sub) + } +} + +@Composable +private fun Ring(ringSize: Dp, fraction: Float?, color: Color, label: String, sub: String?, center: @Composable () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(ringSize), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val w = size.minDimension * 0.085f + val tl = Offset(w, w) + val sz = Size(size.width - 2 * w, size.height - 2 * w) + drawArc(Color.White.copy(alpha = 0.08f), 0f, 360f, false, topLeft = tl, size = sz, style = Stroke(width = w)) + if (fraction != null) { + val sweep = (fraction.coerceIn(0f, 1f) * 360f).coerceAtLeast(1f) + drawArc(color.copy(alpha = 0.25f), -90f, sweep, false, topLeft = tl, size = sz, style = Stroke(width = w * 1.9f, cap = StrokeCap.Round)) + drawArc(color, -90f, sweep, false, topLeft = tl, size = sz, style = Stroke(width = w, cap = StrokeCap.Round)) + } + } + center() + } + Small(label, 13.sp, color) + if (sub != null) Small(sub, 11.5.sp) + } +} + +@Composable +private fun ColumnScope.BigFps(d: HudData, accent: Color) { + Big(f1(d.fps), "fps", 104.sp, accent, glow = true, modifier = Modifier.offset(x = (-4).dp)) + Line(lowsText(d, 16.sp, accent), 16.sp, Modifier.padding(top = 10.dp)) + Spacer(Modifier.weight(1f)) + FrameGraph(d, accent, 72.dp) + Small(listOfNotNull(d.engine.ifEmpty { null }, d.title, d.played).joinToString(" · "), modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun ColumnScope.Full(d: HudData, accent: Color) { + val s = d.device + val z = 16.sp + val left = listOf( + row("GPU", StatsColors.gpu, AnnotatedString(d.gpuName)), + row("GPU", StatsColors.gpu, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.GPU, s?.gpuTempC, z) }), + row("", StatsColors.gpu, buildAnnotatedString { num(s?.gpuClockMhz?.toString() ?: "–", "MHz", z) }), + row("CPU", StatsColors.cpu, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.CPU, s?.cpuTempC, z) }), + row("", StatsColors.cpu, buildAnnotatedString { num(s?.cpuClockMhz?.toString() ?: "–", "MHz", z) }), + row("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z) }), + row("RAM", StatsColors.ram, buildAnnotatedString { num(gib(s?.ramUsedBytes), "GiB", z); gap(); num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z) }), + row("BAT", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", z); gap(); num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", z) }), + ) + val right = listOf( + row(d.engine.ifEmpty { "FPS" }, accent, buildAnnotatedString { num(f1(d.fps), "FPS", z, accent) }), + row("", accent, buildAnnotatedString { num(f1(d.frametimeMs), "ms", z) }), + row("AVG", HudInk.low, buildAnnotatedString { num(f1(d.avgFps), "FPS", z) }), + row("1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low1Fps), "FPS", z) }), + row("0.1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low01Fps), "FPS", z) }), + row("0.01%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low001Fps), "FPS", z) }), + row("TEMP", StatsColors.bat, buildAnnotatedString { temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z) }), + row("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "rpm", z) }), + ) + Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) { + Rows(left, z, 52.dp, 6.dp) + Rows(right, z, 62.dp, 6.dp) + } + Spacer(Modifier.weight(1f)) + val frametime = stringResource(R.string.stats_hud_frametime) + Line(buildAnnotatedString { + withStyle(SpanStyle(color = accent)) { append(frametime) } + withStyle(SpanStyle(color = HudInk.dim)) { append(" min %.1f · max %.1f ms".format(d.graph.filter { it > 0f }.minOrNull() ?: 0f, d.graph.maxOrNull() ?: 0f)) } + }, 12.5.sp, Modifier.padding(bottom = 6.dp)) + FrameGraph(d, accent, 44.dp) +} + +@Composable +private fun ColumnScope.Tiles(d: HudData, accent: Color) { + val s = d.device + val z = 28.sp + val u = 0.5f + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Tile("FPS", accent, buildAnnotatedString { num(f1(d.fps), "", z) }, "${f1(d.avgFps)} avg · ${f1(d.lows.low1Fps)} 1%", glow = accent) + Tile("FRAME", HudInk.dim, buildAnnotatedString { num(f1(d.frametimeMs), "ms", z, unitScale = u) }, "max %.1f ms".format(d.graph.maxOrNull() ?: 0f)) + Tile("GPU", StatsColors.gpu, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", z, unitScale = u) }, + listOfNotNull(s?.gpuTempC?.let { tempLabel(d, it) }, s?.gpuClockMhz?.let { "$it MHz" }).joinToString(" · ")) + } + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Tile("CPU", StatsColors.cpu, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", z, unitScale = u) }, + listOfNotNull(s?.cpuTempC?.let { tempLabel(d, it) }, s?.cpuClockMhz?.let { "$it MHz" }).joinToString(" · ")) + Tile("RAM", StatsColors.ram, buildAnnotatedString { num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z, unitScale = u) }, + "${gib(s?.ramUsedBytes)} / ${gib(s?.ramTotalBytes)} GiB") + Tile("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z, unitScale = u) }, d.gpuName) + } + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Tile("BAT TEMP", StatsColors.bat, buildAnnotatedString { temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z) }, stringResource(R.string.stats_hud_battery_temp)) + Tile("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "", z) }, "rpm") + Tile("BATTERY", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", z, unitScale = u) }, + listOfNotNull(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f W".format(it) }, s?.battery?.runtimeText).joinToString(" · ")) + } + } +} + +@Composable +private fun ColumnScope.Rings(d: HudData, accent: Color) { + val s = d.device + Row(Modifier.weight(1f).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(26.dp)) { + Ring(200.dp, d.fps?.let { it / d.hz }, accent, stringResource(R.string.stats_hud_fps_of, d.hz), "1% ${f1(d.lows.low1Fps)} · 0.1% ${f1(d.lows.low01Fps)}") { + Big(f1(d.fps), "fps", 40.sp, HudInk.value, glow = true, unitScale = 0.4f) + } + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) { + Ring(96.dp, s?.gpuPercent?.let { it / 100f }, StatsColors.gpu, "GPU", s?.gpuTempC?.let { tempLabel(d, it) }) { Big(s?.gpuPercent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) } + Ring(96.dp, s?.cpuPercent?.let { it / 100f }, StatsColors.cpu, "CPU", s?.cpuTempC?.let { tempLabel(d, it) }) { Big(s?.cpuPercent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) } + } + Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) { + Ring(96.dp, s?.ramPercent?.let { it / 100f }, StatsColors.ram, "RAM", s?.let { "${gib(it.ramUsedBytes)} GiB" }) { + Big(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) + } + Ring(96.dp, s?.battery?.percent?.let { it / 100f }, StatsColors.bat, "BAT", s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f W".format(it) }) { + Big(s?.battery?.percent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) + } + } + } + } +} + +@Composable +private fun ColumnScope.Graphs(d: HudData, accent: Color) { + val s = d.device + val frameMs = d.fpsHistory.map { 1000f / max(it, 1f) } + @Composable + fun graphRow(label: String, color: Color, values: List, hi: Float, value: AnnotatedString) { + Row(Modifier.fillMaxWidth().height(56.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Small(label, 14.sp, color, Modifier.width(62.dp)) + Graph(values, 0f, hi, color, Modifier.weight(1f).height(52.dp)) + Line(value, 24.sp, Modifier.width(128.dp), TextAlign.End) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.SpaceEvenly) { + graphRow("FPS", accent, d.fpsHistory, max(d.hz.toFloat(), d.fpsHistory.maxOrNull() ?: 0f) * 1.1f, buildAnnotatedString { num(f1(d.fps), "fps", 24.sp, accent, 0.5f) }) + graphRow("FRAME", lerp(accent, Color.White, 0.3f), frameMs, max(40f, (frameMs.maxOrNull() ?: 0f) * 1.2f), buildAnnotatedString { num(f1(d.frametimeMs), "ms", 24.sp, unitScale = 0.5f) }) + graphRow("CPU", StatsColors.cpu, d.cpuHistory, 100f, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", 24.sp, unitScale = 0.5f) }) + graphRow("GPU", StatsColors.gpu, d.gpuHistory, 100f, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", 24.sp, unitScale = 0.5f) }) + } +} + +@Composable +private fun ColumnScope.Cores(d: HudData, accent: Color) { + val s = d.device + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Big(f1(d.fps), "fps", 40.sp, accent, glow = true, unitScale = 0.38f) + Small(listOfNotNull(d.engine.ifEmpty { null }, d.frametimeMs?.let { "%.1f ms".format(it) }, + s?.cpuPercent?.let { "CPU $it%" }, s?.cpuTempC?.let { tempLabel(d, it) }).joinToString(" · "), 15.sp, modifier = Modifier.padding(bottom = 6.dp)) + } + Spacer(Modifier.height(10.dp)) + val gameTag = stringResource(R.string.stats_hud_game_cores) + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + for (group in d.coreGroups) { + for (core in group.cores) { + val p = s?.perCorePercent?.getOrNull(core)?.takeIf { it >= 0 } + val mhz = s?.perCoreClockMhz?.getOrNull(core)?.takeIf { it > 0 } + Row(Modifier.fillMaxWidth().height(20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Small("C$core", 14.sp, StatsColors.cpu, Modifier.width(40.dp)) + Box(Modifier.weight(1f).height(14.dp).clip(RoundedCornerShape(7.dp)).background(Color.White.copy(alpha = 0.07f))) { + if (p != null && p > 0) Box( + Modifier.fillMaxHeight().fillMaxWidth(p / 100f).clip(RoundedCornerShape(7.dp)) + .background(Brush.horizontalGradient(listOf(Color(0xFF2B6FC9), if (p >= 80) HudInk.amber else StatsColors.cpu))), + ) + } + Line(buildAnnotatedString { num(p?.toString() ?: "–", "%", 14.sp); append(" "); num(mhz?.toString() ?: "–", "MHz", 14.sp) }, + 14.sp, Modifier.width(118.dp), TextAlign.End) + } + } + val label = group.maxGhz?.let { stringResource(R.string.stats_hud_core_group, group.cores.size, "%.2f".format(it)) } + ?: stringResource(R.string.stats_hud_core_group_unknown, group.cores.size) + Row(Modifier.padding(start = 50.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Small(label, 11.5.sp) + if (d.gameCores.isNotEmpty() && group.cores.any { it in d.gameCores }) { + Text(gameTag.uppercase(), fontSize = 10.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = Color(0xFF0A0B0D), + modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(StatsColors.cpu).padding(horizontal = 4.dp)) + } + } + } + } +} + +@Composable +private fun ColumnScope.Heat(d: HudData) { + val s = d.device + @Composable + fun bar(label: String, color: Color, sensor: HudMetrics.TempSensor, c: Int?) { + val red = d.redC(sensor) + Row(Modifier.fillMaxWidth().height(28.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Small(label, 15.sp, color, Modifier.width(48.dp)) + BoxWithConstraints(Modifier.weight(1f).height(20.dp).clip(RoundedCornerShape(10.dp)).background(Color.White.copy(alpha = 0.07f))) { + if (c != null && c > 0) Box(Modifier.fillMaxHeight().fillMaxWidth((c / 100f).coerceIn(0f, 1f)).clip(RoundedCornerShape(10.dp)) + .background(Brush.horizontalGradient(listOf(color.copy(alpha = 0.55f), color)))) + if (red != null && red in 1..99) Box(Modifier.offset(x = maxWidth * (red / 100f)).width(2.dp).fillMaxHeight().background(Color(0xFFEF5350))) + } + Line(buildAnnotatedString { temp(d, sensor, c, 24.sp) }, 24.sp, Modifier.width(92.dp), TextAlign.End) + } + } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + bar("CPU", StatsColors.cpu, HudMetrics.TempSensor.CPU, s?.cpuTempC) + bar("GPU", StatsColors.gpu, HudMetrics.TempSensor.GPU, s?.gpuTempC) + bar("BAT", StatsColors.bat, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC) + } + Small(stringResource(R.string.stats_hud_red_mark), 12.sp, modifier = Modifier.padding(top = 8.dp)) + Spacer(Modifier.weight(1f)) + Row(Modifier.fillMaxWidth().height(96.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Tile("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "", 28.sp) }, "rpm") + Tile("POWER", StatsColors.bat, buildAnnotatedString { num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", 28.sp, unitScale = 0.5f) }, + stringResource(R.string.stats_hud_whole_device)) + Tile("BATTERY", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", 28.sp, unitScale = 0.5f) }, s?.battery?.runtimeText ?: "") + } +} + +@Composable +private fun ColumnScope.GameCard(d: HudData, accent: Color) { + Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp)) { + Box( + Modifier.width(150.dp).fillMaxHeight().clip(RoundedCornerShape(10.dp)) + .background(Brush.linearGradient(listOf(lerp(accent, Color.Black, 0.55f), Color(0xFF0B0D10)))), + contentAlignment = Alignment.BottomStart, + ) { + if (d.cover != null) AsyncImage(model = d.cover, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + else Text(d.title.orEmpty(), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.85f), maxLines = 3, + modifier = Modifier.padding(12.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(d.title.orEmpty(), fontSize = 26.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis) + d.played?.let { Small(stringResource(R.string.stats_hud_playing_for, it), 13.5.sp) } + Line(buildAnnotatedString { + withStyle(SpanStyle(color = accent)) { append(d.engine) } + if (d.engine.isNotEmpty() && d.proton != null) withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") } + d.proton?.let { withStyle(SpanStyle(color = HudInk.vram)) { append(it) } } + }, 13.5.sp) + if (d.resolution.isNotEmpty()) Small(d.resolution, 13.5.sp) + Big(f1(d.fps), "fps", 60.sp, accent, glow = true, unitScale = 0.3f, modifier = Modifier.padding(top = 6.dp)) + Line(lowsText(d, 14.sp, accent, deep = false), 14.sp) + } + } + Spacer(Modifier.height(10.dp)) + FrameGraph(d, accent, 40.dp) +} + +@Composable +private fun ColumnScope.Pacing(d: HudData, accent: Color) { + @Composable + fun stat(label: String, value: String, color: Color = HudInk.value) { + Column { + Small(label, 13.sp) + Text(value, fontSize = 22.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = color, maxLines = 1) + } + } + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Big(f1(d.fps), "fps", 52.sp, accent, glow = true, unitScale = 0.3f) + stat(stringResource(R.string.stats_hud_avg_frame), d.frametimeMs?.let { "%.1f ms".format(it) } ?: "–") + stat(stringResource(R.string.stats_hud_low), f1(d.lows.low1Fps)) + val slow = d.slowPerMinute + stat(stringResource(R.string.stats_hud_slow_frames), slow?.let { stringResource(R.string.stats_hud_per_minute, it) } ?: "–", + if ((slow ?: 0) > 0) HudInk.amber else HudInk.value) + } + Spacer(Modifier.height(12.dp)) + val target = 1000f / d.hz + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { + val graphColor = lerp(accent, Color.White, 0.3f) + Canvas(Modifier.fillMaxSize()) { + val axis = Color.White.copy(alpha = 0.12f) + drawLine(axis, Offset(0f, 0f), Offset(0f, size.height), 1.dp.toPx()) + drawLine(axis, Offset(0f, size.height), Offset(size.width, size.height), 1.dp.toPx()) + val dash = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 5.dp.toPx())) + for ((ms, color) in listOf(target to Color.White.copy(alpha = 0.25f), target * 2 to HudInk.amber.copy(alpha = 0.5f))) { + val y = size.height * (1f - ms / FrameStats.GRAPH_MAX_MS) + drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash) + } + } + Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize()) + for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) { + Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color, + Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp)) + } + } +} + +@Composable +private fun ColumnScope.Night(d: HudData, dim: Boolean) { + val clockInk = if (dim) Color(0xFF6F7A87) else HudInk.value + val fpsInk = if (dim) Color(0xFF4D7FA8) else LocalPalette.current.primary + Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text(d.clock, fontSize = 88.sp, fontWeight = FontWeight.Light, color = clockInk, maxLines = 1, softWrap = false) + Line(buildAnnotatedString { + num(f1(d.fps), "fps", 30.sp, fpsInk) + d.device?.battery?.percent?.let { + withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") } + num(it.toString(), "%", 30.sp, if (dim) Color(0xFF6B5A44) else StatsColors.bat) + } + }, 30.sp, Modifier.padding(top = 14.dp)) + } +} + +@Composable +private fun ColumnScope.Mega(d: HudData, accent: Color) { + val s = d.device + val z = 13.5.sp + val left = mutableListOf( + row("GPU", StatsColors.gpu, AnnotatedString(d.gpuName)), + row("GPU", StatsColors.gpu, buildAnnotatedString { + num(s?.gpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.GPU, s?.gpuTempC, z); gap(); num(s?.gpuClockMhz?.toString() ?: "–", "MHz", z) + }), + row("CPU", StatsColors.cpu, buildAnnotatedString { + num(s?.cpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.CPU, s?.cpuTempC, z); gap(); num(s?.cpuClockMhz?.toString() ?: "–", "MHz", z) + }), + ) + val cores = s?.perCorePercent?.size ?: 0 + for (i in 0 until cores) { + left += row("C$i", StatsColors.cpu, buildAnnotatedString { + num(s?.perCorePercent?.getOrNull(i)?.takeIf { it >= 0 }?.toString() ?: "–", "%", z); gap() + num(s?.perCoreClockMhz?.getOrNull(i)?.takeIf { it > 0 }?.toString() ?: "–", "MHz", z) + }) + } + val right = listOf( + row("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z) }), + row("RAM", StatsColors.ram, buildAnnotatedString { num("${gib(s?.ramUsedBytes)}/${gib(s?.ramTotalBytes)}", "GiB", z); gap(); num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z) }), + row("SWP", StatsColors.ram, buildAnnotatedString { num("${gib(s?.swapUsedBytes)}/${gib(s?.swapTotalBytes)}", "GiB", z) }), + row("NET", accent, buildAnnotatedString { + withStyle(SpanStyle(color = HudInk.dim)) { append("↓") }; num(s?.netDownBps?.let { (it / 1024).toString() } ?: "–", "", z) + append(" "); withStyle(SpanStyle(color = HudInk.dim)) { append("↑") }; num(s?.netUpBps?.let { (it / 1024).toString() } ?: "–", "KB/s", z) + }), + row("BAT", StatsColors.bat, buildAnnotatedString { + num(s?.battery?.percent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z); gap() + num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", z) + }), + row("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "rpm", z) }), + row(d.engine.ifEmpty { "FPS" }, accent, buildAnnotatedString { num(f1(d.fps), "FPS", z, accent); gap(); num(f1(d.frametimeMs), "ms", z) }), + row("AVG", HudInk.low, buildAnnotatedString { num(f1(d.avgFps), "FPS", z) }), + row("1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low1Fps), "FPS", z) }), + row("0.1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low01Fps), "FPS", z) }), + row("0.01%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low001Fps), "FPS", z) }), + ) + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Rows(left, z, 44.dp, 3.dp) + Rows(right, z, 58.dp, 3.dp) + } + Spacer(Modifier.weight(1f)) + Line(buildAnnotatedString { + withStyle(SpanStyle(color = HudInk.dim)) { append("RES ") }; append(d.resolution.ifEmpty { "–" }) + d.proton?.let { withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") }; withStyle(SpanStyle(color = HudInk.vram)) { append(it) } } + d.played?.let { withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") }; append(it) } + }, 12.5.sp, Modifier.padding(bottom = 6.dp)) + FrameGraph(d, accent, 30.dp) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt index 7e6ae6434..1d2480688 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt @@ -36,28 +36,31 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R import com.droiddeck.launcher.hud.DroidDeckMark +import com.droiddeck.launcher.hud.FrameStats import com.droiddeck.launcher.hud.HudMetrics import com.droiddeck.launcher.session.StatsStrip +import com.droiddeck.launcher.session.StripMetric import kotlin.math.max import kotlin.math.roundToInt -/** FusionHUD's metric colors, so the bottom screen reads like the HUD on the top screen. */ internal object StatsColors { val cpu = Color(0xFF58A6FF) val gpu = Color(0xFF5EE08A) val ram = Color(0xFFFF7BC0) val bat = Color(0xFFFFAB5E) - // Throttle and brake stay apart for red-green colour blindness (checked against the dark surface). val throttle = Color(0xFF14A39A) val brake = Color(0xFFE5484D) val speed = Color(0xFF2E8FD6) @@ -66,19 +69,27 @@ internal object StatsColors { val badge = Color(0xFF0B0D10) } -/** The last [capacity] readings, oldest first. */ internal class History(private val capacity: Int) { private val values = ArrayDeque(capacity) - fun add(v: Float) { - if (values.size == capacity) values.removeFirst() + private val times = ArrayDeque(capacity) + fun add(v: Float, atMs: Long = 0L) { + if (values.size == capacity) { + values.removeFirst() + times.removeFirst() + } values.addLast(v) + times.addLast(atMs) } fun snapshot(): List = values.toList() + fun spanSeconds(): Int { + val first = times.firstOrNull()?.takeIf { it > 0L } ?: return 0 + val last = times.last() + return ((last - first + 500L) / 1000L).toInt() + } } private val MARK_RATIO = DroidDeckMark.widthFor(1f) -/** The DroidDeck mark (the split D with the blue orb), the same artwork the HUD draws. */ @Composable internal fun DroidDeckLogo(height: Dp, modifier: Modifier = Modifier, body: Color = Color.White, orb: Color = LocalPalette.current.primary) { Canvas(modifier.size(height * MARK_RATIO, height)) { @@ -96,7 +107,6 @@ private fun LogoBadge(size: Dp) { } } -/** The mark and "DroidDeck" on the left, the running game's name in the middle, the clock on the right. */ @Composable private fun LogoHeader(clock: String, game: String?) { val colors = MaterialTheme.colorScheme @@ -123,7 +133,6 @@ internal fun Sparkline(values: List, min: Float, max: Float, color: Color } } -/** A readout card: dark surface, a thin accent edge and the DroidDeck mark before the title ("" = mark only). */ @Composable internal fun StatsCard(title: String?, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { val pal = LocalPalette.current @@ -141,7 +150,6 @@ internal fun StatsCard(title: String?, modifier: Modifier = Modifier, content: @ } } -/** What the top strip shows; histories hold one reading a second, oldest first. */ internal class StripData( val fps: Float?, val device: HudMetrics.Snapshot?, @@ -151,6 +159,12 @@ internal class StripData( val wattsHistory: List, val clock: String, val game: String? = null, + val engine: String = "", + val lows: FrameStats.Lows? = null, + val graph: FloatArray? = null, + val metrics: Set = StripMetric.defaults, + val fahrenheit: Boolean = false, + val tempColor: ((HudMetrics.TempSensor, Int) -> Color)? = null, ) private fun percentTemp(percent: Int?, temp: Int?): String = @@ -161,11 +175,11 @@ private fun batteryText(s: HudMetrics.Snapshot?): String = private fun fpsText(fps: Float?): String = fps?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–" -/** The top strip; a tap moves to the next style. */ @Composable internal fun StatsStripView(style: StatsStrip, d: StripData, onTap: () -> Unit) { Box(Modifier.fillMaxWidth().clickable(onClick = onTap)) { when (style) { + StatsStrip.LINE -> LineStrip(d) StatsStrip.PANEL -> PanelStrip(d) StatsStrip.TILES -> TilesStrip(d) StatsStrip.RINGS -> RingsStrip(d) @@ -173,6 +187,113 @@ internal fun StatsStripView(style: StatsStrip, d: StripData, onTap: () -> Unit) } } +private object LineInk { + val value = Color(0xFFF2F5F9) + val dim = Color(0xFF9AA4B2) + val vram = Color(0xFFC98BFF) +} + +private class LineItem(val priority: Int, val content: @Composable () -> Unit) + +@Composable +internal fun LineStrip(d: StripData, modifier: Modifier = Modifier) { + val pal = LocalPalette.current + val shape = RoundedCornerShape(12.dp) + val m = d.metrics + val s = d.device + val items = ArrayList() + items += LineItem(0) { DroidDeckLogo(16.dp) } + if (StripMetric.ENGINE in m && d.engine.isNotEmpty()) items += LineItem(70) { LineText(d.engine, LineInk.dim, 12.sp) } + items += LineItem(0) { + Row(verticalAlignment = Alignment.Bottom) { + LineText(fpsText(d.fps), LineInk.value, 17.sp) + LineText("fps", LineInk.dim, 10.sp, Modifier.padding(start = 2.dp, bottom = 2.dp)) + } + } + val graph = d.graph + if (StripMetric.GRAPH in m && graph != null) items += LineItem(60) { + Sparkline(graph.asList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(pal.primary, Color.White, 0.3f), Modifier.width(56.dp).height(16.dp)) + } + if (StripMetric.LOWS in m) { + items += LineItem(10) { LinePair("1%", LineInk.dim, lowText(d.lows?.low1Fps), StatsColors.cpu) } + items += LineItem(90) { LinePair("0.1%", LineInk.dim, lowText(d.lows?.low01Fps), StatsColors.cpu) } + } + if (StripMetric.LOW001 in m) items += LineItem(100) { LinePair("0.01%", LineInk.dim, lowText(d.lows?.low001Fps), StatsColors.cpu) } + if (StripMetric.GPU in m || StripMetric.GPU_TEMP in m) items += LineItem(20) { + LineMetric("GPU", StatsColors.gpu, if (StripMetric.GPU in m) s?.gpuPercent else null, + if (StripMetric.GPU_TEMP in m) s?.gpuTempC else null, HudMetrics.TempSensor.GPU, d) + } + if (StripMetric.CPU in m || StripMetric.CPU_TEMP in m) items += LineItem(30) { + LineMetric("CPU", StatsColors.cpu, if (StripMetric.CPU in m) s?.cpuPercent else null, + if (StripMetric.CPU_TEMP in m) s?.cpuTempC else null, HudMetrics.TempSensor.CPU, d) + } + if (StripMetric.VRAM in m) items += LineItem(80) { LinePair("VRAM", LineInk.vram, gibText(s?.vramUsedBytes), LineInk.vram) } + if (StripMetric.RAM in m) items += LineItem(40) { LinePair("RAM", StatsColors.ram, s?.let { "${it.ramPercent.roundToInt()}%" } ?: "–", StatsColors.ram) } + val clock = StripMetric.CLOCK in m && d.clock.isNotEmpty() + if (clock) items += LineItem(50) { LineText(d.clock, LineInk.dim, 12.sp) } + PriorityRow( + IntArray(items.size) { items[it].priority }, 10.dp, pinLast = clock, + modifier = modifier.fillMaxWidth().clip(shape).background(pal.surface).border(1.dp, pal.primary.copy(alpha = 0.35f), shape) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + items.forEach { it.content() } + } +} + +@Composable +private fun PriorityRow(priorities: IntArray, spacing: Dp, pinLast: Boolean, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(content, modifier) { measurables, constraints -> + val gap = spacing.roundToPx() + val loose = Constraints(maxHeight = constraints.maxHeight) + val placeables = measurables.map { it.measure(loose) } + val max = if (constraints.hasBoundedWidth) constraints.maxWidth else Int.MAX_VALUE + val keep = fitByPriority(IntArray(placeables.size) { placeables[it].width }, priorities.copyOf(placeables.size), gap, max) + val height = maxOf(constraints.minHeight, placeables.filterIndexed { i, _ -> keep[i] }.maxOfOrNull { it.height } ?: 0) + val used = placeables.filterIndexed { i, _ -> keep[i] }.sumOf { it.width } + gap * maxOf(0, keep.count { it } - 1) + val width = if (constraints.hasBoundedWidth) constraints.maxWidth else used + layout(width, height) { + var x = 0 + val last = placeables.lastIndex + placeables.forEachIndexed { i, p -> + if (!keep[i]) return@forEachIndexed + val px = if (pinLast && i == last) width - p.width else x + p.placeRelative(px, (height - p.height) / 2) + x += p.width + gap + } + } + } +} + +@Composable +private fun LineText(text: String, color: Color, size: TextUnit = 13.sp, modifier: Modifier = Modifier) { + Text(text, fontSize = size, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = color, maxLines = 1, softWrap = false, + modifier = modifier) +} + +@Composable +private fun LinePair(label: String, labelColor: Color, value: String, valueColor: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + LineText(label, labelColor, 11.sp) + LineText(value, valueColor) + } +} + +@Composable +private fun LineMetric(label: String, color: Color, percent: Int?, tempC: Int?, sensor: HudMetrics.TempSensor, d: StripData) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + LineText(label, color, 11.sp) + if (percent == null && tempC == null) LineText("–", color) + if (percent != null) LineText("$percent%", color) + if (tempC != null) LineText(tempText(tempC, d.fahrenheit), d.tempColor?.invoke(sensor, tempC) ?: LineInk.value) + } +} + +private fun lowText(fps: Float?): String = fps?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–" + +private fun gibText(bytes: Long?): String = bytes?.let { "%.1fGiB".format(it / 1073741824.0) } ?: "–" + +private fun tempText(c: Int, fahrenheit: Boolean): String = if (fahrenheit) "${(c * 9f / 5f + 32f).roundToInt()}°F" else "$c°C" + @Composable private fun PanelStrip(d: StripData) { val pal = LocalPalette.current diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt index 0c1f66b9d..acf6753bf 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt @@ -621,7 +621,6 @@ private fun GamesView(report: StorageReport, sessionRunning: Boolean, a: FrontEn Spacer(Modifier.height(8.dp)) Note(stringResource(R.string.storage_games_note)) confirm?.let { g -> - // steam://uninstall reaches only the session's client, the selected one. val otherClient = g.x64 != (SessionPrefs.steamClientArch(LocalContext.current) == "x86_64") if (otherClient) AlertDialog( onDismissRequest = { confirm = null }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt index 642160b15..5d9adf2cb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt @@ -204,22 +204,6 @@ internal fun AchievementRow(a: Achievement, compact: Boolean, modifier: Modifier } } -@Composable -internal fun StoreAchievementsCard(game: StoreAchievements.Game, title: String, modifier: Modifier = Modifier) { - val label = StoreAchievements.forSource(game.source)?.label ?: return - val state = rememberAchievements(game) ?: return - var open by remember { mutableStateOf(false) } - StoreAchievementsCardContent(label, state, modifier) { open = true } - if (open) { - VeilRing() - Dialog(onDismissRequest = { open = false }, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { - CompositionLocalProvider(LocalFrontFocus provides null) { - SessionAchievementsPanel(label, state, title, veil = Color.Transparent, floating = true, onClose = { open = false }) - } - } - } -} - @Composable internal fun StoreAchievementsCardContent(label: String, state: AchievementsState, modifier: Modifier = Modifier, onOpen: () -> Unit) { val colors = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt index 314cb13da..90d6d4ac1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt @@ -177,7 +177,6 @@ internal fun StoreGameFacts(developer: String, publisher: String, releaseDate: S } } -/** The headline scores, small enough to sit inside the action capsule. */ @Composable internal fun CapsuleScores(scores: GameScores, modifier: Modifier = Modifier) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = modifier) { @@ -216,7 +215,6 @@ private fun CapsuleStat(label: String, link: String?, showLabel: Boolean = true, } } -/** Credits and times as label and value rows, for the details panel. */ @Composable internal fun StoreDetailRows(developer: String, publisher: String, releaseDate: String, scores: GameScores) { val released = remember(releaseDate) { GameFacts.releaseLabel(releaseDate) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt index 3996d3042..8cba37f91 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt @@ -1,6 +1,5 @@ package com.droiddeck.launcher.ui -// `split` is the size of a leading section that starts its own rows; 0 means one continuous grid. internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int, split: Int = 0): Int? { if (index !in 0 until count || columns < 1 || direction !in listOf(-1, 1)) return null if (split !in 1 until count) return (index + columns * direction).takeIf { it in 0 until count } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 119c0a4d6..35c21173e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -808,14 +808,6 @@ private fun SignInButton(store: Store, large: Boolean = false) { } } -@Composable -internal fun StoreLogo(store: Store, size: Dp) { - val c = sourceColours(store.id) - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(Shape14).background(c.fill).border(1.dp, c.dot.copy(alpha = 0.5f), Shape14)) { - Text(store.shortLabel, fontSize = (size.value / 4).sp, fontWeight = FontWeight.Black, color = c.ink, maxLines = 1) - } -} - @Composable private fun Storefront( @@ -862,7 +854,6 @@ private fun Storefront( val focus = LocalFrontFocus.current val scope = rememberCoroutineScope() val searching = q.isNotEmpty() || browsing || catalog - // A bar only while there is nothing else to look at: no shelves yet and no search results. val bareTab = if (searching) visible.isEmpty() else shelves == null || shelves.isEmpty val barLine = StoresState.status[store]?.takeIf { bareTab && syncCount(it) != null } val itchBar = store == Store.ITCH && q.isEmpty() @@ -1423,15 +1414,6 @@ internal fun ConfirmButton(label: String, confirm: String, compact: Boolean = fa SecondaryButton(if (armed) confirm else label, compact = compact) { if (!armed) armed = true else { armed = false; onConfirm() } } } -@Composable -internal fun stageLabel(stage: DownloadStage): String = when (stage) { - DownloadStage.MANIFEST -> stringResource(R.string.stores_stage_manifest) - DownloadStage.DOWNLOAD -> stringResource(R.string.stores_stage_download) - DownloadStage.VERIFY -> stringResource(R.string.stores_stage_verify) - DownloadStage.INSTALL -> stringResource(R.string.stores_stage_install) - DownloadStage.DONE -> stringResource(R.string.stores_stage_done) -} - internal fun openStoreUrl(ctx: android.content.Context, item: CatalogItem) = openUrl(ctx, item.storeUrl) internal fun openUrl(ctx: android.content.Context, url: String) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StripFit.kt b/app/src/main/java/com/droiddeck/launcher/ui/StripFit.kt new file mode 100644 index 000000000..fa4131ca8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StripFit.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.ui + +internal fun fitByPriority(widths: IntArray, priorities: IntArray, spacing: Int, maxWidth: Int): BooleanArray { + val keep = BooleanArray(widths.size) + var used = 0 + var count = 0 + for (i in widths.indices.sortedWith(compareBy({ priorities[it] }, { it }))) { + val add = widths[i] + if (count > 0) spacing else 0 + if (used + add > maxWidth) break + keep[i] = true + used += add + count++ + } + return keep +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SyncAdvancedPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SyncAdvancedPage.kt new file mode 100644 index 000000000..332be2b0d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SyncAdvancedPage.kt @@ -0,0 +1,109 @@ +package com.droiddeck.launcher.ui + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import com.droiddeck.launcher.R +import com.droiddeck.launcher.sync.SyncPrefs +import com.droiddeck.launcher.sync.Syncthing +import java.io.File + +@Composable +internal fun SyncAdvancedPage(host: MenuHost, onBack: () -> Unit) { + val ctx = LocalContext.current + var runMode by remember { mutableStateOf(SyncPrefs.runMode(ctx)) } + var wifiOnly by remember { mutableStateOf(SyncPrefs.wifiOnly(ctx)) } + var pauseInGames by remember { mutableStateOf(SyncPrefs.pauseInGames(ctx)) } + var confirmReset by rememberSaveable { mutableStateOf(false) } + if (confirmReset) ResetDialog(onDismiss = { confirmReset = false }) { Syncthing.reset(ctx) } + val ports = Syncthing.ports + SettingsPage(host, stringResource(R.string.sync_advanced), onBack, eyebrow = stringResource(R.string.setup_tab_sync)) { + PairedColumns( + { + SettingsGroup(stringResource(R.string.sync_ports)) { + PortRow(stringResource(R.string.sync_port_sync), ports?.sync?.toString()) + PortRow(stringResource(R.string.sync_port_discovery), ports?.discovery?.toString()) + ActionRow(stringResource(R.string.sync_repick), stringResource(R.string.sync_repick_hint), stringResource(R.string.sync_repick_button), onClick = { Syncthing.restart() }) + } + }, + { + SettingsGroup(stringResource(R.string.sync_running_group)) { + ChoiceRow( + host, "sync-run", stringResource(R.string.sync_run), null, + listOf(SyncPrefs.RUN_WHILE_OPEN to stringResource(R.string.sync_run_open), SyncPrefs.RUN_BACKGROUND to stringResource(R.string.sync_run_background)), + runMode, stacked = true, + ) { SyncPrefs.setRunMode(ctx, it); runMode = it; Syncthing.settingsChanged() } + ToggleRow(host, "sync-wifi", stringResource(R.string.sync_wifi_only), null, wifiOnly) { + SyncPrefs.setWifiOnly(ctx, it); wifiOnly = it; Syncthing.settingsChanged() + } + ToggleRow(host, "sync-games", stringResource(R.string.sync_pause_games), null, pauseInGames) { + SyncPrefs.setPauseInGames(ctx, it); pauseInGames = it; Syncthing.settingsChanged() + } + ActionRow(stringResource(R.string.sync_logs), stringResource(R.string.sync_logs_hint), stringResource(R.string.sync_logs_share), onClick = { shareLog(ctx) }) + } + }, + ) + SettingsGroup(stringResource(R.string.sync_reset_group)) { + SettingsRow(stringResource(R.string.sync_api_key), Syncthing.apiKeyTail?.let { "••••••••••••••••••••$it" }) { + SecondaryButton(stringResource(R.string.sync_regenerate), enabled = Syncthing.phase == Syncthing.Phase.RUNNING, compact = true) { Syncthing.regenerateApiKey() } + } + ActionRow(stringResource(R.string.sync_reset), stringResource(R.string.sync_reset_hint), stringResource(R.string.sync_reset_button), onClick = { confirmReset = true }) + } + } +} + +@Composable +private fun PortRow(label: String, value: String?) { + val colors = MaterialTheme.colorScheme + SettingsRow(label, null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(value ?: "—", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace, color = colors.onBackground) + Text(stringResource(R.string.common_auto).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.onSurfaceVariant) + } + } +} + +@Composable +private fun ResetDialog(onDismiss: () -> Unit, onReset: () -> Unit) { + val colors = MaterialTheme.colorScheme + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + AppDialog(shown, close, "syncReset", wide = false, maxWidth = 480.dp) { + DialogTitleRow(stringResource(R.string.sync_reset_confirm_title), onClose = close) + Text(stringResource(R.string.sync_reset_hint), fontSize = 13.5.sp, color = colors.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = close) + PrimaryButton(stringResource(R.string.sync_reset_button), compact = true) { onReset(); close() } + } + } +} + +private fun shareLog(context: Context) { + val log = Syncthing.logFile(context).takeIf { it.isFile } ?: return + val out = File(File(context.cacheDir, "share").apply { mkdirs() }, "syncthing-log.txt") + log.copyTo(out, overwrite = true) + val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", out) + val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_STREAM, uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + send.clipData = ClipData.newRawUri(out.name, uri) + context.startActivity(Intent.createChooser(send, context.getString(R.string.logshare_chooser)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SyncFolderPicker.kt b/app/src/main/java/com/droiddeck/launcher/ui/SyncFolderPicker.kt new file mode 100644 index 000000000..092b69615 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SyncFolderPicker.kt @@ -0,0 +1,130 @@ +package com.droiddeck.launcher.ui + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.CreateNewFolder +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.FolderSpecial +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.SdCard +import androidx.compose.material.icons.outlined.Smartphone +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.sync.Syncthing +import java.io.File + +internal class SyncLocation(val root: File, val label: String, val icon: ImageVector) + +internal fun containerHome(context: Context) = File(LinuxRuntime.rootDir(context), "root") + +internal fun syncLocations(context: Context): List { + val volumes = pickVolumes(context) + val roots = Syncthing.storageRoots(context) + val shared = roots.drop(1).mapIndexed { i, root -> + val volume = volumes.firstOrNull { root.path.startsWith(it.root.path + "/") } + if (i == 0) SyncLocation(root, context.getString(R.string.sync_location_shared), Icons.Outlined.Smartphone) + else SyncLocation(root, context.getString(R.string.sync_location_card, volume?.label ?: root.path), Icons.Outlined.SdCard) + } + val home = containerHome(context).takeIf { it.isDirectory }?.let { SyncLocation(it, context.getString(R.string.sync_location_home), Icons.Outlined.Home) } + return listOfNotNull(home, SyncLocation(Syncthing.folderRoot(context), context.getString(R.string.sync_location_sync), Icons.Outlined.FolderSpecial)) + + shared + SyncLocation(roots.first(), context.getString(R.string.sync_location_app), Icons.Outlined.Folder) +} + +@Composable +internal fun SyncFolderPicker(title: String, suggestedName: String?, onDismiss: () -> Unit, onPick: (File) -> Unit) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + val locations = remember { syncLocations(ctx) } + val guard = remember { Syncthing.guard(ctx) } + fun locationOf(d: File) = locations.filter { d.path == it.root.path || d.path.startsWith(it.root.path + "/") }.maxByOrNull { it.root.path.length } + var dir by remember { mutableStateOf(containerHome(ctx).takeIf { it.isDirectory } ?: Syncthing.folderRoot(ctx).also { it.mkdirs() }) } + var refresh by remember { mutableIntStateOf(0) } + var naming by remember { mutableStateOf(false) } + val location = dir?.let { locationOf(it) } + val up = { dir = dir?.let { d -> if (location == null || d.path == location.root.path) null else d.parentFile } } + val back = { if (dir == null) close() else up() } + val first = remember(dir) { FocusRequester() } + LaunchedEffect(dir) { focusWithinFrames({ false }) { first } } + val folders = remember(dir, refresh) { + dir?.listFiles()?.filter { it.isDirectory && it.canRead() && guard.browsable(it) } + ?.sortedBy { it.name.lowercase() }.orEmpty() + } + var locked by remember { mutableStateOf(null) } + if (naming) { + TextEntryDialog(stringResource(R.string.sync_new_folder), suggestedName.orEmpty(), onDismiss = { naming = false }) { name -> + val parent = dir + if (parent != null && validName(name)) { + val created = File(parent, name) + if (created.isDirectory || created.mkdirs()) { dir = created; refresh++ } + } + } + } + AppDialog( + shown, close, "syncFolderPicker", wide = false, maxWidth = 520.dp, width = locked, fitContent = locked == null, back = back, + modifier = Modifier.onSizeChanged { if (locked == null && it.width > 0) locked = with(density) { it.width.toDp() } }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + DialogTitleRow(title, onClose = close) + if (dir != null && location != null) { + val crumbs = listOf(location.label) + dir!!.path.removePrefix(location.root.path).split('/').filter { it.isNotEmpty() } + Text(crumbs.joinToString(" › "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.7f).dp + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()).padding(top = 4.dp), + ) { + if (dir == null) { + locations.forEachIndexed { i, l -> + key(l.root.path) { PickerRow(l.icon, l.label, null, tall = true, focus = if (i == 0) first else null) { dir = l.root.also { it.mkdirs() } } } + } + } else { + PickerRow(Icons.AutoMirrored.Outlined.ArrowBack, "..", null, focus = first, onClick = up) + PickerRow(Icons.Filled.Check, stringResource(R.string.sync_use_folder), null, accent = true, enabled = guard.allows(dir!!.path)) { + onPick(dir!!); close() + } + PickerRow(Icons.Outlined.CreateNewFolder, stringResource(R.string.sync_new_folder), null) { naming = true } + for (f in folders) key(f.path) { + PickerRow(Icons.Outlined.Folder, f.name, null) { dir = f } + } + } + } + } + } +} + +private fun validName(name: String) = name.isNotBlank() && '/' !in name && name != "." && name != ".." diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SyncPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/SyncPanel.kt new file mode 100644 index 000000000..e6073d710 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SyncPanel.kt @@ -0,0 +1,436 @@ +package com.droiddeck.launcher.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.sync.SyncDevice +import com.droiddeck.launcher.sync.SyncFolder +import com.droiddeck.launcher.sync.SyncPendingFolder +import com.droiddeck.launcher.sync.SyncSnapshot +import com.droiddeck.launcher.sync.Syncthing +import com.droiddeck.launcher.sync.SyncthingWebActivity +import java.io.File + +@Composable +internal fun SyncPanel(host: MenuHost, runtimeReady: Boolean, onAdvanced: () -> Unit) { + val ctx = LocalContext.current + DisposableEffect(Unit) { val release = Syncthing.watch(); onDispose { release() } } + var showId by rememberSaveable { mutableStateOf(false) } + var confirmOff by rememberSaveable { mutableStateOf(false) } + val snap = Syncthing.snapshot + if (showId && snap != null) DeviceIdDialog(snap.myId) { showId = false } + if (confirmOff) TurnOffDialog(onDismiss = { confirmOff = false }) { forget -> + Syncthing.setEnabled(ctx, false, forget) + } + val phase = Syncthing.phase + when { + phase == Syncthing.Phase.FAILED -> SyncFailed( + if (Syncthing.failure == Syncthing.FAILURE_MISSING) stringResource(R.string.sync_missing) else Syncthing.failure.orEmpty(), + onRetry = { Syncthing.restart() }, onAdvanced = onAdvanced, onOff = { confirmOff = true }) + !Syncthing.enabled -> SyncOff(host, runtimeReady) { Syncthing.setEnabled(ctx, true) } + phase != Syncthing.Phase.RUNNING || snap == null -> SyncStarting() + else -> SyncRunning(snap, onShowId = { showId = true }, onAdvanced = onAdvanced, onOff = { confirmOff = true }) + } +} + +@Composable +private fun SyncOff(host: MenuHost, runtimeReady: Boolean, onEnable: () -> Unit) { + val missing = Syncthing.failure == Syncthing.FAILURE_MISSING + val hint = when { + missing -> R.string.sync_missing + !runtimeReady -> R.string.sync_needs_runtime + else -> R.string.sync_enable_hint + } + SettingsGroup(stringResource(R.string.sync_group)) { + ToggleRow( + host, "sync-enable", stringResource(R.string.sync_enable), stringResource(hint), false, enabled = !missing && runtimeReady, + ) { if (it) onEnable() } + } +} + +@Composable +private fun SyncStarting() { + val colors = MaterialTheme.colorScheme + Column( + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 96.dp), + ) { + CircularProgressIndicator(strokeWidth = 3.dp, color = LocalPalette.current.signal, modifier = Modifier.size(40.dp)) + Text(stringResource(R.string.sync_starting), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant) + } +} + +@Composable +private fun SyncFailed(reason: String, onRetry: () -> Unit, onAdvanced: () -> Unit, onOff: () -> Unit) { + SettingsGroup(stringResource(R.string.sync_group)) { + SettingsRow(stringResource(R.string.sync_status_failed), reason) { + SecondaryButton(stringResource(R.string.sync_failed_retry), compact = true, onClick = onRetry) + } + SettingsRow(stringResource(R.string.sync_advanced), stringResource(R.string.sync_advanced_hint)) { + SecondaryButton(stringResource(R.string.sync_open), compact = true, onClick = onAdvanced) + } + SettingsRow(stringResource(R.string.sync_running), stringResource(R.string.sync_running_hint)) { + ToggleSwitch(true, label = stringResource(R.string.sync_running)) { if (!it) onOff() } + } + } +} + +@Composable +private fun SyncRunning(snap: SyncSnapshot, onShowId: () -> Unit, onAdvanced: () -> Unit, onOff: () -> Unit) { + PairedColumns( + { + SettingsGroup(stringResource(R.string.sync_group)) { + StatusCard(snap) + SettingsRow(stringResource(R.string.sync_running), stringResource(R.string.sync_running_hint)) { + ToggleSwitch(true, label = stringResource(R.string.sync_running)) { if (!it) onOff() } + } + } + SettingsGroup(stringResource(R.string.sync_this_device)) { + SettingsRow(stringResource(R.string.sync_device_id), shortId(snap.myId)) { + SecondaryButton(stringResource(R.string.sync_show_qr), compact = true, onClick = onShowId) + } + SettingsRow(stringResource(R.string.sync_advanced), stringResource(R.string.sync_advanced_hint)) { + SecondaryButton(stringResource(R.string.sync_open), compact = true, onClick = onAdvanced) + } + } + }, + { + Waiting(snap) + SettingsGroup(stringResource(R.string.sync_folders)) { + if (snap.folders.isEmpty()) SettingsRow(stringResource(R.string.sync_no_folders), null) {} + snap.folders.sortedByDescending { it.blocked }.forEach { FolderRow(it) } + AddFolderRow(snap.devices) + } + SettingsGroup(stringResource(R.string.sync_devices)) { + if (snap.devices.isEmpty()) SettingsRow(stringResource(R.string.sync_no_devices), null) {} + snap.devices.forEach { DeviceRow(it) } + } + }, + ) +} + +@Composable +internal fun PairedColumns(left: @Composable ColumnScope.() -> Unit, right: @Composable ColumnScope.() -> Unit) { + if (LocalNarrowPane.current) { + Column { left(); right() } + return + } + Row(horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { left() } + Column(Modifier.weight(1f)) { right() } + } +} + +@Composable +private fun StatusCard(snap: SyncSnapshot) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val paused = Syncthing.pauseRequested || Syncthing.devicesPaused + val (headline, tint) = when { + Syncthing.pauseRequested && Syncthing.devicesPaused -> stringResource(R.string.sync_status_paused) to colors.onSurfaceVariant + Syncthing.pauseRequested -> stringResource(R.string.sync_status_pausing) to colors.onSurfaceVariant + Syncthing.devicesPaused -> stringResource(R.string.sync_status_resuming) to colors.onSurfaceVariant + snap.needsAttention -> stringResource(R.string.sync_status_attention) to AttentionAmber + snap.syncing -> stringResource(R.string.sync_status_syncing) to pal.signal + else -> stringResource(R.string.sync_status_up_to_date) to pal.good + } + val detail = when (Syncthing.autoPause) { + Syncthing.AutoPause.WIFI -> stringResource(R.string.sync_paused_wifi) + Syncthing.AutoPause.GAME -> stringResource(R.string.sync_paused_game) + null -> if (snap.syncing && !paused) stringResource(R.string.sync_left, Formatter.formatShortFileSize(ctx, snap.needBytes)) + else stringResource(R.string.sync_summary, snap.folders.size, snap.devices.count { it.connected }, snap.devices.size) + } + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + when { + snap.syncing && !paused -> Icon(Icons.Outlined.Sync, null, tint = tint, modifier = Modifier.size(22.dp)) + snap.needsAttention && !paused -> Icon(Icons.Outlined.WarningAmber, null, tint = tint, modifier = Modifier.size(22.dp)) + else -> Box(Modifier.size(12.dp).clip(CircleShape).background(tint)) + } + Text(headline, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.weight(1f)) + if (snap.syncing && !paused) Text("${snap.percent}%", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = pal.signal) + } + if (snap.syncing && !paused) LinearProgressIndicator( + progress = { snap.percent / 100f }, color = pal.signal, trackColor = pal.line, modifier = Modifier.fillMaxWidth().height(4.dp).clip(CircleShape), + ) + Text(detail, fontSize = 12.5.sp, color = colors.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text("↓ ${rate(ctx, snap.inBytesPerSecond)}", fontSize = 13.sp, color = colors.onSurfaceVariant) + Text("↑ ${rate(ctx, snap.outBytesPerSecond)}", fontSize = 13.sp, color = colors.onSurfaceVariant) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + PrimaryButton(stringResource(R.string.sync_open_web_ui), compact = true, modifier = Modifier.weight(1f)) { openWebUi(ctx) } + SecondaryButton(stringResource(if (Syncthing.userPaused) R.string.sync_resume else R.string.sync_pause), compact = true) { + Syncthing.setPaused(ctx, !Syncthing.userPaused) + } + } + } + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) +} + +@Composable +private fun Waiting(snap: SyncSnapshot) { + if (snap.pendingFolders.isEmpty() && snap.pendingDevices.isEmpty()) return + val ctx = LocalContext.current + var accepting by remember { mutableStateOf(null) } + accepting?.let { f -> + SyncFolderPicker(stringResource(R.string.sync_accept_title, f.label), f.label, onDismiss = { accepting = null }) { dir -> + Syncthing.addFolder(ctx, dir, f.label, listOf(f.deviceId), f.id) { ok -> if (!ok) addFailed(ctx) } + } + } + SettingsGroup(stringResource(R.string.sync_waiting)) { + snap.pendingDevices.forEach { name -> + SettingsRow(stringResource(R.string.sync_pending_device, name), null, highlighted = true) { + SecondaryButton(stringResource(R.string.sync_review), compact = true) { openWebUi(ctx) } + } + } + snap.pendingFolders.forEach { f -> + SettingsRow(stringResource(R.string.sync_pending_folder, f.offeredBy, f.label), stringResource(R.string.sync_pending_folder_hint), highlighted = true) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton(stringResource(R.string.sync_review), compact = true) { openWebUi(ctx) } + PrimaryButton(stringResource(R.string.sync_accept), compact = true) { accepting = f } + } + } + } + } +} + +@Composable +private fun AddFolderRow(devices: List) { + val ctx = LocalContext.current + var picking by remember { mutableStateOf(false) } + var picked by remember { mutableStateOf(null) } + if (picking) SyncFolderPicker(stringResource(R.string.sync_add_folder), null, onDismiss = { picking = false }) { picked = it } + picked?.let { dir -> + ShareFolderDialog(dir, devices, onDismiss = { picked = null }) { chosen -> + Syncthing.addFolder(ctx, dir, dir.name, chosen) { ok -> if (!ok) addFailed(ctx) } + } + } + SettingsRow(stringResource(R.string.sync_add_folder), stringResource(R.string.sync_add_folder_hint)) { + SecondaryButton(stringResource(R.string.sync_choose), compact = true) { picking = true } + } +} + +@Composable +private fun ShareFolderDialog(folder: File, devices: List, onDismiss: () -> Unit, onAdd: (List) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + val chosen = remember { mutableStateListOf() } + AppDialog(shown, close, "syncShareFolder", wide = false, maxWidth = 520.dp) { + DialogTitleRow(folder.name, onClose = close) + Text(folder.path, fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (devices.isEmpty()) { + Text(stringResource(R.string.sync_share_none), fontSize = 13.5.sp, color = colors.onSurfaceVariant) + } else { + Text(stringResource(R.string.sync_share_with), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + devices.forEach { d -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clip(Shape12).border(1.dp, pal.line, Shape12).background(colors.background).padding(end = 12.dp), + ) { + Checkbox(d.id in chosen, { if (it) chosen.add(d.id) else chosen.remove(d.id) }) + Text(d.name, fontSize = 14.sp, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = close) + PrimaryButton(stringResource(R.string.sync_add), compact = true) { onAdd(chosen.toList()); close() } + } + } +} + +private fun addFailed(context: Context) { + android.widget.Toast.makeText(context, R.string.sync_add_failed, android.widget.Toast.LENGTH_LONG).show() +} + +@Composable +private fun FolderRow(f: SyncFolder) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + if (f.blocked) { + Column(Modifier.fillMaxWidth().background(colors.error.copy(alpha = 0.06f)).padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Outlined.Block, null, tint = colors.error, modifier = Modifier.size(18.dp)) + Text(f.label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.sync_folder_blocked), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.error) + } + Text(f.path, fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.sync_folder_blocked_hint), fontSize = 12.5.sp, color = colors.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) { + SecondaryButton(stringResource(R.string.sync_remove_folder), compact = true) { Syncthing.removeFolder(f.id) } + SecondaryButton(stringResource(R.string.sync_open_web_ui), compact = true) { openWebUi(ctx) } + } + Text(stringResource(R.string.sync_remove_folder_hint), fontSize = 12.sp, color = colors.onSurfaceVariant) + } + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + return + } + val (state, tint) = when { + f.paused -> stringResource(R.string.sync_folder_paused) to colors.onSurfaceVariant + f.syncing -> stringResource(R.string.sync_folder_syncing, percent(f)) to pal.signal + f.outOfSync -> stringResource(R.string.sync_folder_out_of_sync) to AttentionAmber + f.state == "scanning" || f.state == "scan-waiting" -> stringResource(R.string.sync_folder_scanning) to colors.onSurfaceVariant + else -> stringResource(R.string.sync_folder_up_to_date) to pal.good + } + val detail = when { + f.errors > 0 -> stringResource(R.string.sync_folder_errors, f.errors) + f.sharedWith.isEmpty() -> Formatter.formatShortFileSize(ctx, f.globalBytes) + else -> stringResource(R.string.sync_folder_detail, Formatter.formatShortFileSize(ctx, f.globalBytes), f.sharedWith.joinToString(", ")) + } + SettingsRow(f.label, detail) { StateLabel(state, tint) } +} + +@Composable +private fun DeviceRow(d: SyncDevice) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val detail = when { + d.connected -> d.address?.substringAfter("://") + d.lastSeen != null -> stringResource(R.string.sync_last_seen, DateUtils.getRelativeTimeSpanString(d.lastSeen).toString()) + else -> null + } + SettingsRow(d.name, detail) { + if (d.connected) StateLabel(stringResource(R.string.sync_connected), pal.good) + else StateLabel(stringResource(R.string.sync_offline), colors.onSurfaceVariant) + } +} + +@Composable +private fun StateLabel(text: String, tint: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + Box(Modifier.size(9.dp).clip(CircleShape).background(tint)) + Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 1) + } +} + +@Composable +private fun DeviceIdDialog(id: String, onDismiss: () -> Unit) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + val qr = remember(id) { qrBitmap(id) } + AppDialog(shown, close, "syncDeviceId", wide = true, maxWidth = 640.dp) { + DialogTitleRow(stringResource(R.string.sync_device_id_title), onClose = close) + Row(horizontalArrangement = Arrangement.spacedBy(22.dp), verticalAlignment = Alignment.CenterVertically) { + Image(qr, stringResource(R.string.sync_device_id), filterQuality = FilterQuality.None, + modifier = Modifier.size(208.dp).clip(Shape14).background(Color.White).padding(10.dp)) + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f)) { + Text(id.chunked(32).joinToString("\n"), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace, + color = colors.onBackground, lineHeight = 26.sp) + Text(stringResource(R.string.sync_device_id_hint), fontSize = 13.sp, color = colors.onSurfaceVariant) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.sync_copy), compact = true) { + ctx.getSystemService(ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText("Syncthing device ID", id)) + } + SecondaryButton(stringResource(R.string.sync_share), compact = true) { + ctx.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, id), null)) + } + PrimaryButton(stringResource(R.string.sync_done), compact = true, onClick = close) + } + } +} + +@Composable +private fun TurnOffDialog(onDismiss: () -> Unit, onTurnOff: (Boolean) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + var forget by remember { mutableStateOf(false) } + AppDialog(shown, close, "syncTurnOff", wide = false, maxWidth = 520.dp) { + DialogTitleRow(stringResource(R.string.sync_off_title), onClose = close) + Text(stringResource(R.string.sync_off_body), fontSize = 13.5.sp, color = colors.onSurfaceVariant) + Row( + verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().clip(Shape12).border(1.dp, pal.line, Shape12).background(colors.background).padding(end = 12.dp, top = 2.dp, bottom = 8.dp), + ) { + Checkbox(forget, { forget = it }) + Column(Modifier.padding(top = 12.dp)) { + Text(stringResource(R.string.sync_off_forget), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + Text(stringResource(R.string.sync_off_forget_hint), fontSize = 12.5.sp, color = colors.onSurfaceVariant) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = close) + PrimaryButton(stringResource(R.string.sync_turn_off), compact = true) { onTurnOff(forget); close() } + } + } +} + +internal fun openWebUi(context: Context) { + context.startActivity(Intent(context, SyncthingWebActivity::class.java)) +} + +private fun shortId(id: String) = if (id.length > 16) "${id.take(7)}-…-${id.takeLast(7)}" else id + +private fun rate(context: Context, bytesPerSecond: Long) = Formatter.formatShortFileSize(context, bytesPerSecond) + "/s" + +private fun percent(f: SyncFolder): Int = + if (f.globalBytes <= 0) 0 else (((f.globalBytes - f.needBytes).coerceAtLeast(0) * 100) / f.globalBytes).toInt().coerceIn(0, 99) + +private fun qrBitmap(text: String): ImageBitmap { + val matrix = com.google.zxing.qrcode.QRCodeWriter().encode(text, com.google.zxing.BarcodeFormat.QR_CODE, 0, 0) + val bitmap = android.graphics.Bitmap.createBitmap(matrix.width, matrix.height, android.graphics.Bitmap.Config.ARGB_8888) + for (x in 0 until matrix.width) for (y in 0 until matrix.height) { + bitmap.setPixel(x, y, if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE) + } + return bitmap.asImageBitmap() +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 352b29a54..cb6d5ed22 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -52,7 +52,6 @@ object Themes { const val DEFAULT_PRIMARY = 0xFF8B5CF6.toInt() const val DEFAULT_SECONDARY = 0xFF22D3EE.toInt() - /** The two colours of the custom Gradient theme: primary, then secondary. Changing them restyles the app at once. */ var gradientColors by mutableStateOf(DEFAULT_PRIMARY to DEFAULT_SECONDARY) private set @@ -60,11 +59,9 @@ object Themes { const val DEFAULT_RGB_SPEED = 3 const val RGB_HUE_STEP = 3f - /** How fast Inner Gamer cycles, 1 (slow) to [RGB_SPEEDS] (fast). */ var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED) private set - /** Where Inner Gamer is on the colour wheel, in whole [RGB_HUE_STEP] steps so the app restyles only when the colour visibly moves. */ var rgbHue by mutableStateOf(0f) private set @@ -78,7 +75,6 @@ object Themes { rgbSpeed = value } - /** Milliseconds for one trip round the colour wheel. */ fun rgbCycleMillis(speed: Int): Int = 30_000 / speed.coerceIn(1, RGB_SPEEDS) fun loadGradient(context: Context) { @@ -86,7 +82,6 @@ object Themes { rgbSpeed = SessionPrefs.rgbSpeed(context) } - /** The Inner Gamer palette at one point on the colour wheel. */ fun innerGamer(hue: Float): Palette = ThemeColors.palette( Color.hsv(hue % 360f, 0.9f, 1f).toArgb(), Color.hsv((hue + 60f) % 360f, 0.9f, 1f).toArgb(), R.string.theme_inner_gamer, R.string.theme_inner_gamer_detail, INNER_GAMER, @@ -97,7 +92,6 @@ object Themes { gradientColors = primary to secondary } - /** The theme the person chose, with the custom colours read first; for code that is not in a composition. */ fun current(context: Context): Palette { val id = SessionPrefs.theme(context) if (id == GRADIENT || id == INNER_GAMER) loadGradient(context) @@ -186,14 +180,12 @@ private val ContentDirected = Typography().run { ) } -/** Turns the colour wheel on the system's own animation clock; with animations off, in the app or in Android, the colour holds still. */ @Composable private fun InnerGamerHue(speed: Int) { if (Motion.scale == 0f) return key(speed) { InnerGamerCycle(speed) } } -/** One run of the cycle at one speed, picking up from the colour on screen so a new speed takes over at once without a jump. */ @Composable private fun InnerGamerCycle(speed: Int) { val start = remember { Themes.rgbHue } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt index b2db63562..938d2ef90 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt @@ -7,9 +7,7 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -/** Colour maths for the custom gradient theme: hue, saturation and brightness in, a readable palette out. */ internal object ThemeColors { - /** Hue 0..360, saturation and brightness 0..1. */ class Hsv(val h: Float, val s: Float, val v: Float) fun toHsv(argb: Int): Hsv { @@ -32,7 +30,6 @@ internal object ThemeColors { fun hex(argb: Int): String = "#%06X".format(argb and 0xFFFFFF) - /** `#RRGGBB`, `RRGGBB` or `#RGB`; null for anything else. */ fun parseHex(text: String): Int? { val digits = text.trim().removePrefix("#") if (!digits.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null @@ -46,7 +43,6 @@ internal object ThemeColors { private const val MIN_LUMINANCE = 0.10f - /** The colour, made brighter (then paler, for a deep blue that brightness alone cannot lift) when it is too dark to show on the dark background. */ fun readable(argb: Int): Int { if (Color(argb).luminance() >= MIN_LUMINANCE) return argb or 0xFF000000.toInt() val hsv = toHsv(argb) @@ -64,7 +60,6 @@ internal object ThemeColors { return out } - /** A dark neutral tinted with the primary's hue; plain greys stay grey. */ fun tint(primary: Int, saturation: Float, brightness: Float): Color { val hsv = toHsv(primary) val strength = (hsv.s / 0.35f).coerceIn(0f, 1f) @@ -79,7 +74,6 @@ internal object ThemeColors { return (max(x, y) + 0.05f) / (min(x, y) + 0.05f) } - /** Dark or white text, whichever reads better on the worse of the two gradient colours. */ internal fun inkFor(first: Int, second: Int): Color { fun worst(ink: Color) = min(contrast(ink, Color(first)), contrast(ink, Color(second))) return if (worst(DARK_INK) >= worst(Color.White)) DARK_INK else Color.White @@ -87,7 +81,6 @@ internal object ThemeColors { private const val MIN_CONTRAST = 4.5f - /** The colour nudged just enough, lighter for dark text and darker for white text, to give [ink] a readable contrast on it. */ private fun fitTo(ink: Color, argb: Int): Int { fun short(color: Int) = contrast(ink, Color(color)) < MIN_CONTRAST if (!short(argb)) return argb diff --git a/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt index 0fda34fc2..96502bb79 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt @@ -55,7 +55,6 @@ import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_DRAG_MOVE @Composable fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit) { val context = LocalContext.current - // Keep an open editor attached to the game it was opened for. val targetGame = remember { game } val initialDefaults = remember { GesturePrefs.read(context) } val initialGame = remember { targetGame?.takeIf { GesturePrefs.hasOverride(context, it) }?.let { GesturePrefs.read(context, it) } } @@ -169,7 +168,6 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit private fun GestureDialogFrame(onDismiss: () -> Unit, footer: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit) { val p = LocalPalette.current - // The app window does not opt into RTL, so these dialogs follow their resource locale explicitly. val direction = if (LocalConfiguration.current.layoutDirection == android.view.View.LAYOUT_DIRECTION_RTL) LayoutDirection.Rtl else LayoutDirection.Ltr Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { CompositionLocalProvider(LocalLayoutDirection provides direction) { diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index 3a86e28af..1bbeb18e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -211,10 +211,6 @@ object AppUpdates { internal class Guess(val follow: Follow, val certain: Boolean) - /** - * Stable and Preview builds of one commit are the same APK. Preview history can include older Stable releases, - * so an overlapping older-build guess keeps the Stable default for this run without persisting it. - */ internal fun guessFollow(catalog: Catalog?, me: Installed): Guess { if (me.pr != 0) return Guess(Follow(Channel.TEST, me.pr), true) val stable = catalog?.stable diff --git a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt index 87d958889..5d1d33455 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt @@ -57,7 +57,6 @@ object SelfInstaller { } @Suppress("DEPRECATION") val flags = if (Build.VERSION.SDK_INT >= 28) { - // Android 9 and 10 only collect an archive's certificates when GET_SIGNATURES is set. PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES } else { PackageManager.GET_SIGNATURES @@ -91,7 +90,6 @@ object SelfInstaller { } } - /** Reports the session id before the commit, so a status broadcast that arrives right away is recognised. */ fun install(context: Context, apk: File, onSession: (Int) -> Unit = {}) { val installer = context.packageManager.packageInstaller val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { @@ -114,7 +112,6 @@ object SelfInstaller { } } - /** Only a system installer activity may be launched from the status broadcast, without URI grants. */ fun safeConfirmIntent(context: Context, confirm: Intent): Intent? { val target = confirm.resolveActivity(context.packageManager) ?: return null val info = runCatching { context.packageManager.getApplicationInfo(target.packageName, 0) }.getOrNull() ?: return null diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/BottomDesktopCompositorService.kt b/app/src/main/java/com/droiddeck/launcher/wayland/BottomDesktopCompositorService.kt new file mode 100644 index 000000000..59c182dd3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/wayland/BottomDesktopCompositorService.kt @@ -0,0 +1,117 @@ +package com.droiddeck.launcher.wayland + +import android.app.Service +import android.content.Intent +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.util.Log +import android.view.Surface +import com.droiddeck.launcher.core.FileUtils +import java.io.File + +/** + * The bottom-screen desktop's compositor. The compositor's native state is one per process, so the + * desktop gets a process of its own while the session's compositor keeps the top screen. + */ +class BottomDesktopCompositorService : Service() { + private var client: Messenger? = null + /** Echoed in every reply, so the session process can tell replies to an earlier attach apart. */ + @Volatile private var attachTag = 0 + + private val handler = object : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + when (msg.what) { + MSG_ATTACH -> attach(msg) + MSG_DETACH -> CompositorHost.detach(null) + MSG_TOUCH -> WaylandCompositor.nativeSendTouch(msg.arg1 and 0xff, msg.arg1 shr 8, + msg.arg2 ushr 16, msg.arg2 and 0xffff) + MSG_KEY -> WaylandCompositor.nativeSendKey(msg.arg1, msg.arg2) + MSG_POINTER -> WaylandCompositor.nativeSendPointer(1, msg.arg1, msg.arg2) + MSG_SCENE -> WaylandCompositor.nativeSendSceneInput(msg.arg1, msg.arg2, msg.peekData()?.getInt(KEY_B) ?: 0) + MSG_CURSOR -> if (CompositorHost.isStarted) WaylandCompositor.nativeSetCursorComposite(msg.arg1 != 0) + MSG_NEW_GUEST -> WaylandCompositor.nativeResetFirstFrame() + MSG_TEXT -> msg.data?.getString(KEY_TEXT)?.let { WaylandCompositor.textInputCommit(it) } + MSG_STOP -> { + Log.i(TAG, "bottom-screen desktop closed; ending its compositor process") + // A producer that dies still connected keeps the panel's surface from taking another one. + CompositorHost.detach(null) + postDelayed({ android.os.Process.killProcess(android.os.Process.myPid()) }, 300) + } + else -> super.handleMessage(msg) + } + } + } + + private val messenger = Messenger(handler) + + override fun onBind(intent: Intent?): IBinder = messenger.binder + + override fun onUnbind(intent: Intent?): Boolean { + // The session process went away without saying so; nothing is left to draw for. + android.os.Process.killProcess(android.os.Process.myPid()) + return false + } + + private fun attach(msg: Message) { + val surface = msg.obj as? Surface ?: return + val data = msg.data ?: return + client = msg.replyTo + attachTag = msg.arg1 + val runtimeDir = File(data.getString(KEY_RUNTIME_DIR) ?: return).apply { mkdirs() } + val width = data.getInt(KEY_WIDTH) + val height = data.getInt(KEY_HEIGHT) + if (!CompositorHost.isStarted) { + FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb")) + data.getString(KEY_LOG)?.let { path -> + WaylandCompositor.setSessionLogFile(File(path)) + runCatching { android.system.Os.setenv("BL_WAYLAND_LOG", path, true) } + .onFailure { Log.w(TAG, "could not point the compositor's log at $path", it) } + } + WaylandCompositor.setFirstFrameListener { reply(MSG_FIRST_FRAME) } + WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER) + } + val turnip = com.droiddeck.launcher.gpu.TurnipDriver(this) + val driverId = if (CompositorHost.isStarted) null else turnip.install() + val started = CompositorHost.startOrAttach( + this, surface, runtimeDir.path, driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) }, + applicationInfo.nativeLibraryDir, + width, height, data.getFloat(KEY_REFRESH, 60f), 0, + ) + Log.i(TAG, (if (started) "compositor started" else "compositor attached") + " at ${width}x$height in ${runtimeDir.path}") + reply(MSG_ATTACHED) + } + + private fun reply(what: Int) { + val target = client ?: return + runCatching { target.send(Message.obtain(null, what, attachTag, 0)) } + .onFailure { Log.w(TAG, "the session process did not take message $what", it) } + } + + companion object { + private const val TAG = "BottomDesktopComp" + const val MSG_ATTACH = 1 + const val MSG_DETACH = 2 + const val MSG_TOUCH = 3 + const val MSG_KEY = 4 + const val MSG_TEXT = 5 + const val MSG_STOP = 6 + const val MSG_NEW_GUEST = 7 + const val MSG_POINTER = 8 + const val MSG_SCENE = 9 + const val MSG_CURSOR = 10 + const val MSG_ATTACHED = 101 + const val MSG_FIRST_FRAME = 102 + const val KEY_RUNTIME_DIR = "runtimeDir" + const val KEY_WIDTH = "width" + const val KEY_HEIGHT = "height" + const val KEY_REFRESH = "refresh" + const val KEY_LOG = "log" + const val KEY_TEXT = "text" + const val KEY_B = "b" + private const val SCALE_FIT = 1 + private const val ALIGN_CENTER = 0 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index ea06879fa..716644002 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -21,7 +21,6 @@ object CompositorHost { @Volatile private var ownVsyncThread = true - // The screen tick gets its own thread so a busy main thread cannot delay the compositor's frames. private val vsyncThread: HandlerThread by lazy { HandlerThread("droiddeck-vsync", Process.THREAD_PRIORITY_DISPLAY).apply { start() } } @@ -30,7 +29,6 @@ object CompositorHost { @Volatile private var vsyncGeneration = 0 - // Each resume gets a new callback; one left queued from an earlier resume, on either thread, stops itself. private fun vsyncCallback(generation: Int) = object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { if (generation != vsyncGeneration) return diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 073ed60ff..8fd329ce2 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -45,9 +45,8 @@ public final class WaylandCompositor { private static volatile long lastFrameNanos; private static volatile long frameIntervalNanos; - private static volatile long gameFrames; // written by the compositor thread only + private static volatile long gameFrames; - /** Frames the game has presented since the app started; the rate is the difference over time. */ public static long gameFrameCount() { return gameFrames; } public static long recentFrameIntervalMs() { @@ -148,6 +147,10 @@ public final class WaylandCompositor { public static native void nativeStart(String xdgRuntimeDir); + /** Nanoseconds of the compositor's own GPU work while timing is on, or -1 when it is not timed. */ + public static native long nativeGpuBusyNs(); + public static native void nativeSetGpuTiming(boolean on); + public static native void nativeStartWithSurface(Surface surface, String xdgRuntimeDir, String driverPath, String libraryName, String nativeLibDir); @@ -317,7 +320,6 @@ public final class WaylandCompositor { public static native long nativeHudFrameTimes(float[] out); - /** Stream mode: the scene is the Xbox Cloud Gaming native player's picture, handed over frame by frame. */ public static native void nativeStreamMode(boolean on); public static native void nativeStreamFrame(android.hardware.HardwareBuffer buffer); @@ -332,7 +334,6 @@ public final class WaylandCompositor { private static float pendingDeltaX, pendingDeltaY; public static void sendPointerDelta(float dx, float dy) { - // Round-to-zero per event would drop slow motion entirely; carry the remainder. pendingDeltaX += dx * 256f; pendingDeltaY += dy * 256f; int x = (int) Math.floor(pendingDeltaX); diff --git a/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java b/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java index 2a196ed2d..c26b295e8 100644 --- a/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java +++ b/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java @@ -19,31 +19,14 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.util.HashMap; -/** - * DroidDeck's hardware video decoder factory for the native Xbox Cloud Gaming player. It builds WebRTC's own - * AndroidVideoDecoder (hence this package) around a MediaCodec wrapper that can: - * - ask the decoder for low-latency output (Android's KEY_LOW_LATENCY and Qualcomm's low-latency / picture-order - * vendor keys; unknown keys are ignored, and a decoder that refuses them is configured without them); - * - for zero-copy, decode straight onto the player's SurfaceView instead of WebRTC's texture, rendering every - * output buffer there (no GL copy, no compositor; WebRTC then gets no decoded frames to draw). - * Without either option it behaves like WebRTC's HardwareVideoDecoderFactory. - * The route is switchable while the decoder runs (setOutputSurface): {@link #useScreen} sends frames straight to the - * screen (zero-copy) or, with null, back to WebRTC's texture (for the Vulkan compositor and frame generation); - * {@link #setZeroCopySurface} moves a zero-copy decoder to a stand-in surface while the screen surface is gone. - * The factory also reports the decoded size and the decode time (input queued to output ready), which WebRTC's - * stats lack in zero-copy. - */ public class DroidDeckLowLatencyDecoderFactory implements VideoDecoderFactory { - /** Counts frames sent to the display in zero-copy mode (WebRTC's own stats see none). */ public interface FrameCounter { void onFrameShown(); } private final HardwareVideoDecoderFactory hardware; private final EglBase.Context sharedContext; private final boolean lowLatency; - /** Frames go to {@link #zeroCopySurface} (the screen or the stand-in), not WebRTC's texture. */ private volatile boolean zeroCopy; private volatile Surface zeroCopySurface; - /** Back on WebRTC's texture: render the next buffer there, so WebRTC's pending texture frame completes. */ private volatile boolean renderNext; private final FrameCounter counter; private volatile Wrapper live; @@ -64,19 +47,11 @@ public class DroidDeckLowLatencyDecoderFactory implements VideoDecoderFactory { this.counter = counter; } - /** - * Zero-copy: the surface to decode onto from now on; null = a stand-in that drops every frame (the screen - * surface is being destroyed). Must be called before a destroyed surface's callback returns. - */ public synchronized void setZeroCopySurface(Surface surface) { if (!zeroCopy) return; moveTo(surface != null ? surface : standInSurface(), surface != null ? "the screen" : "a stand-in"); } - /** - * Switch the route live: a surface = zero-copy onto it; null = WebRTC's texture again. Returns false when the - * decoder refused the new surface (the route is then unchanged). - */ public synchronized boolean useScreen(Surface screen) { if (screen != null) { Wrapper w = live; @@ -120,12 +95,10 @@ public class DroidDeckLowLatencyDecoderFactory implements VideoDecoderFactory { return standIn.getSurface(); } - /** The decoder's picture size, or null before the first decoder is configured. */ public String videoSize() { return width > 0 ? width + "x" + height : null; } - /** Average decode time since the last call, in ms (-1 when no frame was decoded in between). */ public double takeDecodeMs() { synchronized (decodeLock) { double avg = decodeCount > 0 ? decodeSumMs / decodeCount : -1; @@ -170,10 +143,8 @@ public class DroidDeckLowLatencyDecoderFactory implements VideoDecoderFactory { } } - /** MediaCodec itself (not WebRTC's wrapper) so the output surface can be changed while it runs. */ private class Wrapper implements MediaCodecWrapper { final MediaCodec raw; - /** WebRTC's texture surface, the route back from zero-copy. */ Surface texture; Wrapper(MediaCodec raw) { this.raw = raw; } @@ -207,8 +178,6 @@ public class DroidDeckLowLatencyDecoderFactory implements VideoDecoderFactory { if (from.containsKey(key)) to.setInteger(key, from.getInteger(key)); } - // Zero-copy: AndroidVideoDecoder renders one buffer to its texture and drops the rest until the texture - // hands the frame back, which never happens with the display surface; show every buffer instead. @Override public void releaseOutputBuffer(int index, boolean render) { if (zeroCopy) { raw.releaseOutputBuffer(index, true); if (counter != null) counter.onFrameShown(); } else if (renderNext) { renderNext = false; raw.releaseOutputBuffer(index, true); } diff --git a/app/src/main/res/drawable/ic_bottom_keyboard.xml b/app/src/main/res/drawable/ic_bottom_keyboard.xml new file mode 100644 index 000000000..ff8aa7500 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_keyboard.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_bottom_mouse.xml b/app/src/main/res/drawable/ic_bottom_mouse.xml new file mode 100644 index 000000000..7a5584985 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_mouse.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_bottom_touch.xml b/app/src/main/res/drawable/ic_bottom_touch.xml new file mode 100644 index 000000000..ff90202a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_touch.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 820b0951f..4b46f97bf 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -299,6 +299,8 @@ وحدة تحكم Xbox 360 Deck: زر «الوصول السريع» والجيروسكوب. Steam Controller: الشيء نفسه، مع استشعار القبضة على الأزرار الخلفية. DualShock 4 وDualSense: الجيروسكوب، ولوحتا التتبع كلوحة اللمس الخاصة بها. Xbox 360: افتح «الوصول السريع» باستخدام Guide+A. زر الرجوع + الضغط المطوّل على الرجوع لاختصارات Steam + الضغط المطوّل على الرجوع يفتح الوصول السريع أو ينشئ اختصارات Steam مع أزرار أخرى. أوقفه لتجاهل الضغط المطوّل؛ النقر والنقر المزدوج يبقيان يعملان. اضغط مطوّلًا على زر الرجوع لمدة 700 ملّي ثانية، أو اضغط عليه مطوّلًا مع زر آخر لاختصارات Steam. مهلة النقر المزدوج %1$d ملّي ثانية @@ -662,6 +664,8 @@ شريط تثبيت في مكانه اسحب HUD لتحريكه؛ والضغط مطولًا يثبّته أو يلغي تثبيته. أثناء تثبيته، تظل عناصر التحكم على الشاشة الموجودة تحته تعمل. + الالتصاق بالجانبين + اترك الواجهة لتنتقل إلى أقرب جانب من الشاشة. اسحبها بسرعة لترميها في ذلك الاتجاه. المظهر الحجم على الشاشة هادئ @@ -1860,6 +1864,93 @@ لوحة المفاتيح + لوحة اللمس الوحدة الطرفية مقابض Deck + لوحتا اللمس + لوحة المعلومات + لا يوجد ما يُعرض هنا بعد. + التنزيلات + لا توجد تنزيلات جارية. + Steam + جارٍ تنزيل ذاكرة المظلِّلات المؤقتة + متوقف مؤقتًا + بانتظار البدء + الإشعارات + الشاشة السفلية لـ DroidDeck + اعرض الرسائل والإشعارات الأخرى هنا أثناء اللعب. تبقى على هذه الشاشة فقط ولا تُحفظ. يطلب Android الإذن مرة واحدة: تُفتح صفحة الوصول إلى الإشعارات على هذه الشاشة؛ فعِّل DroidDeck هناك وستعود هذه الصفحة تلقائيًا. + السماح بالوصول إلى الإشعارات + لا توجد إشعارات. + الإشعارات: %1$d + مسح + لقطات الشاشة + يتطلب FPS تشغيل واجهة الأداء + السرعة + الدواسات + قوة G + التوقيت + أفضل لفة + الإطارات °C + دواسة الوقود والمكابح، آخر 10 s + السرعة، %1$s + الترس + وحدات السرعة + تلقائي (%1$s) + عميل Steam ARM + عميل Steam x86 + دواسة الوقود + المكابح + لوحة العدادات + تبديل الترس + فرامل اليد + محدِّد سرعة حارة الصيانة + TC + ABS + التيربو %1$s bar + المحرك %1$s °C + الزيت %1$s °C + موزّع الطاقة + الوقود، t + الرئيسي %1$s t + الاحتياطي %1$s t + انقر على الشريط العلوي أو لوحة العدادات لتغيير نمطها. + إرسال بيانات لوحة العدادات + تلقائي + يفعّلها لكل لعبة مدرجة هنا، بما في ذلك الألعاب التي يُعثر عليها لاحقًا. تبقى الألعاب التي توقفها متوقفة. + يغيّر هذا إعداد إخراج البيانات الخاص باللعبة نفسها. يسري في المرة التالية التي تبدأ فيها اللعبة، وإيقافه يعيد الإعداد القديم. + أغلق %1$s لتغيير هذا. + تعذّر تغيير إعدادات %1$s. + %1$s: فعِّل %2$s في إعدادات اللعبة باستخدام IP 127.0.0.1 والمنفذ %3$d. + تُحفظ في Pictures/DroidDeck. تظهر لقطات شاشة Steam هنا أيضًا. + التقاط لقطة شاشة + تم حفظ لقطة الشاشة. + تعذّر التقاط لقطة شاشة. + لا توجد لقطات شاشة بعد. + الدردشة والصوت + اقرأ دردشة Steam وردّ عليها هنا بينما تستمر اللعبة على الشاشة الرئيسية. تستخدم الحساب المسجَّل دخوله في Steam. + لا يملك Steam بعد تسجيل دخول على الويب لمشاركته، لذا سجّل الدخول هنا مرة واحدة. + أغلق Android صفحة الدردشة بينما كان DroidDeck في الخلفية. ستُفتح مجددًا عند عودتك إلى DroidDeck. + فتح Steam Chat + إغلاق الدردشة + الميكروفون مفعّل + الميكروفون متوقف + الميكروفون متوقف لهذه اللعبة. للتحدث، فعِّل «الميكروفون» في إعدادات Steam في DroidDeck (الصوت) وابدأ اللعبة من جديد. + مستوى الصوت %1$d%% + إحصاءات داخل اللعبة + FPS + CPU + GPU + البطارية + اللفة + المركز + اللفة الحالية + اللفة السابقة + الوقود + الحمولة + توزيع الطاقة SYS · ENG · WEP + الوضع القانوني + الرصيد + War Thunder + Elite Dangerous + فعِّل إخراج البيانات في لعبة السباق (يُسمّى غالبًا القياس عن بُعد أو UDP) لرؤية لوحة عدادات حية هنا. تبقى البيانات على هذا الجهاز. + يستقبل المنفذ %1$d بيانات (%2$d حزمة بحجم %3$d بايت)، لكن بتنسيق لا تستطيع هذه الصفحة قراءته بعد. + يستخدم برنامج آخر المنفذ %1$s بالفعل، لذا لا يمكن عرض تلك اللعبة هنا. جارٍ إزالة بيئة تشغيل Linux @@ -2194,6 +2285,7 @@ %1$s يضبط أسلوب التكبير على %2$s. مطابقة الشاشة (%1$d×%2$d) اتباع الشاشة (يُغيَّر الحجم عند الطي أو الفتح) + مقياس %1$s Steam إظهار تفاصيل الجهاز إخفاء تفاصيل الجهاز @@ -3387,6 +3479,113 @@ يمكن إعادة حساب المشاهدات المحفوظة. ابدأ معايرة جديدة لالتقاطها في نظام إحداثيات التتبع الحالي. المجموع الاختباري لنموذج تتبع اليدين غير مطابق تعذر تحميل OpenCV + Syncthing + Syncthing + المزامنة عبر Syncthing + هذا تطبيق لـ Syncthing مبني على Syncthing-Fork، يتيح لك مزامنة الملفات الموجودة في نظام الملفات الداخلي لـ DroidDeck مع نسخ Syncthing أخرى. ينطبق ذلك فقط على الملفات التي تخزّنها داخل نظام الملفات الداخلي لـ DroidDeck. + هذا الإصدار لا يتضمن Syncthing. + جارٍ تشغيل Syncthing + جارٍ البدء + محدَّث + جارٍ المزامنة + متوقف مؤقتًا + جارٍ الإيقاف المؤقت + جارٍ الاستئناف + يحتاج إلى انتباه + توقف Syncthing + إعادة المحاولة + متوقف مؤقتًا حتى الاتصال بشبكة Wi-Fi + متوقف مؤقتًا أثناء اللعب + المجلدات: %1$d · الأجهزة المتصلة: %2$d من %3$d + المتبقي: %1$s + فتح واجهة الويب + إيقاف مؤقت + استئناف + قيد التشغيل + أوقف هذا الخيار لإيقاف Syncthing تمامًا. + هذا الجهاز + معرّف الجهاز + عرض رمز QR + متقدم + المنافذ، وقت التشغيل، إعادة التعيين + فتح + بانتظارك + يريد %1$s الاتصال + يريد %1$s مشاركة «%2$s» + اقبل لاختيار مكان حفظه في DroidDeck. + المراجعة في واجهة الويب + المجلدات + لا توجد مجلدات بعد. + %1$s · %2$s + محدَّث + جارٍ المزامنة %1$d%% + جارٍ الفحص + متوقف مؤقتًا + غير متزامن + تعذّرت مزامنة ملفات: %1$d + محظور + يقع هذا المجلد خارج مساحة تخزين DroidDeck، لذلك أوقفه DroidDeck مؤقتًا. لمزامنة مجلدات خارج DroidDeck، استخدم تطبيق Syncthing-Fork. + إزالة من Syncthing + لا تؤدي إزالته إلى حذف أي ملفات. + الأجهزة + لا توجد أجهزة بعد. + متصل + غير متصل + آخر ظهور %1$s + معرّف هذا الجهاز + على جهازك الآخر، أضف جهازًا في Syncthing وامسح هذا الرمز أو الصق المعرّف. ثم اقبله هنا في واجهة الويب. + نسخ + مشاركة + تم + هل تريد إيقاف Syncthing؟ + سيتوقف Syncthing الآن ويظل متوقفًا إلى أن تعيد تشغيله. تبقى الملفات المتزامنة في مكانها. + حذف إعدادات Syncthing أيضًا + يزيل معرّف هذا الجهاز والمجلدات والأجهزة. عند تشغيله مجددًا يبدأ من جديد. + إيقاف + المنافذ + المزامنة + الاكتشاف المحلي + اختيار منافذ جديدة + يُعاد التحقق منها عند كل تشغيل. + إعادة الاختيار + إعادة التعيين + مفتاح API + إعادة الإنشاء + إعادة تعيين Syncthing + معرّف جهاز جديد، وتُزال المجلدات والأجهزة. يُحتفظ بالملفات الموجودة في مساحة تخزين DroidDeck. + إعادة التعيين + هل تريد إعادة تعيين Syncthing؟ + التشغيل + التشغيل + أثناء فتح DroidDeck + في الخلفية + عبر Wi-Fi فقط + إيقاف مؤقت أثناء الألعاب + السجلات + مخرجات Syncthing لهذه الجلسة + مشاركة + Syncthing + اضبط بيئة تشغيل Linux أولًا. + قبول + حفظ «%1$s» في + إضافة مجلد + مزامنة مجلد في مساحة تخزين DroidDeck + اختيار + إضافة + تعذّرت إضافة المجلد. + المشاركة مع + لا توجد أجهزة بعد. يمكنك مشاركة هذا المجلد من واجهة الويب لاحقًا. + استخدام هذا المجلد + مجلد جديد + المجلد الرئيسي للحاوية + مجلد Sync + مساحة تخزين DroidDeck المشتركة + DroidDeck على %1$s + بيانات تطبيق DroidDeck + تُحفظ المجلدات الجديدة في مجلد Sync الخاص بـ DroidDeck. لمزامنة مجلد آخر في DroidDeck، استخدم «إضافة مجلد» في علامة تبويب Syncthing. + Syncthing + إعادة التحميل + Syncthing لا يعمل. توقّف Steam بسبب تعليمة غير صالحة (SIGILL). قد تكون مشكلة توافق في تعليمات CPU أو تعطّلًا في العميل. شارك السجلات مع تفاصيل جهازك. يمكنك تجربة إعدادات Steam → عميل Steam → x86-64 (تجريبي). يتطلب تنزيلًا إضافيًا وقد يعمل بشكل أبطأ. تعذّر على برنامج تشغيل رسومات Linux العثور على جهاز Vulkan صالح للاستخدام. تحقّق من أن برنامج تشغيل GPU المحدد لـ Linux يدعم هذا الـ GPU. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 6e81ad38b..671337a5b 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -299,6 +299,8 @@ Control de Xbox 360 Deck: botón de Quick Access y giroscopio. Steam Controller: lo mismo, con detección de agarre en los botones traseros. DualShock 4 y DualSense: giroscopio, y los trackpads como su panel táctil. Xbox 360: abre Quick Access con Guide+A. Atrás + Mantener Atrás para atajos de Steam + Mantener Atrás abre Acceso rápido o crea combinaciones de Steam con otros botones. Desactívalo para ignorar Atrás mantenido; tocar y tocar dos veces siguen funcionando. Mantén Atrás 700 ms, o mantén Atrás y pulsa otro botón para los atajos de Steam. Intervalo de doble toque %1$d ms @@ -656,6 +658,8 @@ Barra Fijar posición Arrastra el HUD para moverlo; una pulsación larga lo bloquea o lo desbloquea. Mientras está bloqueado, los controles en pantalla que tiene debajo siguen funcionando. + Ajustar a los lados + Al soltar el HUD, se mueve al lado más cercano de la pantalla. Un gesto rápido lo lanza en esa dirección. Aspecto Tamaño en pantalla Suave @@ -1858,6 +1862,93 @@ Teclado + trackpad Terminal Agarres de Deck + trackpads + Panel + Todavía no hay nada que mostrar aquí. + Descargas + No hay descargas en curso. + Steam + Descargando caché de shaders + En pausa + Esperando para empezar + Notificaciones + DroidDeck – pantalla inferior + Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola. + Permitir acceso a notificaciones + No hay notificaciones. + %1$d notificaciones + Borrar + Capturas de pantalla + Los FPS requieren el HUD activado + Velocidad + Pedales + Fuerza G + Tiempos + Mejor vuelta + Llantas °C + Acelerador y freno, últimos 10 s + Velocidad, %1$s + Marcha + Unidades de velocidad + Automático (%1$s) + Cliente de Steam ARM + Cliente de Steam x86 + Acelerador + Freno + Luces del tablero + Cambio + Freno de mano + Limitador de pit + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Aceite %1$s °C + Distribuidor de energía + Combustible, t + Principal %1$s t + Reserva %1$s t + Toca la franja superior o el tablero para cambiar su estilo. + Enviar datos al panel + Automático + Lo activa en todos los juegos de esta lista, incluidos los que se encuentren más adelante. Los juegos que desactives seguirán desactivados. + Esto cambia el ajuste de salida de datos del propio juego. Se aplica la próxima vez que se inicie el juego; al desactivarlo se restaura el ajuste anterior. + Cierra %1$s para cambiar esto. + No se pudieron cambiar los ajustes de %1$s. + %1$s: activa %2$s en los ajustes del juego con la IP 127.0.0.1 y el puerto %3$d. + Se guardan en Pictures/DroidDeck. Las capturas de Steam también aparecen aquí. + Hacer captura de pantalla + Captura de pantalla guardada. + No se pudo hacer la captura de pantalla. + Todavía no hay capturas de pantalla. + Chat y voz + Lee y responde el chat de Steam aquí mientras el juego sigue en la pantalla principal. Usa la cuenta con la que se inició sesión en Steam. + Steam todavía no tiene una sesión web que compartir, así que inicia sesión aquí una vez. + Android cerró la página del chat mientras DroidDeck estaba en segundo plano. Se volverá a abrir cuando regreses a DroidDeck. + Abrir Steam Chat + Cerrar chat + Micro activado + Micro desactivado + El micro está desactivado en este juego. Para hablar, activa Micrófono en los ajustes de Steam de DroidDeck (Audio) y vuelve a iniciar el juego. + Volumen %1$d%% + Estadísticas del juego + FPS + CPU + GPU + Batería + Vuelta + Posición + Vuelta actual + Última vuelta + Combustible + Carga + Pips SYS · ENG · WEP + Estado legal + Saldo + War Thunder + Elite Dangerous + Activa la salida de datos de un juego de carreras (a menudo llamada telemetría o UDP) para ver aquí un panel en vivo. Los datos no salen de este dispositivo. + El puerto %1$d recibe datos (%2$d paquetes de %3$d bytes), pero en un formato que esta página aún no puede leer. + Otro programa ya usa el puerto %1$s, así que ese juego no se puede mostrar aquí. Eliminando el runtime de Linux Eliminando el runtime de Linux · %1$d elemento @@ -2170,6 +2261,7 @@ %1$s Cambia el escalado a %2$s. Igual a la pantalla (%1$d×%2$d) Seguir la pantalla (cambia de tamaño al plegar o desplegar) + Escalado %1$s Steam Mostrar detalles del dispositivo Ocultar detalles del dispositivo @@ -2617,7 +2709,7 @@ Hecho No se pudo guardar la disposición táctil Disposición táctil guardada en Steam - App %1$d + App %1$s Etiqueta (opcional) No se encontraron los iconos de Steam @@ -3252,6 +3344,113 @@ Las vistas guardadas se pueden recalcular. Inicie una nueva calibración para capturar en el sistema de coordenadas de seguimiento actual. La suma de comprobación del modelo de mano no coincide OpenCV no se pudo cargar + Syncthing + Syncthing + Sincronizar con Syncthing + Basada en Syncthing-Fork, es una implementación de Syncthing que te permite sincronizar archivos del sistema de archivos interno de DroidDeck con otras instancias de Syncthing. Solo sirve para los archivos que guardas en el sistema de archivos interno de DroidDeck. + Esta compilación no incluye Syncthing. + Iniciando Syncthing + Iniciando + Actualizado + Sincronizando + En pausa + Pausando + Reanudando + Requiere atención + Syncthing se detuvo + Reintentar + En pausa hasta tener Wi-Fi + En pausa durante el juego + %1$d carpetas · %2$d de %3$d dispositivos conectados + Faltan %1$s + Abrir la interfaz web + Pausar + Reanudar + En ejecución + Desactívalo para detener Syncthing por completo. + Este dispositivo + ID del dispositivo + Mostrar QR + Avanzado + Puertos, cuándo se ejecuta, restablecer + Abrir + Esperando tu respuesta + %1$s quiere conectarse + %1$s quiere compartir “%2$s” + Acepta para elegir dónde se guarda en DroidDeck. + Revisar en la interfaz web + Carpetas + Aún no hay carpetas. + %1$s · %2$s + Actualizada + Sincronizando %1$d%% + Escaneando + En pausa + No sincronizada + No se pudieron sincronizar %1$d archivos + Bloqueada + Esta carpeta está fuera del almacenamiento de DroidDeck, así que DroidDeck la puso en pausa. Para sincronizar carpetas fuera de DroidDeck, usa la app Syncthing-Fork. + Quitar de Syncthing + Quitarla no borra ningún archivo. + Dispositivos + Aún no hay dispositivos. + Conectado + Sin conexión + Visto por última vez %1$s + ID de este dispositivo + En tu otro dispositivo, agrega un dispositivo en Syncthing y escanea este código o pega el ID. Después, acéptalo aquí en la interfaz web. + Copiar + Compartir + Listo + ¿Desactivar Syncthing? + Syncthing se detiene ahora y seguirá detenido hasta que vuelvas a activarlo. Los archivos sincronizados se quedan donde están. + Borrar también la configuración de Syncthing + Quita el ID de este dispositivo, las carpetas y los dispositivos. Al volver a activarlo, empieza desde cero. + Desactivar + Puertos + Sincronización + Descubrimiento local + Elegir puertos nuevos + Se vuelve a revisar en cada inicio. + Volver a elegir + Restablecer + Clave API + Regenerar + Restablecer Syncthing + ID de dispositivo nuevo; se quitan las carpetas y los dispositivos. Los archivos del almacenamiento de DroidDeck se conservan. + Restablecer + ¿Restablecer Syncthing? + Ejecución + Ejecutar + Mientras DroidDeck está abierto + En segundo plano + Solo con Wi-Fi + Pausar durante los juegos + Registros + Salida de Syncthing de esta sesión + Compartir + Syncthing + Configura primero el runtime de Linux. + Aceptar + Guardar “%1$s” en + Agregar carpeta + Sincroniza una carpeta del almacenamiento de DroidDeck + Elegir + Agregar + No se pudo agregar la carpeta. + Compartir con + Aún no hay dispositivos. Puedes compartir esta carpeta más tarde desde la interfaz web. + Usar esta carpeta + Carpeta nueva + Carpeta personal del contenedor + Carpeta Sync + Almacenamiento compartido de DroidDeck + DroidDeck en %1$s + Datos de la app DroidDeck + Las carpetas nuevas se guardan en la carpeta Sync de DroidDeck. Para sincronizar otra carpeta de DroidDeck, usa Agregar carpeta en la pestaña Syncthing. + Syncthing + Recargar + Syncthing no se está ejecutando. Steam se detuvo por una instrucción ilegal (SIGILL). Puede ser un problema de compatibilidad con instrucciones de la CPU o un fallo del cliente. Comparte los registros con los datos de tu dispositivo. Puedes probar Ajustes de Steam → Cliente de Steam → x86-64 (experimental). Requiere una descarga adicional y puede ir más lento. El controlador gráfico de Linux no encontró un dispositivo Vulkan utilizable. Comprueba que el controlador de GPU de Linux seleccionado sea compatible con esta GPU. diff --git a/app/src/main/res/values-bg/gestures.xml b/app/src/main/res/values-bg/gestures.xml index 15898ef0e..60d56796f 100644 --- a/app/src/main/res/values-bg/gestures.xml +++ b/app/src/main/res/values-bg/gestures.xml @@ -71,7 +71,7 @@ Backspace Enter Tab - Space + Интервал Delete Insert Home diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index c4748a7b5..f0dc56681 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -299,6 +299,8 @@ Контролер Xbox 360 Deck: бутон Quick Access и гироскоп. Steam Controller: същото, с усещане за захват на задните бутони. DualShock 4 и DualSense: гироскоп, а тракпадовете служат като тъчпад. Xbox 360: отворете Quick Access с Guide+A. Back + Задържане на Назад за преки пътища в Steam + Задържането на Назад отваря Бърз достъп или прави комбинации в Steam с други бутони. Изключете, за да се пренебрегва задържането; докосване и двойно докосване още работят. Задръжте Back за 700 ms или задръжте Back и натиснете друг бутон за преки пътища на Steam. Интервал за двойно докосване %1$d ms @@ -654,6 +656,8 @@ Лента Заключване на място Плъзнете HUD, за да го преместите; продължително натискане го заключва или отключва. Докато е заключен, екранните контроли под него продължават да работят. + Залепване отстрани + Пуснете HUD и той отива към по-близката страна на екрана. Бързо плъзване го хвърля в тази посока. Визия Размер на екрана Мек @@ -1593,7 +1597,7 @@ Ctrl Super Alt - Space + Интервал AltGr Tab %1$s ✓ @@ -1772,6 +1776,93 @@ Клавиатура + тракпад Терминал Захвати + тракпади на Deck + Табло + Все още няма нищо за показване. + Изтегляния + Няма активни изтегляния. + Steam + Изтегляне на кеша на шейдърите + На пауза + Изчаква стартиране + Известия + Долен екран на DroidDeck + Показвайте тук съобщения и други известия, докато играете. Те остават само на този екран и не се запазват. Android пита веднъж: страницата за достъп до известията се отваря на този екран; включете DroidDeck там и тази страница ще се върне сама. + Разрешаване на достъпа до известията + Няма известия. + Известия: %1$d + Изчисти + Екранни снимки + За FPS трябва HUD да е включен + Скорост + Педали + G-сила + Времена + Най-добра обиколка + Гуми °C + Газ и спирачка, последните 10 s + Скорост, %1$s + Предавка + Единици за скорост + Автоматично (%1$s) + Steam клиент ARM + Steam клиент x86 + Газ + Спирачка + Табло + Смяна на предавка + Ръчна спирачка + Ограничител за бокса + TC + ABS + Турбо %1$s bar + Двигател %1$s °C + Масло %1$s °C + Разпределител на енергия + Гориво, t + Основно %1$s t + Резерв %1$s t + Докоснете горната лента или таблото, за да смените стила му. + Изпращане на данни за таблото + Автоматично + Включва го за всяка игра от списъка тук, включително игри, открити по-късно. Игрите, които изключите, остават изключени. + Това променя собствената настройка на играта за извеждане на данни. Влиза в сила при следващото стартиране на играта; изключването връща старата настройка. + Затворете %1$s, за да промените това. + Настройките на %1$s не можаха да бъдат променени. + %1$s: включете %2$s в настройките на играта с IP 127.0.0.1 и порт %3$d. + Запазват се в Pictures/DroidDeck. Екранните снимки от Steam също се показват тук. + Направи екранна снимка + Екранната снимка е запазена. + Не можа да се направи екранна снимка. + Все още няма екранни снимки. + Чат и глас + Четете и отговаряйте на чата в Steam тук, докато играта продължава на основния екран. Използва се акаунтът, с който сте влезли в Steam. + Steam все още няма уеб вход за споделяне, затова влезте тук веднъж. + Android затвори страницата на чата, докато DroidDeck беше на заден фон. Тя ще се отвори отново, когато се върнете в DroidDeck. + Отвори Steam Chat + Затвори чата + Микрофонът е включен + Микрофонът е изключен + Микрофонът е изключен за тази игра. За да говорите, включете „Микрофон“ в настройките на Steam в DroidDeck (Звук) и стартирайте играта отново. + Сила на звука %1$d%% + Статистика в играта + FPS + CPU + GPU + Батерия + Обиколка + Позиция + Тази обиколка + Последна обиколка + Гориво + Товар + Енергия SYS · ENG · WEP + Правен статус + Баланс + War Thunder + Elite Dangerous + Включете извеждането на данни в състезателна игра (често наричано телеметрия или UDP), за да виждате тук табло на живо. Данните остават на това устройство. + Порт %1$d получава данни (%2$d пакета по %3$d байта), но във формат, който тази страница все още не може да чете. + Друга програма вече използва порт %1$s, затова тази игра не може да се показва тук. Премахване на средата на Linux @@ -2102,6 +2193,7 @@ %1$s Задава мащабиране на %2$s. Напасване на екрана (%1$d×%2$d) Следване на екрана (преоразмерява при сгъване или разгъване) + Мащабирано %1$s Steam Покажи данните за устройството Скрий данните за устройството @@ -3243,6 +3335,113 @@ Запазените изгледи могат да бъдат преизчислени. Започнете ново калибриране за заснемане в текущата координатна система за проследяване. Несъответствие на контролната сума на модела на ръката OpenCV не можа да се зареди + Syncthing + Syncthing + Синхронизиране със Syncthing + Базирана на Syncthing-Fork, това е реализация на Syncthing, която ви позволява да синхронизирате файлове във вътрешната файлова система на DroidDeck с други екземпляри на Syncthing. Това важи само за файлове, които съхранявате във вътрешната файлова система на DroidDeck. + Тази компилация не включва Syncthing. + Стартиране на Syncthing + Стартиране + Актуално + Синхронизиране + На пауза + Поставяне на пауза + Възобновяване + Изисква внимание + Syncthing спря + Опитай отново + На пауза до връзка с Wi-Fi + На пауза по време на играта + Папки: %1$d · свързани устройства: %2$d от %3$d + Остават %1$s + Отвори уеб интерфейса + Пауза + Продължи + Работи + Изключете, за да спрете Syncthing напълно. + Това устройство + Идентификатор на устройството + Покажи QR + Разширени + Портове, кога да работи, нулиране + Отвори + Очаква ви + %1$s иска да се свърже + %1$s иска да сподели „%2$s“ + Приемете, за да изберете къде да се запази в DroidDeck. + Преглед в уеб интерфейса + Папки + Все още няма папки. + %1$s · %2$s + Актуално + Синхронизиране %1$d%% + Сканиране + На пауза + Несинхронизирано + Файлове, които не можаха да се синхронизират: %1$d + Блокирано + Тази папка е извън хранилището на DroidDeck, затова DroidDeck я постави на пауза. За да синхронизирате папки извън DroidDeck, използвайте приложението Syncthing-Fork. + Премахни от Syncthing + Премахването не изтрива файлове. + Устройства + Все още няма устройства. + Свързано + Офлайн + Последно видяно %1$s + Идентификатор на това устройство + На другото си устройство добавете устройство в Syncthing и сканирайте този код или поставете идентификатора. След това го приемете тук в уеб интерфейса. + Копирай + Сподели + Готово + Изключване на Syncthing? + Syncthing спира сега и остава спрян, докато не бъде включен отново. Синхронизираните файлове остават на мястото си. + Изтрий и конфигурацията на Syncthing + Премахва идентификатора на това устройство, папките и устройствата. При повторно включване започва отначало. + Изключи + Портове + Синхронизиране + Локално откриване + Избор на нови портове + Проверява се отново при всяко стартиране. + Избери отново + Нулиране + API ключ + Генерирай нов + Нулиране на Syncthing + Нов идентификатор на устройството, папките и устройствата се премахват. Файловете в хранилището на DroidDeck се запазват. + Нулирай + Нулиране на Syncthing? + Работа + Изпълнение + Докато DroidDeck е отворен + На заден план + Само през Wi-Fi + Пауза по време на игри + Журнали + Изход на Syncthing за тази сесия + Сподели + Syncthing + Първо настройте средата на Linux. + Приеми + Запазване на „%1$s“ в + Добави папка + Синхронизиране на папка в хранилището на DroidDeck + Избери + Добави + Папката не можа да бъде добавена. + Споделяне с + Все още няма устройства. Можете да споделите тази папка по-късно от уеб интерфейса. + Използвай тази папка + Нова папка + Домашна папка на контейнера + Папка Sync + Споделено хранилище на DroidDeck + DroidDeck на %1$s + Данни на приложението DroidDeck + Новите папки се запазват в папката Sync на DroidDeck. За да синхронизирате друга папка на DroidDeck, използвайте „Добави папка“ в раздела Syncthing. + Syncthing + Презареди + Syncthing не работи. Steam спря с невалидна инструкция (SIGILL). Това може да е проблем със съвместимостта на CPU инструкциите или срив на клиента. Споделете дневниците с данни за устройството си. Можете да опитате Настройки на Steam → Клиент на Steam → x86-64 (експериментално). Изисква допълнително изтегляне и може да работи по-бавно. Графичният драйвер на Linux не намери използваемо устройство Vulkan. Проверете дали избраният GPU драйвер за Linux поддържа този GPU. diff --git a/app/src/main/res/values-cs/gestures.xml b/app/src/main/res/values-cs/gestures.xml index 3b1b30f3a..6252bf73e 100644 --- a/app/src/main/res/values-cs/gestures.xml +++ b/app/src/main/res/values-cs/gestures.xml @@ -71,7 +71,7 @@ Backspace Enter Tab - Space + Mezerník Delete Insert Home diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e629e9070..273096d58 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -299,6 +299,8 @@ Ovladač Xbox 360 Deck: tlačítko Quick Access a gyroskop. Steam Controller: totéž, navíc snímání úchopu na zadních tlačítkách. DualShock 4 a DualSense: gyroskop, trackpad slouží jako touchpad. Xbox 360: Quick Access otevřete kombinací Guide+A. Zpět + Podržení Zpět pro zkratky Steam + Podržení Zpět otevře Rychlý přístup nebo vytváří kombinace Steam s dalšími tlačítky. Vypnutím se podržení Zpět ignoruje; klepnutí a dvojité klepnutí fungují dál. Podržte Zpět 700 ms, nebo podržte Zpět a stiskněte jiné tlačítko pro zkratky Steamu. Interval dvojitého klepnutí %1$d ms @@ -658,6 +660,8 @@ Lišta Zamknout na místě Přetažením HUD přesunete; dlouhým stiskem ho zamknete nebo odemknete. Když je zamknutý, ovládací prvky na obrazovce pod ním dál fungují. + Přichytit ke stranám + Pusťte HUD a přesune se k bližší straně obrazovky. Rychlým švihnutím ho hodíte tím směrem. Vzhled Velikost na obrazovce Jemná @@ -1625,7 +1629,7 @@ Ctrl Super Alt - Space + Mezerník AltGr Tab %1$s ✓ @@ -1816,6 +1820,93 @@ Klávesnice + trackpad Terminál Rukojeti + trackpady Deck + Přehled + Zatím tu není nic k zobrazení. + Stahování + Neprobíhá žádné stahování. + Steam + Stahování mezipaměti shaderů + Pozastaveno + Čeká na spuštění + Oznámení + Spodní obrazovka DroidDeck + Zobrazujte zde zprávy a další oznámení během hraní. Zůstávají jen na této obrazovce a neukládají se. Android se zeptá jednou: na této obrazovce se otevře jeho stránka přístupu k oznámením; zapněte tam DroidDeck a tato stránka se sama vrátí. + Povolit přístup k oznámením + Žádná oznámení. + Oznámení: %1$d + Vymazat + Snímky obrazovky + FPS vyžaduje zapnutý HUD + Rychlost + Pedály + Přetížení (G) + Časy + Nejlepší kolo + Pneumatiky °C + Plyn a brzda, posledních 10 s + Rychlost, %1$s + Rychlostní stupeň + Jednotky rychlosti + Automaticky (%1$s) + Klient Steam ARM + Klient Steam x86 + Plyn + Brzda + Kontrolky + Řazení + Ruční brzda + Omezovač v boxech + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Olej %1$s °C + Rozdělovač energie + Palivo, t + Hlavní %1$s t + Rezerva %1$s t + Klepnutím na horní pruh nebo palubní desku změníte jejich styl. + Odesílat data pro palubní desku + Automaticky + Zapne to pro každou zde uvedenou hru, včetně her nalezených později. Hry, které vypnete, zůstanou vypnuté. + Tím se mění vlastní nastavení výstupu dat ve hře. Projeví se při příštím spuštění hry; vypnutím se obnoví původní nastavení. + Pro změnu zavřete %1$s. + Nastavení hry %1$s se nepodařilo změnit. + %1$s: zapněte %2$s v nastavení hry s IP 127.0.0.1 a portem %3$d. + Ukládá se do Pictures/DroidDeck. Zobrazují se zde i snímky obrazovky ze Steamu. + Pořídit snímek obrazovky + Snímek obrazovky uložen. + Snímek obrazovky se nepodařilo pořídit. + Zatím žádné snímky obrazovky. + Chat a hlas + Čtěte chat Steamu a odpovídejte na něj zde, zatímco hra dál běží na hlavní obrazovce. Používá účet, kterým je Steam přihlášen. + Steam zatím nemá webové přihlášení ke sdílení, proto se zde jednou přihlaste. + Android zavřel stránku chatu, když byl DroidDeck na pozadí. Znovu se otevře, až se do DroidDecku vrátíte. + Otevřít Steam Chat + Zavřít chat + Mikrofon zapnut + Mikrofon vypnut + Mikrofon je pro tuto hru vypnutý. Chcete-li mluvit, zapněte Mikrofon v nastavení Steamu v DroidDecku (Zvuk) a spusťte hru znovu. + Hlasitost %1$d%% + Statistiky ve hře + FPS + CPU + GPU + Baterie + Kolo + Pozice + Aktuální kolo + Poslední kolo + Palivo + Náklad + Energie SYS · ENG · WEP + Právní stav + Zůstatek + War Thunder + Elite Dangerous + Zapněte v závodní hře výstup dat (často nazývaný telemetrie nebo UDP) a uvidíte zde živou palubní desku. Data zůstávají v tomto zařízení. + Port %1$d přijímá data (%2$d paketů po %3$d bajtech), ale ve formátu, který tato stránka zatím neumí přečíst. + Port %1$s již používá jiný program, takže se zde tato hra nemůže zobrazit. Odstraňování runtime Linuxu @@ -2148,6 +2239,7 @@ %1$s Nastaví škálování na %2$s. Podle obrazovky (%1$d×%2$d) Sledovat obrazovku (změní velikost při složení nebo rozložení) + Zmenšeno %1$s Steam Zobrazit podrobnosti o zařízení Skrýt podrobnosti o zařízení @@ -3315,6 +3407,113 @@ Uložená zobrazení lze přepočítat. Spusťte novou kalibraci pro zachycení v aktuálním sledovacím souřadnicovém systému. Neshoda kontrolního součtu modelu ruky OpenCV se nepodařilo načíst + Syncthing + Syncthing + Synchronizovat přes Syncthing + Jde o implementaci Syncthingu založenou na Syncthing-Fork, která umožňuje synchronizovat soubory v interním souborovém systému DroidDecku s dalšími instancemi Syncthingu. Týká se pouze souborů, které ukládáte v interním souborovém systému DroidDecku. + Toto sestavení neobsahuje Syncthing. + Spouští se Syncthing + Spouští se + Aktuální + Synchronizuje se + Pozastaveno + Pozastavuje se + Obnovuje se + Vyžaduje pozornost + Syncthing se zastavil + Zkusit znovu + Pozastaveno do připojení k Wi-Fi + Pozastaveno během hry + Složky: %1$d · připojená zařízení: %2$d z %3$d + Zbývá %1$s + Otevřít webové rozhraní + Pozastavit + Obnovit + Spuštěno + Vypnutím Syncthing úplně zastavíte. + Toto zařízení + ID zařízení + Zobrazit QR + Pokročilé + Porty, kdy spouštět, obnovení + Otevřít + Čeká na vás + %1$s se chce připojit + %1$s chce sdílet „%2$s“ + Po přijetí vyberete, kam se v DroidDecku uloží. + Zkontrolovat ve webovém rozhraní + Složky + Zatím žádné složky. + %1$s · %2$s + Aktuální + Synchronizace %1$d %% + Prohledává se + Pozastaveno + Nesynchronizováno + Nepodařilo se synchronizovat soubory: %1$d + Zablokováno + Tato složka je mimo úložiště DroidDecku, proto ji DroidDeck pozastavil. Ke synchronizaci složek mimo DroidDeck použijte aplikaci Syncthing-Fork. + Odebrat ze Syncthingu + Odebráním se žádné soubory nesmažou. + Zařízení + Zatím žádná zařízení. + Připojeno + Offline + Naposledy viděno %1$s + ID tohoto zařízení + Na druhém zařízení přidejte v Syncthingu zařízení a naskenujte tento kód nebo vložte ID. Potom ho zde přijměte ve webovém rozhraní. + Kopírovat + Sdílet + Hotovo + Vypnout Syncthing? + Syncthing se nyní zastaví a zůstane zastavený, dokud ho znovu nezapnete. Synchronizované soubory zůstanou, kde jsou. + Smazat také konfiguraci Syncthingu + Odebere ID tohoto zařízení, složky a zařízení. Po opětovném zapnutí začne od začátku. + Vypnout + Porty + Synchronizace + Místní vyhledávání + Vybrat nové porty + Kontroluje se znovu při každém spuštění. + Vybrat znovu + Obnovení + Klíč API + Vygenerovat znovu + Obnovit Syncthing + Nové ID zařízení, složky a zařízení budou odebrány. Soubory v úložišti DroidDecku zůstanou zachovány. + Obnovit + Obnovit Syncthing? + Spouštění + Spouštět + Když je DroidDeck otevřený + Na pozadí + Pouze Wi-Fi + Pozastavit během her + Protokoly + Výstup Syncthingu pro tuto relaci + Sdílet + Syncthing + Nejprve nastavte běhové prostředí Linuxu. + Přijmout + Uložit „%1$s“ do + Přidat složku + Synchronizovat složku v úložišti DroidDecku + Vybrat + Přidat + Složku se nepodařilo přidat. + Sdílet se + Zatím žádná zařízení. Tuto složku můžete sdílet později z webového rozhraní. + Použít tuto složku + Nová složka + Domovská složka kontejneru + Složka Sync + Sdílené úložiště DroidDecku + DroidDeck na %1$s + Data aplikace DroidDeck + Nové složky se ukládají do složky Sync v DroidDecku. Chcete-li synchronizovat jinou složku DroidDecku, použijte Přidat složku na kartě Syncthing. + Syncthing + Načíst znovu + Syncthing neběží. Steam se zastavil na neplatné instrukci (SIGILL). Může jít o problém s kompatibilitou instrukcí CPU nebo o pád klienta. Sdílejte protokoly s údaji o zařízení. Můžete zkusit Nastavení Steamu → Klient Steam → x86-64 (experimentální). Vyžaduje další stahování a může běžet pomaleji. Grafický ovladač Linuxu nenašel použitelné zařízení Vulkan. Zkontrolujte, zda vybraný ovladač GPU pro Linux toto GPU podporuje. diff --git a/app/src/main/res/values-da/gestures.xml b/app/src/main/res/values-da/gestures.xml index 1fd863e0d..022f14b97 100644 --- a/app/src/main/res/values-da/gestures.xml +++ b/app/src/main/res/values-da/gestures.xml @@ -71,7 +71,7 @@ Tilbage Enter Tab - Space + Mellemrum Delete Insert Home diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 608d9be2d..a0a441f16 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -299,6 +299,8 @@ Xbox 360-controller Deck: Hurtig adgang-knap og gyro. Steam Controller: det samme, med gribefølsomme bagknapper. DualShock 4 og DualSense: gyro, og touchpaddene som deres touchpad. Xbox 360: åbn Hurtig adgang med Guide+A. Tilbage + Hold Tilbage for Steam-genveje + Hold Tilbage for at åbne Hurtig adgang eller lave Steam-kombinationer med andre knapper. Slå fra for at ignorere holdt Tilbage; tryk og dobbelttryk virker stadig. Hold Tilbage i 700 ms, eller hold Tilbage og tryk på en anden knap for Steam-genveje. Tidsrum for dobbelttryk %1$d ms @@ -654,6 +656,8 @@ Bjælke Lås fast Træk i HUD for at flytte det; et langt tryk låser eller frigør det. Mens det er låst, virker skærmknapperne under det stadig. + Fastgør til siderne + Slip HUD\'en, så flytter den sig til skærmens nærmeste side. Et hurtigt stryg kaster den i den retning. Udseende Størrelse på skærmen Blød @@ -1593,7 +1597,7 @@ Ctrl Super Alt - Space + Mellemrum AltGr Tab %1$s ✓ @@ -1772,6 +1776,93 @@ Tastatur + trackpad Terminal Deck-greb + trackpads + Dashboard + Intet at vise her endnu. + Downloads + Ingen downloads i gang. + Steam + Downloader shader-cache + På pause + Venter på at starte + Notifikationer + DroidDecks nederste skærm + Vis beskeder og andre notifikationer her, mens du spiller. De bliver kun på denne skærm og gemmes ikke. Android spørger én gang: siden for notifikationsadgang åbnes på denne skærm; slå DroidDeck til der, så vender denne side selv tilbage. + Tillad notifikationsadgang + Ingen notifikationer. + %1$d notifikationer + Ryd + Skærmbilleder + FPS kræver, at HUD er slået til + Hastighed + Pedaler + G-kraft + Tider + Bedste omgang + Dæk °C + Speeder og bremse, seneste 10 s + Hastighed, %1$s + Gear + Hastighedsenheder + Auto (%1$s) + Steam-klient ARM + Steam-klient x86 + Speeder + Bremse + Kontrollamper + Gearskift + Håndbremse + Pitbegrænser + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Olie %1$s °C + Energifordeler + Brændstof, t + Hoved %1$s t + Reserve %1$s t + Tryk på den øverste bjælke eller instrumentbrættet for at skifte stil. + Send instrumentbrætdata + Auto + Slår det til for alle spil på listen her, også spil, der findes senere. Spil, du slår fra, forbliver slået fra. + Dette ændrer spillets egen indstilling for dataoutput. Det træder i kraft, næste gang spillet starter; slås det fra, gendannes den gamle indstilling. + Luk %1$s for at ændre dette. + Kunne ikke ændre indstillingerne for %1$s. + %1$s: slå %2$s til i spillets indstillinger med IP 127.0.0.1 og port %3$d. + Gemmes i Pictures/DroidDeck. Steam-skærmbilleder vises også her. + Tag skærmbillede + Skærmbillede gemt. + Kunne ikke tage et skærmbillede. + Ingen skærmbilleder endnu. + Chat og stemme + Læs og besvar Steam-chat her, mens spillet fortsætter på hovedskærmen. Den bruger den konto, Steam er logget ind med. + Steam har endnu ikke et weblogin at dele, så log ind her én gang. + Android lukkede chatsiden, mens DroidDeck var i baggrunden. Den åbner igen, når du vender tilbage til DroidDeck. + Åbn Steam Chat + Luk chat + Mikrofon til + Mikrofon fra + Mikrofonen er slået fra for dette spil. For at tale skal du slå Mikrofon til i DroidDecks Steam-indstillinger (Lyd) og starte spillet igen. + Lydstyrke %1$d%% + Statistik i spillet + FPS + CPU + GPU + Batteri + Omgang + Placering + Denne omgang + Sidste omgang + Brændstof + Last + Energi SYS · ENG · WEP + Juridisk status + Saldo + War Thunder + Elite Dangerous + Slå dataoutput til i et racerspil (ofte kaldet telemetri eller UDP) for at se et live-instrumentbræt her. Det forbliver på denne enhed. + Port %1$d modtager data (%2$d pakker á %3$d bytes), men i et format, som denne side endnu ikke kan læse. + Et andet program bruger allerede port %1$s, så det spil kan ikke vises her. Fjerner Linux-runtime @@ -2102,6 +2193,7 @@ %1$s Indstiller skalering til %2$s. Match skærm (%1$d×%2$d) Følg skærm (ændrer størrelse ved fold/udfold) + Skaleret %1$s Steam Vis enhedsoplysninger Skjul enhedsoplysninger @@ -2630,7 +2722,7 @@ Vis kun spillet på brillen. Fra: telefonen viser det, og Android spejler telefonen. Skærm Opdateringshastighed - 3D (side-by-side) + 3D (side om side) Skift smartbrillerne til side-om-side 3D. Hovedsporing styres separat. 3D-signalet blev ikke bekræftet. Prøv igen, når 2D-billedet er tilbage. Intet billede: brillen er i en tilstand, som telefonen ikke kan sende. Hold dens 2D/3D-knap inde, eller tag stikket ud og sæt det i igen. @@ -3243,6 +3335,113 @@ Gemte visninger kan genberegnes. Start en ny kalibrering for at fange i det aktuelle sporingskoordinatsystem. Håndmodel kontrolsum uoverensstemmelse OpenCV kunne ikke indlæse + Syncthing + Syncthing + Synkroniser med Syncthing + Baseret på Syncthing-Fork er dette en Syncthing-implementering, der lader dig synkronisere filer i DroidDecks interne filsystem med andre Syncthing-instanser. Det gælder kun filer, du gemmer i DroidDecks interne filsystem. + Denne build indeholder ikke Syncthing. + Starter Syncthing + Starter + Opdateret + Synkroniserer + Sat på pause + Sætter på pause + Genoptager + Kræver opmærksomhed + Syncthing er stoppet + Prøv igen + På pause indtil Wi-Fi + På pause under spillet + %1$d mapper · %2$d af %3$d enheder forbundet + %1$s tilbage + Åbn webgrænsefladen + Sæt på pause + Genoptag + Kører + Slå fra for at stoppe Syncthing helt. + Denne enhed + Enheds-ID + Vis QR + Avanceret + Porte, hvornår der køres, nulstilling + Åbn + Venter på dig + %1$s vil gerne forbinde + %1$s vil gerne dele »%2$s« + Accepter for at vælge, hvor den gemmes i DroidDeck. + Gennemse i webgrænsefladen + Mapper + Ingen mapper endnu. + %1$s · %2$s + Opdateret + Synkroniserer %1$d%% + Scanner + Sat på pause + Ikke synkroniseret + %1$d filer kunne ikke synkroniseres + Blokeret + Denne mappe ligger uden for DroidDecks lager, så DroidDeck har sat den på pause. Brug appen Syncthing-Fork til at synkronisere mapper uden for DroidDeck. + Fjern fra Syncthing + Fjernelse sletter ingen filer. + Enheder + Ingen enheder endnu. + Forbundet + Offline + Sidst set %1$s + Denne enheds ID + Tilføj en enhed i Syncthing på din anden enhed, og scan denne kode, eller indsæt ID\'et. Accepter den derefter her i webgrænsefladen. + Kopiér + Del + Færdig + Slå Syncthing fra? + Syncthing stopper nu og forbliver stoppet, indtil det slås til igen. Synkroniserede filer bliver, hvor de er. + Slet også Syncthings konfiguration + Fjerner denne enheds ID, mapper og enheder. Når det slås til igen, starter det forfra. + Slå fra + Porte + Synkronisering + Lokal opdagelse + Vælg nye porte + Kontrolleres igen ved hver start. + Vælg igen + Nulstil + API-nøgle + Generér ny + Nulstil Syncthing + Nyt enheds-ID, mapper og enheder fjernes. Filer i DroidDecks lager bevares. + Nulstil + Nulstil Syncthing? + Kørsel + Kør + Mens DroidDeck er åben + I baggrunden + Kun Wi-Fi + Sæt på pause under spil + Logfiler + Syncthing-output for denne session + Del + Syncthing + Konfigurer Linux-runtimen først. + Accepter + Gem »%1$s« i + Tilføj mappe + Synkroniser en mappe i DroidDecks lager + Vælg + Tilføj + Mappen kunne ikke tilføjes. + Del med + Ingen enheder endnu. Du kan dele mappen fra webgrænsefladen senere. + Brug denne mappe + Ny mappe + Containerens hjemmemappe + Sync-mappe + DroidDecks delte lager + DroidDeck på %1$s + DroidDecks appdata + Nye mapper gemmes i DroidDecks Sync-mappe. Brug Tilføj mappe på fanen Syncthing for at synkronisere en anden DroidDeck-mappe. + Syncthing + Genindlæs + Syncthing kører ikke. Steam stoppede med en ulovlig instruktion (SIGILL). Det kan være et kompatibilitetsproblem med CPU-instruktioner eller et klientnedbrud. Del logs med oplysninger om din enhed. Du kan prøve Steam-indstillinger → Steam-klient → x86-64 (eksperimentel). Det kræver en ekstra download og kan køre langsommere. Linux-grafikdriveren kunne ikke finde en brugbar Vulkan-enhed. Tjek, at den valgte Linux-GPU-driver understøtter denne GPU. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c47c919c4..a6c5d7c22 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -299,6 +299,8 @@ Xbox-360-Controller Deck: Schnellzugriffstaste und Gyro. Steam Controller: dasselbe, mit Griffsensor an den Rücktasten. DualShock 4 und DualSense: Gyro, die Trackpads dienen als deren Touchpad. Xbox 360: Schnellzugriff mit Guide+A öffnen. Zurück + Zurück halten für Steam-Kürzel + Zurück halten öffnet den Schnellzugriff oder bildet mit anderen Tasten Steam-Kombinationen. Ausschalten, um gehaltenes Zurück zu ignorieren; Tippen und Doppeltippen funktionieren weiter. Halte Zurück 700 ms lang gedrückt oder halte Zurück und drücke eine weitere Taste für Steam-Tastenkürzel. Zeitfenster für Doppeltippen %1$d ms @@ -654,6 +656,8 @@ Leiste Position sperren Ziehe das HUD, um es zu verschieben; langes Drücken sperrt oder entsperrt es. Solange es gesperrt ist, funktionieren die Bildschirmsteuerungen darunter weiter. + An Seiten einrasten + Lass das HUD los, und es springt an die nähere Bildschirmseite. Ein Wischen wirft es in diese Richtung. Aussehen Größe auf dem Bildschirm Dezent @@ -1764,6 +1768,93 @@ Tastatur + Trackpad Terminal Deck-Griffe + Trackpads + Dashboard + Hier gibt es noch nichts anzuzeigen. + Downloads + Keine laufenden Downloads. + Steam + Shader-Cache wird heruntergeladen + Pausiert + Wartet auf Start + Benachrichtigungen + DroidDeck – unterer Bildschirm + Zeigt Nachrichten und andere Benachrichtigungen hier an, während du spielst. Sie bleiben nur auf diesem Bildschirm und werden nicht gespeichert. Android fragt einmal: Die Seite für den Benachrichtigungszugriff öffnet sich auf diesem Bildschirm; aktiviere dort DroidDeck, dann kehrt diese Seite von selbst zurück. + Benachrichtigungszugriff erlauben + Keine Benachrichtigungen. + %1$d Benachrichtigungen + Löschen + Screenshots + FPS benötigt das aktive HUD + Geschwindigkeit + Pedale + G-Kraft + Zeiten + Beste Runde + Reifen °C + Gas und Bremse, letzte 10 s + Geschwindigkeit, %1$s + Gang + Geschwindigkeitseinheit + Automatisch (%1$s) + Steam-Client ARM + Steam-Client x86 + Gas + Bremse + Kontrollleuchten + Schalten + Handbremse + Pit-Limiter + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Öl %1$s °C + Energieverteiler + Treibstoff, t + Haupttank %1$s t + Reserve %1$s t + Tippe auf die obere Leiste oder die Instrumentenanzeige, um ihren Stil zu ändern. + Dashboard-Daten senden + Automatisch + Schaltet es für jedes hier aufgeführte Spiel ein, auch für später gefundene Spiele. Spiele, die du ausschaltest, bleiben aus. + Dies ändert die eigene Datenausgabe-Einstellung des Spiels. Es wirkt beim nächsten Start des Spiels; beim Ausschalten wird die alte Einstellung wiederhergestellt. + Schließe %1$s, um dies zu ändern. + Die Einstellungen von %1$s konnten nicht geändert werden. + %1$s: Aktiviere %2$s in den Spieleinstellungen mit IP 127.0.0.1 und Port %3$d. + Gespeichert in Pictures/DroidDeck. Steam-Screenshots werden hier ebenfalls angezeigt. + Screenshot aufnehmen + Screenshot gespeichert. + Screenshot konnte nicht aufgenommen werden. + Noch keine Screenshots. + Chat & Sprache + Lies und beantworte hier den Steam-Chat, während das Spiel auf dem Hauptbildschirm weiterläuft. Es wird das Konto verwendet, mit dem Steam angemeldet ist. + Steam hat noch keine Web-Anmeldung zum Teilen, melde dich also einmal hier an. + Android hat die Chat-Seite geschlossen, während DroidDeck im Hintergrund war. Sie öffnet sich wieder, wenn du zu DroidDeck zurückkehrst. + Steam Chat öffnen + Chat schließen + Mikro an + Mikro aus + Das Mikro ist für dieses Spiel aus. Um zu sprechen, aktiviere „Mikrofon“ in den Steam-Einstellungen von DroidDeck (Audio) und starte das Spiel neu. + Lautstärke %1$d%% + Spielstatistiken + FPS + CPU + GPU + Akku + Runde + Position + Aktuelle Runde + Letzte Runde + Treibstoff + Fracht + Pips SYS · ENG · WEP + Rechtsstatus + Guthaben + War Thunder + Elite Dangerous + Aktiviere die Datenausgabe eines Rennspiels (oft Telemetrie oder UDP genannt), um hier ein Live-Cockpit zu sehen. Die Daten bleiben auf diesem Gerät. + Port %1$d empfängt Daten (%2$d Pakete mit %3$d Bytes), aber in einem Format, das diese Seite noch nicht lesen kann. + Ein anderes Programm verwendet bereits Port %1$s, daher kann dieses Spiel hier nicht angezeigt werden. Linux-Laufzeit wird entfernt @@ -2094,6 +2185,7 @@ %1$s Stellt die Skalierung auf %2$s. An Bildschirm anpassen (%1$d×%2$d) Bildschirm folgen (passt sich beim Auf- und Zuklappen an) + Skaliert %1$s Steam Gerätedetails anzeigen Gerätedetails ausblenden @@ -3243,6 +3335,113 @@ Gespeicherte Ansichten können neu berechnet werden. Starten Sie eine neue Kalibrierung zur Erfassung im aktuellen Tracking-Koordinatensystem. Die Prüfsumme des Handmodells stimmt nicht überein OpenCV konnte nicht geladen werden + Syncthing + Syncthing + Mit Syncthing synchronisieren + Basiert auf Syncthing-Fork: eine Syncthing-Implementierung, mit der du Dateien im internen Dateisystem von DroidDeck mit anderen Syncthing-Instanzen synchronisieren kannst. Gilt nur für Dateien, die du im internen Dateisystem von DroidDeck speicherst. + Dieser Build enthält kein Syncthing. + Syncthing wird gestartet + Wird gestartet + Aktuell + Synchronisiert + Pausiert + Wird pausiert + Wird fortgesetzt + Benötigt Aufmerksamkeit + Syncthing gestoppt + Erneut versuchen + Pausiert, bis Wi-Fi verfügbar ist + Während des Spiels pausiert + %1$d Ordner · %2$d von %3$d Geräten verbunden + Noch %1$s + Web-GUI öffnen + Pausieren + Fortsetzen + Aktiv + Ausschalten, um Syncthing vollständig zu beenden. + Dieses Gerät + Geräte-ID + QR-Code anzeigen + Erweitert + Ports, Ausführung, Zurücksetzen + Öffnen + Wartet auf dich + %1$s möchte sich verbinden + %1$s möchte „%2$s“ teilen + Annehmen, um zu wählen, wo es in DroidDeck gespeichert wird. + In der Web-GUI prüfen + Ordner + Noch keine Ordner. + %1$s · %2$s + Aktuell + Synchronisiere %1$d %% + Scanne + Pausiert + Nicht synchron + %1$d Dateien konnten nicht synchronisiert werden + Blockiert + Dieser Ordner liegt außerhalb des DroidDeck-Speichers, daher hat DroidDeck ihn pausiert. Um Ordner außerhalb von DroidDeck zu synchronisieren, nutze die App Syncthing-Fork. + Aus Syncthing entfernen + Beim Entfernen werden keine Dateien gelöscht. + Geräte + Noch keine Geräte. + Verbunden + Offline + Zuletzt gesehen %1$s + ID dieses Geräts + Füge auf deinem anderen Gerät in Syncthing ein Gerät hinzu und scanne diesen Code oder füge die ID ein. Nimm es dann hier in der Web-GUI an. + Kopieren + Teilen + Fertig + Syncthing ausschalten? + Syncthing wird jetzt beendet und bleibt aus, bis du es wieder einschaltest. Synchronisierte Dateien bleiben, wo sie sind. + Auch die Syncthing-Konfiguration löschen + Entfernt die ID dieses Geräts, Ordner und Geräte. Beim erneuten Einschalten beginnt alles von vorn. + Ausschalten + Ports + Synchronisierung + Lokale Gerätesuche + Neue Ports wählen + Wird bei jedem Start erneut geprüft. + Neu wählen + Zurücksetzen + API-Schlüssel + Neu generieren + Syncthing zurücksetzen + Neue Geräte-ID, Ordner und Geräte werden entfernt. Dateien im DroidDeck-Speicher bleiben erhalten. + Zurücksetzen + Syncthing zurücksetzen? + Ausführung + Ausführen + Während DroidDeck geöffnet ist + Im Hintergrund + Nur über Wi-Fi + Während Spielen pausieren + Logs + Syncthing-Ausgabe dieser Sitzung + Teilen + Syncthing + Richte zuerst die Linux-Laufzeit ein. + Annehmen + „%1$s“ speichern in + Ordner hinzufügen + Einen Ordner im DroidDeck-Speicher synchronisieren + Auswählen + Hinzufügen + Der Ordner konnte nicht hinzugefügt werden. + Teilen mit + Noch keine Geräte. Du kannst diesen Ordner später über die Web-GUI teilen. + Diesen Ordner verwenden + Neuer Ordner + Home-Verzeichnis des Containers + Sync-Ordner + Gemeinsamer DroidDeck-Speicher + DroidDeck auf %1$s + DroidDeck-App-Daten + Neue Ordner werden im Sync-Ordner von DroidDeck gespeichert. Um einen anderen DroidDeck-Ordner zu synchronisieren, nutze „Ordner hinzufügen“ im Tab „Syncthing“. + Syncthing + Neu laden + Syncthing läuft nicht. Steam wurde mit einer ungültigen Anweisung (SIGILL) beendet. Das kann ein Kompatibilitätsproblem mit CPU-Befehlen oder ein Absturz des Clients sein. Teile die Logs zusammen mit den Details deines Geräts. Du kannst Steam-Einstellungen → Steam-Client → x86-64 (experimentell) ausprobieren. Das erfordert einen zusätzlichen Download und kann langsamer laufen. Der Linux-Grafiktreiber hat kein nutzbares Vulkan-Gerät gefunden. Prüfe, ob der ausgewählte Linux-GPU-Treiber diese GPU unterstützt. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 31fe78592..b59ef694a 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -299,6 +299,8 @@ Χειριστήριο Xbox 360 Deck: κουμπί Quick Access και γυροσκόπιο. Steam Controller: το ίδιο, με αίσθηση λαβής στα πίσω κουμπιά. DualShock 4 και DualSense: γυροσκόπιο, και τα trackpad ως πινακίδα αφής τους. Xbox 360: ανοίξτε το Quick Access με Guide+A. Πίσω + Παρατεταμένο Πίσω για συντομεύσεις Steam + Το παρατεταμένο Πίσω ανοίγει τη Γρήγορη πρόσβαση ή κάνει συνδυασμούς Steam με άλλα κουμπιά. Απενεργοποιήστε για να αγνοείται· το πάτημα και το διπλό πάτημα λειτουργούν. Κρατήστε πατημένο το Πίσω για 700 ms ή κρατήστε το Πίσω και πατήστε άλλο κουμπί για συντομεύσεις Steam. Διάστημα διπλού πατήματος %1$d ms @@ -654,6 +656,8 @@ Λωρίδα Κλείδωμα θέσης Σύρετε το HUD για να το μετακινήσετε· ένα παρατεταμένο πάτημα το κλειδώνει ή το ξεκλειδώνει. Όσο είναι κλειδωμένο, τα χειριστήρια οθόνης από κάτω του εξακολουθούν να λειτουργούν. + Προσκόλληση στα πλάγια + Αφήστε το HUD και μετακινείται στην πιο κοντινή πλευρά της οθόνης. Μια γρήγορη κίνηση το πετάει προς εκείνη την κατεύθυνση. Εμφάνιση Μέγεθος στην οθόνη Απαλό @@ -1772,6 +1776,93 @@ Πληκτρολόγιο + πινακίδα αφής Τερματικό Λαβές Deck + πινακίδες αφής + Πίνακας ελέγχου + Δεν υπάρχει τίποτα για εμφάνιση ακόμα. + Λήψεις + Δεν εκτελούνται λήψεις. + Steam + Λήψη cache shader + Σε παύση + Αναμονή για έναρξη + Ειδοποιήσεις + DroidDeck – κάτω οθόνη + Εμφάνιση μηνυμάτων και άλλων ειδοποιήσεων εδώ ενώ παίζετε. Παραμένουν μόνο σε αυτή την οθόνη και δεν αποθηκεύονται. Το Android ρωτά μία φορά: η σελίδα πρόσβασης ειδοποιήσεων ανοίγει σε αυτή την οθόνη· ενεργοποιήστε εκεί το DroidDeck και αυτή η σελίδα επιστρέφει μόνη της. + Αποδοχή πρόσβασης ειδοποιήσεων + Δεν υπάρχουν ειδοποιήσεις. + %1$d ειδοποιήσεις + Απαλοιφή + Στιγμιότυπα οθόνης + Τα FPS απαιτούν ενεργό HUD + Ταχύτητα + Πεντάλ + Δύναμη G + Χρονομέτρηση + Καλύτερος γύρος + Ελαστικά °C + Γκάζι και φρένο, τελευταία 10 s + Ταχύτητα, %1$s + Σχέση + Μονάδες ταχύτητας + Αυτόματα (%1$s) + Πελάτης Steam ARM + Πελάτης Steam x86 + Γκάζι + Φρένο + Ενδεικτικές λυχνίες + Αλλαγή + Χειρόφρενο + Περιοριστής pit + TC + ABS + Turbo %1$s bar + Κινητήρας %1$s °C + Λάδι %1$s °C + Διανομέας ισχύος + Καύσιμο, t + Κύριο %1$s t + Εφεδρικό %1$s t + Πατήστε τη λωρίδα στο πάνω μέρος ή τον πίνακα οργάνων για να αλλάξετε το στυλ του. + Αποστολή δεδομένων πίνακα + Αυτόματα + Το ενεργοποιεί για κάθε παιχνίδι της λίστας, μαζί και όσα βρεθούν αργότερα. Τα παιχνίδια που απενεργοποιείτε παραμένουν ανενεργά. + Αυτό αλλάζει τη ρύθμιση εξόδου δεδομένων του ίδιου του παιχνιδιού. Εφαρμόζεται την επόμενη φορά που ξεκινά το παιχνίδι· η απενεργοποίηση επαναφέρει την προηγούμενη ρύθμιση. + Κλείστε το %1$s για να το αλλάξετε. + Δεν ήταν δυνατή η αλλαγή των ρυθμίσεων του %1$s. + %1$s: ενεργοποιήστε το %2$s στις ρυθμίσεις του παιχνιδιού με IP 127.0.0.1 και θύρα %3$d. + Αποθηκεύονται στο Pictures/DroidDeck. Εδώ εμφανίζονται και τα στιγμιότυπα του Steam. + Λήψη στιγμιότυπου + Το στιγμιότυπο αποθηκεύτηκε. + Δεν ήταν δυνατή η λήψη στιγμιότυπου. + Δεν υπάρχουν στιγμιότυπα ακόμα. + Συνομιλία & φωνή + Διαβάστε και απαντήστε στη συνομιλία του Steam εδώ ενώ το παιχνίδι συνεχίζει στην κύρια οθόνη. Χρησιμοποιεί τον λογαριασμό με τον οποίο είναι συνδεδεμένο το Steam. + Το Steam δεν έχει ακόμα σύνδεση web για κοινή χρήση, οπότε συνδεθείτε εδώ μία φορά. + Το Android έκλεισε τη σελίδα συνομιλίας ενώ το DroidDeck ήταν στο παρασκήνιο. Ανοίγει ξανά όταν επιστρέψετε στο DroidDeck. + Άνοιγμα Steam Chat + Κλείσιμο συνομιλίας + Μικρόφωνο ανοιχτό + Μικρόφωνο κλειστό + Το μικρόφωνο είναι απενεργοποιημένο για αυτό το παιχνίδι. Για να μιλήσετε, ενεργοποιήστε το Μικρόφωνο στις ρυθμίσεις Steam του DroidDeck (Ήχος) και ξεκινήστε ξανά το παιχνίδι. + Ένταση %1$d%% + Στατιστικά παιχνιδιού + FPS + CPU + GPU + Μπαταρία + Γύρος + Θέση + Τρέχων γύρος + Προηγούμενος γύρος + Καύσιμο + Φορτίο + Pips SYS · ENG · WEP + Νομική κατάσταση + Υπόλοιπο + War Thunder + Elite Dangerous + Ενεργοποιήστε την έξοδο δεδομένων ενός αγωνιστικού παιχνιδιού (συχνά λέγεται τηλεμετρία ή UDP) για να δείτε εδώ ζωντανό πίνακα οργάνων. Τα δεδομένα μένουν σε αυτή τη συσκευή. + Η θύρα %1$d λαμβάνει δεδομένα (%2$d πακέτα των %3$d byte), αλλά σε μορφή που αυτή η σελίδα δεν μπορεί ακόμα να διαβάσει. + Ένα άλλο πρόγραμμα χρησιμοποιεί ήδη τη θύρα %1$s, οπότε αυτό το παιχνίδι δεν μπορεί να εμφανιστεί εδώ. Κατάργηση του Linux runtime @@ -2102,6 +2193,7 @@ %1$s Ορίζει την κλιμάκωση σε %2$s. Αντιστοίχιση οθόνης (%1$d×%2$d) Ακολούθηση οθόνης (αλλάζει μέγεθος όταν διπλώνει ή ξεδιπλώνει) + Κλιμάκωση %1$s Steam Εμφάνιση στοιχείων συσκευής Απόκρυψη στοιχείων συσκευής @@ -2191,7 +2283,7 @@ Σε προσφορά Φόρτωση περισσότερων Οποιαδήποτε πλατφόρμα - Mobile web + Web για κινητά Προσθήκη ετικέτας Φιλτράρισμα ανά ετικέτα Δεν ήταν δυνατή η φόρτωση παιχνιδιών από το itch.io @@ -3243,6 +3335,113 @@ Οι αποθηκευμένες προβολές μπορούν να υπολογιστούν εκ νέου. Ξεκινήστε μια νέα βαθμονόμηση για αποτύπωση στο τρέχον σύστημα συντεταγμένων παρακολούθησης. Αναντιστοιχία αθροίσματος ελέγχου μοντέλου χεριού Δεν ήταν δυνατή η φόρτωση του OpenCV + Syncthing + Syncthing + Συγχρονισμός με Syncthing + Βασίζεται στο Syncthing-Fork: μια υλοποίηση του Syncthing που σας επιτρέπει να συγχρονίζετε αρχεία του εσωτερικού συστήματος αρχείων του DroidDeck με άλλες εγκαταστάσεις Syncthing. Αφορά μόνο αρχεία που αποθηκεύετε στο εσωτερικό σύστημα αρχείων του DroidDeck. + Αυτή η έκδοση δεν περιλαμβάνει το Syncthing. + Εκκίνηση του Syncthing + Εκκίνηση + Ενημερωμένο + Συγχρονισμός + Σε παύση + Γίνεται παύση + Γίνεται συνέχιση + Χρειάζεται προσοχή + Το Syncthing σταμάτησε + Δοκιμάστε ξανά + Σε παύση έως ότου υπάρξει Wi-Fi + Σε παύση κατά τη διάρκεια του παιχνιδιού + %1$d φάκελοι · %2$d από %3$d συσκευές συνδεδεμένες + Απομένουν %1$s + Άνοιγμα διεπαφής web + Παύση + Συνέχιση + Σε λειτουργία + Απενεργοποιήστε το για να σταματήσει εντελώς το Syncthing. + Αυτή η συσκευή + Ταυτότητα συσκευής + Εμφάνιση QR + Για προχωρημένους + Θύρες, πότε εκτελείται, επαναφορά + Άνοιγμα + Σας περιμένει + Η συσκευή %1$s θέλει να συνδεθεί + Η συσκευή %1$s θέλει να μοιραστεί τον φάκελο «%2$s» + Αποδεχτείτε για να επιλέξετε πού θα αποθηκευτεί στο DroidDeck. + Έλεγχος στη διεπαφή web + Φάκελοι + Δεν υπάρχουν ακόμα φάκελοι. + %1$s · %2$s + Ενημερωμένος + Συγχρονισμός %1$d%% + Σάρωση + Σε παύση + Μη συγχρονισμένος + Δεν ήταν δυνατός ο συγχρονισμός %1$d αρχείων + Αποκλεισμένος + Αυτός ο φάκελος βρίσκεται εκτός του αποθηκευτικού χώρου του DroidDeck, γι\' αυτό το DroidDeck τον έθεσε σε παύση. Για να συγχρονίσετε φακέλους εκτός DroidDeck, χρησιμοποιήστε την εφαρμογή Syncthing-Fork. + Αφαίρεση από το Syncthing + Η αφαίρεση δεν διαγράφει κανένα αρχείο. + Συσκευές + Δεν υπάρχουν ακόμα συσκευές. + Συνδεδεμένη + Εκτός σύνδεσης + Τελευταία εμφάνιση %1$s + Ταυτότητα αυτής της συσκευής + Στην άλλη σας συσκευή, προσθέστε μια συσκευή στο Syncthing και σαρώστε αυτόν τον κωδικό ή επικολλήστε την ταυτότητα. Στη συνέχεια, αποδεχτείτε την εδώ στη διεπαφή web. + Αντιγραφή + Κοινή χρήση + Έγινε + Απενεργοποίηση του Syncthing; + Το Syncthing σταματά τώρα και παραμένει σταματημένο μέχρι να το ενεργοποιήσετε ξανά. Τα συγχρονισμένα αρχεία παραμένουν εκεί που βρίσκονται. + Διαγραφή και των ρυθμίσεων του Syncthing + Αφαιρεί την ταυτότητα αυτής της συσκευής, τους φακέλους και τις συσκευές. Με την επανενεργοποίηση ξεκινά από την αρχή. + Απενεργοποίηση + Θύρες + Συγχρονισμός + Τοπική ανακάλυψη + Επιλογή νέων θυρών + Ελέγχεται ξανά σε κάθε εκκίνηση. + Νέα επιλογή + Επαναφορά + Κλειδί API + Αναδημιουργία + Επαναφορά του Syncthing + Νέα ταυτότητα συσκευής, αφαίρεση φακέλων και συσκευών. Τα αρχεία στον αποθηκευτικό χώρο του DroidDeck διατηρούνται. + Επαναφορά + Επαναφορά του Syncthing; + Λειτουργία + Εκτέλεση + Όσο το DroidDeck είναι ανοιχτό + Στο παρασκήνιο + Μόνο μέσω Wi-Fi + Παύση κατά τη διάρκεια παιχνιδιών + Αρχεία καταγραφής + Έξοδος του Syncthing για αυτή τη συνεδρία + Κοινή χρήση + Syncthing + Ρυθμίστε πρώτα το runtime Linux. + Αποδοχή + Αποθήκευση του «%1$s» σε + Προσθήκη φακέλου + Συγχρονισμός ενός φακέλου στον αποθηκευτικό χώρο του DroidDeck + Επιλογή + Προσθήκη + Δεν ήταν δυνατή η προσθήκη του φακέλου. + Κοινή χρήση με + Δεν υπάρχουν ακόμα συσκευές. Μπορείτε να μοιραστείτε αυτόν τον φάκελο αργότερα από τη διεπαφή web. + Χρήση αυτού του φακέλου + Νέος φάκελος + Αρχικός φάκελος του container + Φάκελος συγχρονισμού + Κοινόχρηστος χώρος DroidDeck + DroidDeck σε %1$s + Δεδομένα εφαρμογής DroidDeck + Οι νέοι φάκελοι αποθηκεύονται στον φάκελο συγχρονισμού του DroidDeck. Για να συγχρονίσετε άλλον φάκελο του DroidDeck, χρησιμοποιήστε την Προσθήκη φακέλου στην καρτέλα Syncthing. + Syncthing + Επαναφόρτωση + Το Syncthing δεν εκτελείται. Το Steam σταμάτησε με μη έγκυρη εντολή (SIGILL). Μπορεί να είναι πρόβλημα συμβατότητας εντολών CPU ή κατάρρευση του πελάτη. Μοιραστείτε τα αρχεία καταγραφής με τα στοιχεία της συσκευής σας. Μπορείτε να δοκιμάσετε Ρυθμίσεις Steam → Πελάτης Steam → x86-64 (πειραματικό). Απαιτεί επιπλέον λήψη και μπορεί να είναι πιο αργό. Το πρόγραμμα οδήγησης γραφικών Linux δεν βρήκε χρησιμοποιήσιμη συσκευή Vulkan. Ελέγξτε ότι το επιλεγμένο πρόγραμμα οδήγησης GPU Linux υποστηρίζει αυτή τη GPU. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 04e029e81..5db289b93 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -299,6 +299,8 @@ Mando Xbox 360 Deck: botón de Quick Access y giroscopio. Steam Controller: lo mismo, con detección de agarre en los botones traseros. DualShock 4 y DualSense: giroscopio, y los trackpads como su panel táctil. Xbox 360: abre Quick Access con Guide+A. Atrás + Mantener Atrás para atajos de Steam + Mantener Atrás abre Acceso rápido o crea combinaciones de Steam con otros botones. Desactívalo para ignorar Atrás mantenido; pulsar y pulsar dos veces siguen funcionando. Mantén Atrás 700 ms, o mantén Atrás y pulsa otro botón para los atajos de Steam. Intervalo de doble toque %1$d ms @@ -656,6 +658,8 @@ Barra Fijar posición Arrastra el HUD para moverlo; una pulsación larga lo bloquea o lo desbloquea. Mientras está bloqueado, los controles en pantalla que tiene debajo siguen funcionando. + Ajustar a los lados + Al soltar el HUD, se mueve al lado más cercano de la pantalla. Un gesto rápido lo lanza en esa dirección. Aspecto Tamaño en pantalla Suave @@ -1794,6 +1798,93 @@ Teclado + trackpad Terminal Agarres de Deck + trackpads + Panel + Todavía no hay nada que mostrar aquí. + Descargas + No hay descargas en curso. + Steam + Descargando caché de shaders + En pausa + Esperando para empezar + Notificaciones + DroidDeck – pantalla inferior + Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola. + Permitir acceso a notificaciones + No hay notificaciones. + %1$d notificaciones + Borrar + Capturas de pantalla + Los FPS requieren el HUD activado + Velocidad + Pedales + Fuerza G + Tiempos + Mejor vuelta + Neumáticos °C + Acelerador y freno, últimos 10 s + Velocidad, %1$s + Marcha + Unidades de velocidad + Automático (%1$s) + Cliente de Steam ARM + Cliente de Steam x86 + Acelerador + Freno + Testigos + Cambio + Freno de mano + Limitador de pit + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Aceite %1$s °C + Distribuidor de energía + Combustible, t + Principal %1$s t + Reserva %1$s t + Toca la franja superior o el cuadro de instrumentos para cambiar su estilo. + Enviar datos al panel + Automático + Lo activa en todos los juegos de esta lista, incluidos los que se encuentren más adelante. Los juegos que desactives seguirán desactivados. + Esto cambia el ajuste de salida de datos del propio juego. Se aplica la próxima vez que se inicie el juego; al desactivarlo se restaura el ajuste anterior. + Cierra %1$s para cambiar esto. + No se han podido cambiar los ajustes de %1$s. + %1$s: activa %2$s en los ajustes del juego con la IP 127.0.0.1 y el puerto %3$d. + Se guardan en Pictures/DroidDeck. Las capturas de Steam también aparecen aquí. + Hacer captura de pantalla + Captura de pantalla guardada. + No se ha podido hacer la captura de pantalla. + Todavía no hay capturas de pantalla. + Chat y voz + Lee y responde el chat de Steam aquí mientras el juego sigue en la pantalla principal. Usa la cuenta con la que se ha iniciado sesión en Steam. + Steam todavía no tiene una sesión web que compartir, así que inicia sesión aquí una vez. + Android cerró la página del chat mientras DroidDeck estaba en segundo plano. Se volverá a abrir cuando regreses a DroidDeck. + Abrir Steam Chat + Cerrar chat + Micro activado + Micro desactivado + El micro está desactivado en este juego. Para hablar, activa Micrófono en los ajustes de Steam de DroidDeck (Audio) y vuelve a iniciar el juego. + Volumen %1$d%% + Estadísticas del juego + FPS + CPU + GPU + Batería + Vuelta + Posición + Vuelta actual + Última vuelta + Combustible + Carga + Pips SYS · ENG · WEP + Estado legal + Saldo + War Thunder + Elite Dangerous + Activa la salida de datos de un juego de carreras (a menudo llamada telemetría o UDP) para ver aquí un panel en directo. Los datos no salen de este dispositivo. + El puerto %1$d recibe datos (%2$d paquetes de %3$d bytes), pero en un formato que esta página aún no puede leer. + Otro programa ya usa el puerto %1$s, así que ese juego no se puede mostrar aquí. Eliminando el runtime de Linux @@ -2125,6 +2216,7 @@ %1$s Cambia el escalado a %2$s. Igual a la pantalla (%1$d×%2$d) Seguir la pantalla (cambia de tamaño al plegar o desplegar) + Escalado %1$s Steam Mostrar detalles del dispositivo Ocultar detalles del dispositivo @@ -3279,6 +3371,113 @@ Las vistas guardadas se pueden recalcular. Inicie una nueva calibración para capturar en el sistema de coordenadas de seguimiento actual. La suma de comprobación del modelo de mano no coincide OpenCV no se pudo cargar + Syncthing + Syncthing + Sincronizar con Syncthing + Basada en Syncthing-Fork, es una implementación de Syncthing que te permite sincronizar archivos del sistema de archivos interno de DroidDeck con otras instancias de Syncthing. Solo sirve para los archivos que guardas en el sistema de archivos interno de DroidDeck. + Esta compilación no incluye Syncthing. + Iniciando Syncthing + Iniciando + Actualizado + Sincronizando + En pausa + Pausando + Reanudando + Requiere atención + Syncthing se detuvo + Reintentar + En pausa hasta tener Wi-Fi + En pausa durante el juego + %1$d carpetas · %2$d de %3$d dispositivos conectados + Faltan %1$s + Abrir la interfaz web + Pausar + Reanudar + En ejecución + Desactívalo para detener Syncthing por completo. + Este dispositivo + ID del dispositivo + Mostrar QR + Avanzado + Puertos, cuándo se ejecuta, restablecer + Abrir + Esperando tu respuesta + %1$s quiere conectarse + %1$s quiere compartir “%2$s” + Acepta para elegir dónde se guarda en DroidDeck. + Revisar en la interfaz web + Carpetas + Aún no hay carpetas. + %1$s · %2$s + Actualizada + Sincronizando %1$d%% + Escaneando + En pausa + No sincronizada + No se pudieron sincronizar %1$d archivos + Bloqueada + Esta carpeta está fuera del almacenamiento de DroidDeck, así que DroidDeck la puso en pausa. Para sincronizar carpetas fuera de DroidDeck, usa la app Syncthing-Fork. + Quitar de Syncthing + Quitarla no borra ningún archivo. + Dispositivos + Aún no hay dispositivos. + Conectado + Sin conexión + Visto por última vez %1$s + ID de este dispositivo + En tu otro dispositivo, añade un dispositivo en Syncthing y escanea este código o pega el ID. Después, acéptalo aquí en la interfaz web. + Copiar + Compartir + Hecho + ¿Desactivar Syncthing? + Syncthing se detiene ahora y seguirá detenido hasta que vuelvas a activarlo. Los archivos sincronizados se quedan donde están. + Borrar también la configuración de Syncthing + Quita el ID de este dispositivo, las carpetas y los dispositivos. Al volver a activarlo, empieza desde cero. + Desactivar + Puertos + Sincronización + Descubrimiento local + Elegir puertos nuevos + Se vuelve a comprobar en cada inicio. + Volver a elegir + Restablecer + Clave API + Regenerar + Restablecer Syncthing + ID de dispositivo nuevo; se quitan las carpetas y los dispositivos. Los archivos del almacenamiento de DroidDeck se conservan. + Restablecer + ¿Restablecer Syncthing? + Ejecución + Ejecutar + Mientras DroidDeck está abierto + En segundo plano + Solo con Wi-Fi + Pausar durante los juegos + Registros + Salida de Syncthing de esta sesión + Compartir + Syncthing + Configura primero el runtime de Linux. + Aceptar + Guardar “%1$s” en + Añadir carpeta + Sincroniza una carpeta del almacenamiento de DroidDeck + Elegir + Añadir + No se pudo añadir la carpeta. + Compartir con + Aún no hay dispositivos. Puedes compartir esta carpeta más tarde desde la interfaz web. + Usar esta carpeta + Carpeta nueva + Carpeta personal del contenedor + Carpeta Sync + Almacenamiento compartido de DroidDeck + DroidDeck en %1$s + Datos de la app DroidDeck + Las carpetas nuevas se guardan en la carpeta Sync de DroidDeck. Para sincronizar otra carpeta de DroidDeck, usa Añadir carpeta en la pestaña Syncthing. + Syncthing + Recargar + Syncthing no se está ejecutando. Steam se detuvo por una instrucción ilegal (SIGILL). Puede ser un problema de compatibilidad con instrucciones de la CPU o un fallo del cliente. Comparte los registros con los datos de tu dispositivo. Puedes probar Ajustes de Steam → Cliente de Steam → x86-64 (experimental). Requiere una descarga adicional y puede ir más lento. El controlador gráfico de Linux no ha encontrado un dispositivo Vulkan utilizable. Comprueba que el controlador de GPU de Linux seleccionado sea compatible con esta GPU. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 6afd0aef8..ed8ad9a77 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -299,6 +299,8 @@ Xbox 360 -ohjain Deck: pikavalikkopainike ja gyroskooppi. Steam Controller: samat, ja takapainikkeissa puristustunnistus. DualShock 4 ja DualSense: gyroskooppi, ja kosketuslevyt toimivat kosketuslevynä. Xbox 360: avaa pikavalikko Guide+A:lla. Back + Pidä Takaisin pohjassa Steam-pikanäppäimille + Takaisin pohjassa pitäminen avaa pikavalikon tai tekee Steam-yhdistelmiä muiden painikkeiden kanssa. Poista käytöstä ohittaaksesi pitkän painalluksen; napautus ja kaksoisnapautus toimivat yhä. Pidä Back-painiketta 700 ms, tai pidä Back-painiketta ja paina toista painiketta Steam-pikanäppäimiä varten. Kaksoisnapautuksen aikaikkuna %1$d ms @@ -654,6 +656,8 @@ Palkki Lukitse paikalleen Siirrä HUD:ia vetämällä; pitkä painallus lukitsee tai vapauttaa sen. Lukittuna sen alla olevat näytön ohjaimet toimivat edelleen. + Kiinnitä sivuille + Kun päästät HUDista irti, se siirtyy näytön lähempään reunaan. Nopea pyyhkäisy heittää sen siihen suuntaan. Ulkoasu Koko näytöllä Pehmeä @@ -1772,6 +1776,93 @@ Näppäimistö + kosketuslevy Pääte Deck-kahvat + kosketuslevyt + Kojelauta + Täällä ei ole vielä mitään näytettävää. + Lataukset + Latauksia ei ole käynnissä. + Steam + Ladataan sävytinvälimuistia + Keskeytetty + Odottaa alkamista + Ilmoitukset + DroidDeckin alanäyttö + Näytä viestit ja muut ilmoitukset täällä pelatessasi. Ne näkyvät vain tällä näytöllä, eikä niitä tallenneta. Android kysyy kerran: sen ilmoitusten käyttöoikeussivu avautuu tälle näytölle; kytke DroidDeck siellä päälle, niin tämä sivu palaa itsestään. + Salli ilmoitusten käyttö + Ei ilmoituksia. + Ilmoituksia: %1$d + Tyhjennä + Kuvakaappaukset + FPS vaatii HUD:n päälle + Nopeus + Polkimet + G-voima + Ajanotto + Paras kierros + Renkaat °C + Kaasu ja jarru, viimeiset 10 s + Nopeus, %1$s + Vaihde + Nopeuden yksiköt + Automaattinen (%1$s) + Steam-asiakasohjelma ARM + Steam-asiakasohjelma x86 + Kaasu + Jarru + Kojelauta + Vaihto + Käsijarru + Varikkorajoitin + TC + ABS + Turbo %1$s bar + Moottori %1$s °C + Öljy %1$s °C + Tehonjakaja + Polttoaine, t + Päätankki %1$s t + Varatankki %1$s t + Napauta yläpalkkia tai kojelautaa vaihtaaksesi sen tyyliä. + Lähetä kojelaudan tiedot + Automaattinen + Kytkee sen päälle jokaiselle tässä luetellulle pelille, myös myöhemmin löytyville. Pois kytkemäsi pelit pysyvät pois päältä. + Tämä muuttaa pelin omaa datan ulostulon asetusta. Muutos tulee voimaan, kun peli käynnistetään seuraavan kerran; pois kytkeminen palauttaa vanhan asetuksen. + Sulje %1$s muuttaaksesi tätä. + Pelin %1$s asetuksia ei voitu muuttaa. + %1$s: kytke pelin asetuksista päälle %2$s IP-osoitteella 127.0.0.1 ja portilla %3$d. + Tallennetaan kansioon Pictures/DroidDeck. Myös Steamin kuvakaappaukset näkyvät täällä. + Ota kuvakaappaus + Kuvakaappaus tallennettu. + Kuvakaappausta ei voitu ottaa. + Ei vielä kuvakaappauksia. + Chat & ääni + Lue Steam-chattia ja vastaa siihen täällä, kun peli jatkuu päänäytöllä. Se käyttää tiliä, jolla Steamiin on kirjauduttu. + Steamilla ei ole vielä jaettavaa verkkokirjautumista, joten kirjaudu tässä kerran. + Android sulki chat-sivun, kun DroidDeck oli taustalla. Se avautuu uudelleen, kun palaat DroidDeckiin. + Avaa Steam Chat + Sulje chat + Mikrofoni päällä + Mikrofoni pois + Mikrofoni on pois päältä tässä pelissä. Jos haluat puhua, kytke Mikrofoni päälle DroidDeckin Steam-asetuksissa (Ääni) ja käynnistä peli uudelleen. + Äänenvoimakkuus %1$d %% + Pelinsisäiset tilastot + FPS + CPU + GPU + Akku + Kierros + Sijoitus + Tämä kierros + Edellinen kierros + Polttoaine + Lasti + Tehopisteet SYS · ENG · WEP + Oikeudellinen asema + Saldo + War Thunder + Elite Dangerous + Kytke kilpa-ajopelin datan ulostulo päälle (usein nimeltään telemetria tai UDP), niin näet täällä reaaliaikaisen kojelaudan. Tiedot pysyvät tällä laitteella. + Portti %1$d vastaanottaa dataa (%2$d pakettia, kukin %3$d tavua), mutta muodossa, jota tämä sivu ei vielä osaa lukea. + Toinen ohjelma käyttää jo porttia %1$s, joten peliä ei voida näyttää täällä. Poistetaan Linux-ajoympäristöä @@ -2102,6 +2193,7 @@ %1$s Asettaa skaalauksen tilaan %2$s. Sovita näyttöön (%1$d×%2$d) Seuraa näyttöä (koko muuttuu taitettuna tai avattuna) + Skaalattu %1$s Steam Näytä laitteen tiedot Piilota laitteen tiedot @@ -3243,6 +3335,113 @@ Tallennetut näkymät voidaan laskea uudelleen. Aloita uusi kalibrointi kaapataksesi nykyiseen seurantakoordinaattijärjestelmään. Käsimallin tarkistussumman epäsuhta OpenCV:tä ei voitu ladata + Syncthing + Syncthing + Synkronoi Syncthingillä + Tämä Syncthing-Forkiin perustuva Syncthing-toteutus synkronoi DroidDeckin sisäisen tiedostojärjestelmän tiedostoja muiden Syncthing-instanssien kanssa. Se koskee vain tiedostoja, joita säilytät DroidDeckin sisäisessä tiedostojärjestelmässä. + Tämä versio ei sisällä Syncthingiä. + Käynnistetään Syncthingiä + Käynnistyy + Ajan tasalla + Synkronoidaan + Keskeytetty + Keskeytetään + Jatketaan + Vaatii huomiota + Syncthing pysähtyi + Yritä uudelleen + Keskeytetty, kunnes Wi-Fi on käytettävissä + Keskeytetty pelin ajaksi + Kansioita %1$d · %2$d/%3$d laitetta yhdistetty + %1$s jäljellä + Avaa web-käyttöliittymä + Keskeytä + Jatka + Käynnissä + Poista käytöstä pysäyttääksesi Syncthingin kokonaan. + Tämä laite + Laitteen tunniste + Näytä QR-koodi + Lisäasetukset + Portit, milloin ajetaan, nollaus + Avaa + Odottaa sinua + %1$s haluaa muodostaa yhteyden + %1$s haluaa jakaa kansion “%2$s” + Hyväksy ja valitse, mihin se tallennetaan DroidDeckissä. + Tarkista web-käyttöliittymässä + Kansiot + Ei vielä kansioita. + %1$s · %2$s + Ajan tasalla + Synkronoidaan %1$d %% + Skannataan + Keskeytetty + Ei synkronoitu + %1$d tiedostoa ei voitu synkronoida + Estetty + Tämä kansio on DroidDeckin tallennustilan ulkopuolella, joten DroidDeck keskeytti sen. Käytä Syncthing-Fork-sovellusta DroidDeckin ulkopuolisten kansioiden synkronointiin. + Poista Syncthingistä + Poistaminen ei poista yhtään tiedostoa. + Laitteet + Ei vielä laitteita. + Yhdistetty + Offline-tilassa + Viimeksi nähty %1$s + Tämän laitteen tunniste + Lisää toisella laitteellasi laite Syncthingissä ja skannaa tämä koodi tai liitä tunniste. Hyväksy se sitten täällä web-käyttöliittymässä. + Kopioi + Jaa + Valmis + Poistetaanko Syncthing käytöstä? + Syncthing pysähtyy nyt ja pysyy pysäytettynä, kunnes otat sen uudelleen käyttöön. Synkronoidut tiedostot pysyvät paikoillaan. + Poista myös Syncthingin asetukset + Poistaa tämän laitteen tunnisteen, kansiot ja laitteet. Kun otat sen uudelleen käyttöön, se aloittaa alusta. + Poista käytöstä + Portit + Synkronointi + Paikallinen etsintä + Valitse uudet portit + Tarkistetaan uudelleen joka käynnistyksessä. + Valitse uudelleen + Nollaus + API-avain + Luo uudelleen + Nollaa Syncthing + Uusi laitteen tunniste, kansiot ja laitteet poistetaan. DroidDeckin tallennustilan tiedostot säilytetään. + Nollaa + Nollataanko Syncthing? + Suoritus + Käynnissä + Kun DroidDeck on auki + Taustalla + Vain Wi-Fi + Keskeytä pelien ajaksi + Lokit + Syncthingin tuloste tältä istunnolta + Jaa + Syncthing + Asenna Linux-ajoympäristö ensin. + Hyväksy + Tallenna “%1$s” kohteeseen + Lisää kansio + Synkronoi kansio DroidDeckin tallennustilasta + Valitse + Lisää + Kansiota ei voitu lisätä. + Jaa laitteille + Ei vielä laitteita. Voit jakaa tämän kansion myöhemmin web-käyttöliittymästä. + Käytä tätä kansiota + Uusi kansio + Kontin kotikansio + Sync-kansio + DroidDeckin jaettu tallennustila + DroidDeck kohteessa %1$s + DroidDeckin sovellustiedot + Uudet kansiot tallennetaan DroidDeckin Sync-kansioon. Jos haluat synkronoida toisen DroidDeck-kansion, käytä Syncthing-välilehden Lisää kansio -toimintoa. + Syncthing + Lataa uudelleen + Syncthing ei ole käynnissä. Steam pysähtyi virheelliseen käskyyn (SIGILL). Syynä voi olla CPU-käskyjen yhteensopivuusongelma tai asiakasohjelman kaatuminen. Jaa lokit ja laitteesi tiedot. Voit kokeilla Steam-asetukset → Steam-asiakasohjelma → x86-64 (kokeellinen). Se vaatii lisälatauksen ja voi toimia hitaammin. Linux-grafiikka-ajuri ei löytänyt käyttökelpoista Vulkan-laitetta. Tarkista, että valittu Linux-GPU-ajuri tukee tätä GPU:ta. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 406bc4496..6a1c0914e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -299,6 +299,8 @@ Manette Xbox 360 Deck : bouton d\'accès rapide et gyroscope. Steam Controller : idem, avec détection de la prise en main sur les boutons arrière. DualShock 4 et DualSense : gyroscope, et les trackpads servent de pavé tactile. Xbox 360 : utilisez Guide+A pour ouvrir l\'accès rapide. Retour + Maintenir Retour pour les raccourcis Steam + Maintenir Retour ouvre l’accès rapide ou crée des combinaisons Steam avec d’autres boutons. Désactivez pour ignorer l’appui long ; l’appui simple et double fonctionnent toujours. Maintenez Retour pendant 700 ms, ou maintenez Retour et appuyez sur un autre bouton pour les raccourcis Steam. Délai du double appui %1$d ms @@ -656,6 +658,8 @@ Bandeau Verrouiller la position Faites glisser le HUD pour le déplacer ; un appui long le verrouille ou le déverrouille. Tant qu\'il est verrouillé, les commandes à l\'écran situées dessous restent utilisables. + Aimanter sur les côtés + Relâchez le HUD et il rejoint le côté de l’écran le plus proche. Un geste rapide le lance dans cette direction. Apparence Taille à l\'écran Douce @@ -1794,6 +1798,93 @@ Clavier + pavé tactile Terminal Poignées Deck + pavés tactiles + Tableau de bord + Rien à afficher pour l\'instant. + Téléchargements + Aucun téléchargement en cours. + Steam + Téléchargement du cache de shaders + En pause + En attente de démarrage + Notifications + Écran inférieur DroidDeck + Affichez ici les messages et autres notifications pendant que vous jouez. Ils restent uniquement sur cet écran et ne sont pas enregistrés. Android ne le demande qu\'une fois : sa page d\'accès aux notifications s\'ouvre sur cet écran ; activez-y DroidDeck et cette page reviendra d\'elle-même. + Autoriser l\'accès aux notifications + Aucune notification. + %1$d notifications + Effacer + Captures d\'écran + Les FPS nécessitent d\'activer le HUD + Vitesse + Pédales + Force G + Chronométrage + Meilleur tour + Pneus °C + Accélérateur et frein, 10 dernières s + Vitesse, %1$s + Rapport + Unités de vitesse + Automatique (%1$s) + Client Steam ARM + Client Steam x86 + Accélérateur + Frein + Tableau de bord + Passage de rapport + Frein à main + Limiteur des stands + TC + ABS + Turbo %1$s bar + Moteur %1$s °C + Huile %1$s °C + Répartiteur d\'énergie + Carburant, t + Principal %1$s t + Réserve %1$s t + Appuyez sur la bande supérieure ou sur le tableau de bord pour changer son style. + Envoyer les données du tableau de bord + Automatique + L\'active pour chaque jeu listé ici, y compris les jeux trouvés plus tard. Les jeux que vous désactivez restent désactivés. + Cela modifie le paramètre de sortie de données propre au jeu. Le changement prend effet au prochain lancement du jeu ; le désactiver rétablit l\'ancien paramètre. + Fermez %1$s pour modifier ce réglage. + Impossible de modifier les paramètres de %1$s. + %1$s : activez %2$s dans les paramètres du jeu avec l\'IP 127.0.0.1 et le port %3$d. + Enregistrées dans Pictures/DroidDeck. Les captures d\'écran Steam s\'affichent aussi ici. + Prendre une capture d\'écran + Capture d\'écran enregistrée. + Impossible de prendre une capture d\'écran. + Aucune capture d\'écran pour l\'instant. + Chat et voix + Lisez le chat Steam et répondez-y ici pendant que le jeu continue de tourner sur l\'écran principal. Le compte connecté à Steam est utilisé. + Steam n\'a pas encore de connexion web à partager, connectez-vous donc ici une fois. + Android a fermé la page de chat pendant que DroidDeck était en arrière-plan. Elle se rouvre lorsque vous revenez sur DroidDeck. + Ouvrir Steam Chat + Fermer le chat + Micro activé + Micro désactivé + Le micro est désactivé pour ce jeu. Pour parler, activez Microphone dans les paramètres Steam de DroidDeck (Audio), puis relancez le jeu. + Volume %1$d %% + Statistiques en jeu + FPS + CPU + GPU + Batterie + Tour + Position + Tour actuel + Dernier tour + Carburant + Cargaison + Points SYS · ENG · WEP + Statut légal + Solde + War Thunder + Elite Dangerous + Activez la sortie de données d\'un jeu de course (souvent appelée télémétrie ou UDP) pour voir ici un tableau de bord en direct. Les données restent sur cet appareil. + Le port %1$d reçoit des données (%2$d paquets de %3$d octets), mais dans un format que cette page ne sait pas encore lire. + Un autre programme utilise déjà le port %1$s ; ce jeu ne peut donc pas s\'afficher ici. Suppression de l\'environnement d\'exécution Linux @@ -2125,6 +2216,7 @@ %1$s Règle la mise à l\'échelle sur %2$s. Adapter à l\'écran (%1$d×%2$d) Suivre l\'écran (s\'adapte au pliage et au dépliage) + Mis à l\'échelle %1$s Steam Afficher les détails de l\'appareil Masquer les détails de l\'appareil @@ -3279,6 +3371,113 @@ Les vues enregistrées peuvent être recalculées. Démarrez un nouvel étalonnage pour capturer dans le système de coordonnées de suivi actuel. Incohérence de la somme de contrôle du modèle manuel OpenCV n\'a pas pu charger + Syncthing + Syncthing + Synchroniser avec Syncthing + Basée sur Syncthing-Fork, cette implémentation de Syncthing vous permet de synchroniser les fichiers du système de fichiers interne de DroidDeck avec d\'autres instances Syncthing. Elle ne concerne que les fichiers que vous stockez dans le système de fichiers interne de DroidDeck. + Cette version n\'inclut pas Syncthing. + Démarrage de Syncthing + Démarrage + À jour + Synchronisation + En pause + Mise en pause + Reprise + Intervention requise + Syncthing s\'est arrêté + Réessayer + En pause jusqu\'à la connexion Wi-Fi + En pause pendant le jeu + %1$d dossiers · %2$d appareils connectés sur %3$d + %1$s restants + Ouvrir l\'interface web + Pause + Reprendre + En cours d\'exécution + Désactivez pour arrêter complètement Syncthing. + Cet appareil + ID de l\'appareil + Afficher le QR code + Avancé + Ports, moment d\'exécution, réinitialisation + Ouvrir + En attente de votre réponse + %1$s souhaite se connecter + %1$s souhaite partager « %2$s » + Acceptez pour choisir où l\'enregistrer dans DroidDeck. + Vérifier dans l\'interface web + Dossiers + Aucun dossier pour l\'instant. + %1$s · %2$s + À jour + Synchronisation %1$d %% + Analyse + En pause + Non synchronisé + Échec de la synchronisation de %1$d fichiers + Bloqué + Ce dossier se trouve en dehors du stockage de DroidDeck, DroidDeck l\'a donc mis en pause. Pour synchroniser des dossiers en dehors de DroidDeck, utilisez l\'application Syncthing-Fork. + Retirer de Syncthing + Le retirer ne supprime aucun fichier. + Appareils + Aucun appareil pour l\'instant. + Connecté + Hors ligne + Vu pour la dernière fois : %1$s + ID de cet appareil + Sur votre autre appareil, ajoutez un appareil dans Syncthing et scannez ce code ou collez l\'ID. Acceptez-le ensuite ici dans l\'interface web. + Copier + Partager + Terminé + Désactiver Syncthing ? + Syncthing s\'arrête maintenant et reste arrêté jusqu\'à ce que vous le réactiviez. Les fichiers synchronisés restent à leur place. + Supprimer aussi la configuration de Syncthing + Supprime l\'ID de cet appareil, les dossiers et les appareils. À la réactivation, tout repart de zéro. + Désactiver + Ports + Synchronisation + Découverte locale + Choisir de nouveaux ports + Vérifié de nouveau à chaque démarrage. + Choisir à nouveau + Réinitialisation + Clé d\'API + Régénérer + Réinitialiser Syncthing + Nouvel ID d\'appareil, dossiers et appareils supprimés. Les fichiers du stockage de DroidDeck sont conservés. + Réinitialiser + Réinitialiser Syncthing ? + Exécution + Exécuter + Quand DroidDeck est ouvert + En arrière-plan + Wi-Fi uniquement + Mettre en pause pendant les jeux + Journaux + Sortie de Syncthing pour cette session + Partager + Syncthing + Veuillez d\'abord installer l\'environnement d\'exécution Linux. + Accepter + Enregistrer « %1$s » dans + Ajouter un dossier + Synchroniser un dossier du stockage de DroidDeck + Choisir + Ajouter + Impossible d\'ajouter le dossier. + Partager avec + Aucun appareil pour l\'instant. Vous pourrez partager ce dossier plus tard depuis l\'interface web. + Utiliser ce dossier + Nouveau dossier + Dossier personnel du conteneur + Dossier Sync + Stockage partagé de DroidDeck + DroidDeck sur %1$s + Données de l\'application DroidDeck + Les nouveaux dossiers sont enregistrés dans le dossier Sync de DroidDeck. Pour synchroniser un autre dossier de DroidDeck, utilisez « Ajouter un dossier » dans l\'onglet Syncthing. + Syncthing + Recharger + Syncthing n\'est pas en cours d\'exécution. Steam s\'est arrêté sur une instruction illégale (SIGILL). Il peut s\'agir d\'un problème de compatibilité des instructions CPU ou d\'un plantage du client. Partagez les journaux avec les détails de votre appareil. Vous pouvez essayer Paramètres Steam → Client Steam → x86-64 (expérimental). Cela nécessite un téléchargement supplémentaire et peut être plus lent. Le pilote graphique Linux n\'a trouvé aucun périphérique Vulkan utilisable. Vérifiez que le pilote GPU Linux sélectionné prend en charge ce GPU. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 83bf51c68..cd884b5ea 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -299,6 +299,8 @@ Xbox 360 कंट्रोलर Deck: Quick Access बटन और gyro। Steam Controller: वही, साथ में पीछे के बटन पर ग्रिप सेंस। DualShock 4 और DualSense: gyro, और ट्रैकपैड उनके टचपैड के तौर पर। Xbox 360: Guide+A से Quick Access खोलें। बैक + Steam शॉर्टकट के लिए बैक दबाकर रखें + बैक दबाकर रखने से क्विक एक्सेस खुलता है या दूसरे बटनों के साथ Steam कॉम्बो बनते हैं। दबाकर रखने को अनदेखा करने के लिए बंद करें; टैप और डबल-टैप काम करते रहेंगे। Steam शॉर्टकट के लिए Back को 700 ms तक दबाए रखें, या Back दबाए रखकर कोई दूसरा बटन दबाएं। डबल-टैप विंडो %1$d ms @@ -654,6 +656,8 @@ पट्टी जगह पर लॉक करें HUD को खिसकाने के लिए उसे ड्रैग करें; देर तक दबाने से वह लॉक या अनलॉक होता है। लॉक रहने पर भी उसके नीचे के ऑन-स्क्रीन कंट्रोल काम करते रहते हैं। + किनारों पर चिपकाएँ + HUD छोड़ने पर यह स्क्रीन की पास वाली ओर चला जाता है। तेज़ी से झटकने पर यह उसी दिशा में जाता है। लुक स्क्रीन पर आकार हल्का @@ -1618,7 +1622,7 @@ कॉपी करें कट करें एक्शन - Favorites + पसंदीदा मौजूदा फ़ोल्डर पिन करें अभी कोई favorite नहीं है - किसी फ़ोल्डर को उसके ⋮ मेनू से पिन करें ताकि यहां जल्दी वापस आ सकें। favorites से हटाएं @@ -1634,7 +1638,7 @@ रीड-ओनली गेम (और Wine) को इस फ़ाइल को ओवरराइट या डिलीट करने से रोकता है। रीड-ओनली नहीं बदला जा सका - Hidden + छिपी हुई यह स्टोरेज hidden फ़्लैग सेव नहीं कर सकता। फ़ाइल को Windows में hidden के तौर पर चिह्नित करता है (Wine का hidden एट्रिब्यूट)। इस स्टोरेज पर Hidden नहीं बदला जा सका @@ -1728,7 +1732,7 @@ फ़ाइलें स्टोरेज क्विक - FAVORITES + पसंदीदा डाउनलोड डाउनलोड ROM @@ -1764,7 +1768,7 @@ D-pad नीचे D-pad बायां D-pad दायां - Hidden + छिपा हुआ ले जाने के लिए ड्रैग करें स्नैप सेव करें @@ -1772,6 +1776,93 @@ कीबोर्ड + ट्रैकपैड टर्मिनल Deck ग्रिप + ट्रैकपैड + डैशबोर्ड + यहां दिखाने के लिए अभी कुछ नहीं है। + डाउनलोड + कोई डाउनलोड नहीं चल रहा है। + Steam + शेडर कैश डाउनलोड हो रहा है + रुका हुआ + शुरू होने का इंतज़ार + नोटिफ़िकेशन + DroidDeck नीचे की स्क्रीन + खेलते समय मैसेज और अन्य नोटिफ़िकेशन यहां दिखाएं। वे सिर्फ़ इसी स्क्रीन पर रहते हैं और सेव नहीं होते। Android एक बार पूछता है: उसका नोटिफ़िकेशन ऐक्सेस पेज इसी स्क्रीन पर खुलता है; वहां DroidDeck चालू करें और यह पेज अपने-आप वापस आ जाएगा। + नोटिफ़िकेशन ऐक्सेस की अनुमति दें + कोई नोटिफ़िकेशन नहीं। + %1$d नोटिफ़िकेशन + साफ़ करें + स्क्रीनशॉट + FPS के लिए HUD चालू होना चाहिए + स्पीड + पैडल + G-फ़ोर्स + टाइमिंग + सबसे अच्छा लैप + टायर °C + थ्रॉटल और ब्रेक, पिछले 10 s + स्पीड, %1$s + गियर + स्पीड की इकाई + ऑटोमैटिक (%1$s) + Steam क्लाइंट ARM + Steam क्लाइंट x86 + थ्रॉटल + ब्रेक + डैशबोर्ड + शिफ़्ट + हैंडब्रेक + पिट लिमिटर + TC + ABS + टर्बो %1$s bar + इंजन %1$s °C + ऑयल %1$s °C + पावर डिस्ट्रीब्यूटर + फ़्यूल, t + मुख्य %1$s t + रिज़र्व %1$s t + स्टाइल बदलने के लिए ऊपर की पट्टी या डैश पर टैप करें। + डैशबोर्ड डेटा भेजें + ऑटोमैटिक + यहां दिए हर गेम के लिए इसे चालू करता है, बाद में मिलने वाले गेम भी शामिल हैं। जिन गेम को आप बंद करते हैं, वे बंद ही रहते हैं। + यह गेम की अपनी डेटा आउटपुट सेटिंग बदलता है। यह अगली बार गेम शुरू होने पर लागू होता है; इसे बंद करने पर पुरानी सेटिंग वापस आ जाती है। + इसे बदलने के लिए %1$s बंद करें। + %1$s की सेटिंग बदली नहीं जा सकीं। + %1$s: गेम की सेटिंग में IP 127.0.0.1 और पोर्ट %3$d के साथ %2$s चालू करें। + Pictures/DroidDeck में सेव होते हैं। Steam स्क्रीनशॉट भी यहां दिखते हैं। + स्क्रीनशॉट लें + स्क्रीनशॉट सेव हो गया। + स्क्रीनशॉट नहीं लिया जा सका। + अभी कोई स्क्रीनशॉट नहीं। + चैट और वॉइस + मुख्य स्क्रीन पर गेम चलते हुए यहां Steam चैट पढ़ें और जवाब दें। यह उसी अकाउंट का इस्तेमाल करता है जिससे Steam में साइन इन है। + Steam के पास अभी शेयर करने के लिए कोई वेब साइन-इन नहीं है, इसलिए यहां एक बार साइन इन करें। + DroidDeck बैकग्राउंड में था, तब Android ने चैट पेज बंद कर दिया। DroidDeck पर वापस आने पर यह फिर से खुल जाता है। + Steam Chat खोलें + चैट बंद करें + माइक चालू + माइक बंद + इस गेम के लिए माइक बंद है। बात करने के लिए DroidDeck की Steam सेटिंग (ऑडियो) में माइक्रोफ़ोन चालू करें और गेम फिर से शुरू करें। + वॉल्यूम %1$d%% + इन-गेम आंकड़े + FPS + CPU + GPU + बैटरी + लैप + पोज़िशन + यह लैप + पिछला लैप + फ़्यूल + कार्गो + पिप्स SYS · ENG · WEP + कानूनी स्थिति + बैलेंस + War Thunder + Elite Dangerous + यहां लाइव डैश देखने के लिए किसी रेसिंग गेम का डेटा आउटपुट (अक्सर टेलीमेट्री या UDP कहलाता है) चालू करें। यह इसी डिवाइस पर रहता है। + पोर्ट %1$d पर डेटा आ रहा है (%3$d बाइट के %2$d पैकेट), लेकिन ऐसे फ़ॉर्मैट में जिसे यह पेज अभी पढ़ नहीं सकता। + पोर्ट %1$s को कोई दूसरा प्रोग्राम पहले से इस्तेमाल कर रहा है, इसलिए वह गेम यहां नहीं दिख सकता। Linux रनटाइम हटाया जा रहा है @@ -2102,6 +2193,7 @@ %1$s स्केलिंग को %2$s पर सेट करता है। स्क्रीन से मिलाएँ (%1$d×%2$d) Follow screen (खुलने या बंद होने पर आकार बदलता है) + स्केल %1$s Steam डिवाइस जानकारी दिखाएं डिवाइस जानकारी छिपाएं @@ -2149,7 +2241,7 @@ डील फ़्री गेम ट्रेंडिंग - Play + खेलें इंस्टॉल करें Battle.net खोलें Blizzard का डाउनलोडर शुरू हो रहा है… @@ -3243,6 +3335,113 @@ सहेजे गए दृश्यों की पुनर्गणना की जा सकती है। वर्तमान ट्रैकिंग समन्वय प्रणाली में कैप्चर करने के लिए एक नया अंशांकन प्रारंभ करें। हैंड मॉडल चेकसम बेमेल OpenCV लोड नहीं हो सका + Syncthing + Syncthing + Syncthing से सिंक करें + Syncthing-Fork पर आधारित यह Syncthing इम्प्लीमेंटेशन आपको DroidDeck के इंटरनल फ़ाइल सिस्टम की फ़ाइलें दूसरे Syncthing इंस्टेंस के साथ सिंक करने देता है। यह सिर्फ़ उन फ़ाइलों के लिए है जिन्हें आप DroidDeck के इंटरनल फ़ाइल सिस्टम में रखते हैं। + इस बिल्ड में Syncthing शामिल नहीं है। + Syncthing शुरू हो रहा है + शुरू हो रहा है + अप टू डेट + सिंक हो रहा है + रोका गया + रोका जा रहा है + फिर से शुरू हो रहा है + ध्यान देने की ज़रूरत है + Syncthing बंद हो गया + फिर से कोशिश करें + Wi-Fi मिलने तक रोका गया + गेम के दौरान रोका गया + %1$d फ़ोल्डर · %3$d में से %2$d डिवाइस कनेक्टेड + %1$s बाकी + वेब UI खोलें + रोकें + फिर से शुरू करें + चल रहा है + Syncthing को पूरी तरह रोकने के लिए बंद करें। + यह डिवाइस + डिवाइस ID + QR दिखाएं + एडवांस्ड + पोर्ट, कब चलाना है, रीसेट + खोलें + आपका इंतज़ार है + %1$s कनेक्ट करना चाहता है + %1$s “%2$s” शेयर करना चाहता है + DroidDeck में इसे कहां सेव करना है, यह चुनने के लिए स्वीकार करें। + वेब UI में देखें + फ़ोल्डर + अभी कोई फ़ोल्डर नहीं। + %1$s · %2$s + अप टू डेट + सिंक हो रहा है %1$d%% + स्कैन हो रहा है + रोका गया + सिंक नहीं है + %1$d फ़ाइलें सिंक नहीं हो सकीं + ब्लॉक किया गया + यह फ़ोल्डर DroidDeck के स्टोरेज से बाहर है, इसलिए DroidDeck ने इसे रोक दिया। DroidDeck से बाहर के फ़ोल्डर सिंक करने के लिए Syncthing-Fork ऐप इस्तेमाल करें। + Syncthing से हटाएं + इसे हटाने से कोई फ़ाइल डिलीट नहीं होती। + डिवाइस + अभी कोई डिवाइस नहीं। + कनेक्टेड + ऑफ़लाइन + आख़िरी बार देखा गया: %1$s + इस डिवाइस की ID + अपने दूसरे डिवाइस पर Syncthing में एक डिवाइस जोड़ें और यह कोड स्कैन करें या ID पेस्ट करें। फिर यहां वेब UI में उसे स्वीकार करें। + कॉपी करें + शेयर करें + पूरा हुआ + Syncthing बंद करें? + Syncthing अभी रुक जाएगा और दोबारा चालू करने तक बंद रहेगा। सिंक की गई फ़ाइलें जहां हैं वहीं रहेंगी। + Syncthing का कॉन्फ़िग भी डिलीट करें + इस डिवाइस की ID, फ़ोल्डर और डिवाइस हटा देता है। दोबारा चालू करने पर सब नए सिरे से शुरू होगा। + बंद करें + पोर्ट + सिंक + लोकल डिस्कवरी + नए पोर्ट चुनें + हर बार शुरू होने पर फिर से जांचा जाता है। + फिर से चुनें + रीसेट + API कुंजी + फिर से बनाएं + Syncthing रीसेट करें + नई डिवाइस ID, फ़ोल्डर और डिवाइस हटा दिए जाते हैं। DroidDeck के स्टोरेज की फ़ाइलें रखी जाती हैं। + रीसेट करें + Syncthing रीसेट करें? + चलना + चलाएं + जब DroidDeck खुला हो + बैकग्राउंड में + सिर्फ़ Wi-Fi + गेम के दौरान रोकें + लॉग + इस सेशन का Syncthing आउटपुट + शेयर करें + Syncthing + पहले Linux रनटाइम सेट अप करें। + स्वीकार करें + “%1$s” यहां सेव करें + फ़ोल्डर जोड़ें + DroidDeck के स्टोरेज का कोई फ़ोल्डर सिंक करें + चुनें + जोड़ें + फ़ोल्डर नहीं जोड़ा जा सका। + इनके साथ शेयर करें + अभी कोई डिवाइस नहीं। आप बाद में वेब UI से यह फ़ोल्डर शेयर कर सकते हैं। + यह फ़ोल्डर इस्तेमाल करें + नया फ़ोल्डर + कंटेनर होम + Sync फ़ोल्डर + DroidDeck शेयर्ड स्टोरेज + %1$s पर DroidDeck + DroidDeck ऐप डेटा + नए फ़ोल्डर DroidDeck के Sync फ़ोल्डर में सेव होते हैं। DroidDeck का कोई दूसरा फ़ोल्डर सिंक करने के लिए Syncthing टैब पर फ़ोल्डर जोड़ें इस्तेमाल करें। + Syncthing + रीलोड करें + Syncthing नहीं चल रहा है। Steam एक अवैध निर्देश (SIGILL) के साथ रुक गया। यह CPU निर्देश कम्पैटिबिलिटी की समस्या या क्लाइंट क्रैश हो सकता है। अपने डिवाइस की जानकारी के साथ लॉग शेयर करें। आप Steam सेटिंग → Steam क्लाइंट → x86-64 (प्रयोगात्मक) आज़मा सकते हैं। इसके लिए अतिरिक्त डाउनलोड चाहिए और यह धीमा चल सकता है। Linux ग्राफ़िक्स ड्राइवर को इस्तेमाल लायक Vulkan डिवाइस नहीं मिला। जांचें कि चुना गया Linux GPU ड्राइवर इस GPU का समर्थन करता है। diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 98c3a80d3..1224ed131 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -277,7 +277,7 @@ Méret Átlátszatlanság Gomb - Trigger + Ravasz Váltás Exportálás Kapszula @@ -299,6 +299,8 @@ Xbox 360 kontroller Deck: Gyors hozzáférés gomb és gyroszkóp. Steam Controller: ugyanez, a hátsó gombok fogásérzékelésével. DualShock 4 és DualSense: gyroszkóp, és az érintőpadok mint touchpad. Xbox 360: a Gyors hozzáférés megnyitása Guide+A-val. Vissza gomb + Vissza nyomva tartása Steam parancsokhoz + A Vissza nyomva tartása megnyitja a Gyors elérést, vagy más gombokkal Steam kombinációkat ad. Kapcsold ki, hogy a nyomva tartást figyelmen kívül hagyja; a koppintás és dupla koppintás továbbra is működik. Tartsa lenyomva a Vissza gombot 700 ms-ig, vagy tartsa lenyomva a Vissza gombot és nyomjon meg egy másik gombot a Steam-parancsikonokhoz. Dupla koppintás időablaka %1$d ms @@ -654,6 +656,8 @@ Sáv Rögzítés a helyén Húzd a HUD-ot az áthelyezéshez; hosszú nyomással rögzítheted vagy feloldhatod. Rögzítve is működnek az alatta lévő képernyős vezérlők. + Igazítás az oldalakhoz + Engedd el a HUD-ot, és a képernyő közelebbi oldalára ugrik. Egy gyors pöccintés abba az irányba dobja. Megjelenés Méret a képernyőn Lágy @@ -1772,6 +1776,93 @@ Billentyűzet + érintőpad Terminál Deck fogók + érintőpadok + Irányítópult + Itt még nincs mit megjeleníteni. + Letöltések + Nincs folyamatban lévő letöltés. + Steam + Shader-gyorsítótár letöltése + Szüneteltetve + Indításra vár + Értesítések + DroidDeck alsó képernyő + Itt jelenítheti meg az üzeneteket és más értesítéseket játék közben. Csak ezen a képernyőn láthatók, és nem kerülnek mentésre. Az Android egyszer kérdez rá: az értesítési hozzáférés oldala ezen a képernyőn nyílik meg; kapcsolja be ott a DroidDecket, és ez az oldal magától visszatér. + Értesítési hozzáférés engedélyezése + Nincsenek értesítések. + %1$d értesítés + Törlés + Képernyőképek + Az FPS-hez be kell kapcsolni a HUD-ot + Sebesség + Pedálok + G-erő + Időmérés + Legjobb kör + Gumik °C + Gáz és fék, utolsó 10 s + Sebesség, %1$s + Fokozat + Sebesség mértékegysége + Automatikus (%1$s) + Steam-kliens ARM + Steam-kliens x86 + Gáz + Fék + Műszerfal + Váltás + Kézifék + Bokszutcai limiter + TC + ABS + Turbó %1$s bar + Motor %1$s °C + Olaj %1$s °C + Energiaelosztó + Üzemanyag, t + Fő %1$s t + Tartalék %1$s t + Koppintson a felső sávra vagy a műszerfalra a stílus módosításához. + Műszerfal-adatok küldése + Automatikus + Bekapcsolja az itt felsorolt összes játéknál, a később talált játékokat is beleértve. A kikapcsolt játékok kikapcsolva maradnak. + Ez a játék saját adatkimeneti beállítását módosítja. A játék következő indításakor lép érvénybe; kikapcsoláskor visszaáll a régi beállítás. + A módosításhoz zárja be ezt: %1$s. + Nem sikerült módosítani a következő beállításait: %1$s. + %1$s: kapcsolja be a(z) %2$s opciót a játék beállításaiban, 127.0.0.1 IP-címmel és %3$d porttal. + Mentés helye: Pictures/DroidDeck. A Steam-képernyőképek is itt jelennek meg. + Képernyőkép készítése + Képernyőkép mentve. + Nem sikerült képernyőképet készíteni. + Még nincsenek képernyőképek. + Csevegés és hang + Itt olvashatja a Steam-csevegést és válaszolhat rá, miközben a játék tovább fut a fő képernyőn. Azt a fiókot használja, amellyel a Steambe bejelentkezett. + A Steam még nem rendelkezik megosztható webes bejelentkezéssel, ezért egyszer jelentkezzen be itt. + Az Android bezárta a csevegőoldalt, amíg a DroidDeck a háttérben volt. Újra megnyílik, amikor visszatér a DroidDeckhez. + Steam Chat megnyitása + Csevegés bezárása + Mikrofon be + Mikrofon ki + A mikrofon ki van kapcsolva ennél a játéknál. A beszédhez kapcsolja be a Mikrofon opciót a DroidDeck Steam-beállításaiban (Hang), majd indítsa újra a játékot. + Hangerő %1$d%% + Játékon belüli statisztikák + FPS + CPU + GPU + Akkumulátor + Kör + Helyezés + Aktuális kör + Előző kör + Üzemanyag + Rakomány + Pontok SYS · ENG · WEP + Jogi státusz + Egyenleg + War Thunder + Elite Dangerous + Kapcsolja be egy autóversenyes játék adatkimenetét (gyakran telemetria vagy UDP néven szerepel), hogy itt élő műszerfalat lásson. Az adatok ezen az eszközön maradnak. + A(z) %1$d port adatokat fogad (%2$d csomag, egyenként %3$d bájt), de olyan formátumban, amelyet ez az oldal még nem tud olvasni. + A(z) %1$s portot már egy másik program használja, ezért az a játék itt nem jeleníthető meg. Linux futtatókörnyezet eltávolítása @@ -2102,6 +2193,7 @@ %1$s A méretezést erre állítja: %2$s. Képernyőhöz illesztés (%1$d×%2$d) Képernyő követése (átméreteződik össze- vagy kinyitáskor) + Skálázva %1$s Steam Eszközrészletek megjelenítése Eszközrészletek elrejtése @@ -3241,6 +3333,113 @@ Az elmentett nézetek újraszámíthatók. Indítson el egy új kalibrálást, hogy rögzítse az aktuális követési koordináta-rendszerben. A kézi modell ellenőrzőösszegének eltérése Az OpenCV betöltése nem sikerült + Syncthing + Syncthing + Szinkronizálás Syncthinggel + Ez a Syncthing-Forkon alapuló Syncthing-megvalósítás lehetővé teszi, hogy a DroidDeck belső fájlrendszerében lévő fájlokat más Syncthing-példányokkal szinkronizálja. Csak a DroidDeck belső fájlrendszerében tárolt fájlokra vonatkozik. + Ez a kiadás nem tartalmazza a Syncthinget. + A Syncthing indítása + Indítás + Naprakész + Szinkronizálás + Szüneteltetve + Szüneteltetés + Folytatás + Beavatkozást igényel + A Syncthing leállt + Újrapróbálás + Szüneteltetve Wi-Fi-kapcsolatig + Szüneteltetve a játék alatt + %1$d mappa · %2$d/%3$d eszköz csatlakoztatva + Még %1$s + Webes felület megnyitása + Szüneteltetés + Folytatás + Fut + Kapcsolja ki a Syncthing teljes leállításához. + Ez az eszköz + Eszközazonosító + QR-kód megjelenítése + Speciális + Portok, futtatás ideje, visszaállítás + Megnyitás + Önre vár + %1$s csatlakozni szeretne + %1$s meg szeretné osztani ezt: „%2$s” + Fogadja el, hogy kiválaszthassa, hová mentse a DroidDeckben. + Áttekintés a webes felületen + Mappák + Még nincsenek mappák. + %1$s · %2$s + Naprakész + Szinkronizálás: %1$d%% + Vizsgálat + Szüneteltetve + Nincs szinkronban + %1$d fájlt nem sikerült szinkronizálni + Letiltva + Ez a mappa a DroidDeck tárhelyén kívül van, ezért a DroidDeck szüneteltette. A DroidDecken kívüli mappák szinkronizálásához használja a Syncthing-Fork alkalmazást. + Eltávolítás a Syncthingből + Az eltávolítás nem töröl fájlokat. + Eszközök + Még nincsenek eszközök. + Csatlakoztatva + Offline + Utoljára látva: %1$s + Az eszköz azonosítója + A másik eszközén adjon hozzá egy eszközt a Syncthingben, és olvassa be ezt a kódot, vagy illessze be az azonosítót. Ezután fogadja el itt, a webes felületen. + Másolás + Megosztás + Kész + Kikapcsolja a Syncthinget? + A Syncthing most leáll, és leállítva marad, amíg újra be nem kapcsolja. A szinkronizált fájlok a helyükön maradnak. + A Syncthing konfigurációjának törlése is + Eltávolítja az eszköz azonosítóját, a mappákat és az eszközöket. Újbóli bekapcsoláskor elölről kezdi. + Kikapcsolás + Portok + Szinkronizálás + Helyi felfedezés + Új portok választása + Minden indításkor újra ellenőrizve. + Újraválasztás + Visszaállítás + API-kulcs + Újragenerálás + A Syncthing visszaállítása + Új eszközazonosító, a mappák és eszközök eltávolítva. A DroidDeck tárhelyén lévő fájlok megmaradnak. + Visszaállítás + Visszaállítja a Syncthinget? + Futtatás + Futtatás + Amíg a DroidDeck nyitva van + A háttérben + Csak Wi-Fi + Szüneteltetés játék közben + Naplók + A Syncthing kimenete ebben a munkamenetben + Megosztás + Syncthing + Előbb állítsa be a Linux futtatókörnyezetet. + Elfogadás + „%1$s” mentése ide: + Mappa hozzáadása + Egy mappa szinkronizálása a DroidDeck tárhelyén + Kiválasztás + Hozzáadás + Nem sikerült hozzáadni a mappát. + Megosztás ezekkel: + Még nincsenek eszközök. Ezt a mappát később a webes felületről is megoszthatja. + Ennek a mappának a használata + Új mappa + Konténer saját mappája + Sync mappa + DroidDeck megosztott tárhely + DroidDeck itt: %1$s + DroidDeck alkalmazásadatok + Az új mappák a DroidDeck Sync mappájába kerülnek. Másik DroidDeck-mappa szinkronizálásához használja a Mappa hozzáadása lehetőséget a Syncthing lapon. + Syncthing + Újratöltés + A Syncthing nem fut. A Steam érvénytelen utasítással leállt (SIGILL). Ez CPU-utasítás-kompatibilitási probléma vagy a kliens összeomlása lehet. Ossza meg a naplókat az eszköz adataival együtt. Kipróbálhatja a Steam-beállítások → Steam-kliens → x86-64 (kísérleti) lehetőséget. További letöltést igényel, és lassabban futhat. A Linux grafikus illesztőprogram nem talált használható Vulkan-eszközt. Ellenőrizze, hogy a kiválasztott Linux GPU-illesztőprogram támogatja-e ezt a GPU-t. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index a2fdf36ec..c8e41d480 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -299,6 +299,8 @@ Pengontrol Xbox 360 Deck: tombol Quick Access dan giroskop. Steam Controller: sama, dengan grip sense pada tombol belakang. DualShock 4 dan DualSense: giroskop, dan trackpad sebagai touchpad-nya. Xbox 360: buka Quick Access dengan Guide+A. Kembali + Tahan Kembali untuk pintasan Steam + Menahan Kembali membuka Akses Cepat atau membuat kombinasi Steam dengan tombol lain. Matikan untuk mengabaikan Kembali yang ditahan; ketuk dan ketuk dua kali tetap berfungsi. Tahan Back selama 700 ms, atau tahan Back dan tekan tombol lain untuk pintasan Steam. Jeda ketuk dua kali %1$d ms @@ -652,6 +654,8 @@ Bilah Kunci posisi Seret HUD untuk memindahkannya; tekan lama untuk mengunci atau membuka kuncinya. Saat terkunci, kontrol layar di bawahnya tetap berfungsi. + Tempel ke sisi + Lepaskan HUD dan HUD akan pindah ke sisi layar yang lebih dekat. Jentikan cepat melemparnya ke arah itu. Tampilan Ukuran di layar Lembut @@ -1750,6 +1754,93 @@ Keyboard + trackpad Terminal Pegangan Deck + trackpad + Dasbor + Belum ada yang bisa ditampilkan di sini. + Unduhan + Tidak ada unduhan yang berjalan. + Steam + Mengunduh cache shader + Dijeda + Menunggu dimulai + Notifikasi + Layar bawah DroidDeck + Tampilkan pesan dan notifikasi lain di sini saat Anda bermain. Notifikasi hanya muncul di layar ini dan tidak disimpan. Android hanya meminta sekali: halaman akses notifikasinya terbuka di layar ini; aktifkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya. + Izinkan akses notifikasi + Tidak ada notifikasi. + %1$d notifikasi + Hapus + Tangkapan layar + FPS memerlukan HUD aktif + Kecepatan + Pedal + Gaya G + Waktu + Lap terbaik + Ban °C + Gas dan rem, 10 s terakhir + Kecepatan, %1$s + Gigi + Satuan kecepatan + Otomatis (%1$s) + Klien Steam ARM + Klien Steam x86 + Gas + Rem + Lampu indikator + Pindah gigi + Rem tangan + Pembatas pit + TC + ABS + Turbo %1$s bar + Mesin %1$s °C + Oli %1$s °C + Distributor daya + Bahan bakar, t + Utama %1$s t + Cadangan %1$s t + Ketuk bilah atas atau dasbor untuk mengubah gayanya. + Kirim data dasbor + Otomatis + Mengaktifkannya untuk setiap gim yang tercantum di sini, termasuk gim yang ditemukan nanti. Gim yang Anda matikan tetap mati. + Ini mengubah setelan keluaran data milik gim itu sendiri. Perubahan berlaku saat gim dimulai berikutnya; mematikannya mengembalikan setelan lama. + Tutup %1$s untuk mengubah ini. + Tidak dapat mengubah setelan %1$s. + %1$s: aktifkan %2$s di setelan gim dengan IP 127.0.0.1 dan port %3$d. + Disimpan ke Pictures/DroidDeck. Tangkapan layar Steam juga muncul di sini. + Ambil tangkapan layar + Tangkapan layar disimpan. + Tidak dapat mengambil tangkapan layar. + Belum ada tangkapan layar. + Obrolan & suara + Baca dan balas obrolan Steam di sini sementara gim tetap berjalan di layar utama. Fitur ini menggunakan akun yang dipakai untuk masuk ke Steam. + Steam belum memiliki info masuk web untuk dibagikan, jadi masuklah di sini sekali. + Android menutup halaman obrolan saat DroidDeck berada di latar belakang. Halaman akan terbuka lagi saat Anda kembali ke DroidDeck. + Buka Steam Chat + Tutup obrolan + Mik aktif + Mik mati + Mik mati untuk gim ini. Untuk berbicara, aktifkan Mikrofon di setelan Steam DroidDeck (Audio) lalu mulai gim lagi. + Volume %1$d%% + Statistik dalam gim + FPS + CPU + GPU + Baterai + Lap + Posisi + Lap ini + Lap terakhir + Bahan bakar + Kargo + Pip SYS · ENG · WEP + Status hukum + Saldo + War Thunder + Elite Dangerous + Aktifkan keluaran data gim balap (sering disebut telemetri atau UDP) untuk melihat dasbor langsung di sini. Data tetap berada di perangkat ini. + Port %1$d menerima data (%2$d paket berukuran %3$d byte), tetapi dalam format yang belum dapat dibaca halaman ini. + Program lain sudah menggunakan port %1$s, jadi gim tersebut tidak dapat ditampilkan di sini. Menghapus runtime Linux @@ -2079,6 +2170,7 @@ %1$s Mengatur skala ke %2$s. Sesuaikan layar (%1$d×%2$d) Ikuti layar (ukuran berubah saat dilipat atau dibuka) + Diskalakan %1$s Steam Tampilkan detail perangkat Sembunyikan detail perangkat @@ -2602,7 +2694,7 @@ Game di kacamata Tampilkan game hanya di kacamata. Nonaktif: ponsel menampilkannya dan Android mencerminkan layar ponsel. Tampilan - Refresh rate + Kecepatan refresh 3D (berdampingan) Alihkan kacamata pintar ke 3D berdampingan. Pelacakan kepala dikontrol secara terpisah. Sinyal 3D belum terkonfirmasi. Coba lagi setelah gambar 2D kembali. @@ -3216,6 +3308,113 @@ Tampilan yang disimpan dapat dihitung ulang. Mulai kalibrasi baru untuk menangkap sistem koordinat pelacakan saat ini. Ketidakcocokan checksum model tangan OpenCV tidak dapat memuat + Syncthing + Syncthing + Sinkronkan dengan Syncthing + Berbasis Syncthing-Fork, ini adalah implementasi Syncthing yang memungkinkan Anda menyinkronkan file di sistem file internal DroidDeck dengan instans Syncthing lain. Ini hanya untuk file yang Anda simpan di dalam sistem file internal DroidDeck. + Build ini tidak menyertakan Syncthing. + Memulai Syncthing + Memulai + Terbaru + Menyinkronkan + Dijeda + Menjeda + Melanjutkan + Perlu perhatian + Syncthing berhenti + Coba lagi + Dijeda hingga ada Wi-Fi + Dijeda selama gim berjalan + %1$d folder · %2$d dari %3$d perangkat terhubung + %1$s lagi + Buka UI web + Jeda + Lanjutkan + Berjalan + Matikan untuk menghentikan Syncthing sepenuhnya. + Perangkat ini + ID Perangkat + Tampilkan QR + Lanjutan + Port, kapan berjalan, atur ulang + Buka + Menunggu Anda + %1$s ingin terhubung + %1$s ingin berbagi “%2$s” + Terima untuk memilih lokasi penyimpanannya di DroidDeck. + Tinjau di UI web + Folder + Belum ada folder. + %1$s · %2$s + Terbaru + Menyinkronkan %1$d%% + Memindai + Dijeda + Tidak sinkron + %1$d file tidak dapat disinkronkan + Diblokir + Folder ini berada di luar penyimpanan DroidDeck, jadi DroidDeck menjedanya. Untuk menyinkronkan folder di luar DroidDeck, gunakan aplikasi Syncthing-Fork. + Hapus dari Syncthing + Menghapusnya tidak akan menghapus file apa pun. + Perangkat + Belum ada perangkat. + Terhubung + Offline + Terakhir terlihat %1$s + ID perangkat ini + Di perangkat Anda yang lain, tambahkan perangkat di Syncthing lalu pindai kode ini atau tempel ID-nya. Kemudian terima di sini melalui UI web. + Salin + Bagikan + Selesai + Matikan Syncthing? + Syncthing berhenti sekarang dan tetap berhenti hingga dinyalakan kembali. File yang telah disinkronkan tetap di tempatnya. + Hapus juga konfigurasi Syncthing + Menghapus ID, folder, dan perangkat milik perangkat ini. Menyalakannya lagi akan memulai dari awal. + Matikan + Port + Sinkronisasi + Penemuan lokal + Pilih port baru + Diperiksa lagi setiap kali dimulai. + Pilih ulang + Atur ulang + Kunci API + Buat ulang + Atur ulang Syncthing + ID perangkat baru, folder dan perangkat dihapus. File di penyimpanan DroidDeck tetap disimpan. + Atur ulang + Atur ulang Syncthing? + Berjalan + Jalankan + Saat DroidDeck terbuka + Di latar belakang + Hanya Wi-Fi + Jeda selama bermain gim + Log + Output Syncthing untuk sesi ini + Bagikan + Syncthing + Siapkan runtime Linux terlebih dahulu. + Terima + Simpan “%1$s” ke + Tambah folder + Sinkronkan folder di penyimpanan DroidDeck + Pilih + Tambah + Folder tidak dapat ditambahkan. + Bagikan dengan + Belum ada perangkat. Anda dapat membagikan folder ini dari UI web nanti. + Gunakan folder ini + Folder baru + Folder home kontainer + Folder Sync + Penyimpanan bersama DroidDeck + DroidDeck di %1$s + Data aplikasi DroidDeck + Folder baru disimpan di folder Sync DroidDeck. Untuk menyinkronkan folder DroidDeck lain, gunakan Tambah folder di tab Syncthing. + Syncthing + Muat ulang + Syncthing tidak berjalan. Steam berhenti karena instruksi ilegal (SIGILL). Ini bisa berupa masalah kompatibilitas instruksi CPU atau klien mengalami crash. Bagikan log beserta detail perangkat Anda. Anda dapat mencoba Setelan Steam → Klien Steam → x86-64 (eksperimental). Ini memerlukan unduhan tambahan dan mungkin berjalan lebih lambat. Driver grafis Linux tidak dapat menemukan perangkat Vulkan yang dapat digunakan. Pastikan driver GPU Linux yang dipilih mendukung GPU ini. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b1cc2f9f1..e5f0ed49a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -299,6 +299,8 @@ Controller Xbox 360 Deck: tasto Accesso rapido e giroscopio. Steam Controller: lo stesso, con il rilevamento della presa sui tasti posteriori. DualShock 4 e DualSense: giroscopio, con i trackpad usati come touchpad. Xbox 360: apri Accesso rapido con Guide+A. Indietro + Tieni premuto Indietro per le scorciatoie Steam + Tenere premuto Indietro apre l’Accesso rapido o crea combinazioni Steam con altri tasti. Disattiva per ignorare la pressione prolungata; tocco e doppio tocco funzionano ancora. Tieni premuto Indietro per 700 ms, oppure tieni premuto Indietro e premi un altro tasto per le scorciatoie di Steam. Intervallo doppio tocco %1$d ms @@ -656,6 +658,8 @@ Barra Blocca in posizione Trascina l\'HUD per spostarlo; una pressione prolungata lo blocca o lo sblocca. Mentre è bloccato, i controlli su schermo sotto di esso continuano a funzionare. + Aggancia ai lati + Rilascia l\'HUD e si sposta sul lato più vicino dello schermo. Un gesto rapido lo lancia in quella direzione. Aspetto Dimensione sullo schermo Tenue @@ -1786,6 +1790,93 @@ Tastiera + trackpad Terminale Impugnature Deck + trackpad + Dashboard + Ancora niente da mostrare qui. + Download + Nessun download in corso. + Steam + Download della cache degli shader + In pausa + In attesa di avvio + Notifiche + Schermo inferiore DroidDeck + Mostra qui messaggi e altre notifiche mentre giochi. Restano solo su questo schermo e non vengono salvate. Android lo chiede una sola volta: la sua pagina di accesso alle notifiche si apre su questo schermo; attiva DroidDeck lì e questa pagina torna da sola. + Consenti accesso alle notifiche + Nessuna notifica. + %1$d notifiche + Cancella + Screenshot + Gli FPS richiedono l\'HUD attivo + Velocità + Pedali + Forza G + Tempi + Giro migliore + Pneumatici °C + Acceleratore e freno, ultimi 10 s + Velocità, %1$s + Marcia + Unità di velocità + Automatico (%1$s) + Client Steam ARM + Client Steam x86 + Acceleratore + Freno + Spie + Cambio marcia + Freno a mano + Limitatore pit + TC + ABS + Turbo %1$s bar + Motore %1$s °C + Olio %1$s °C + Distribuzione energia + Carburante, t + Principale %1$s t + Riserva %1$s t + Tocca la barra superiore o il cruscotto per cambiarne lo stile. + Invia dati al cruscotto + Automatico + Lo attiva per ogni gioco elencato qui, compresi i giochi trovati in seguito. I giochi che disattivi restano disattivati. + Modifica l\'impostazione di output dati del gioco stesso. Ha effetto al prossimo avvio del gioco; disattivandola viene ripristinata l\'impostazione precedente. + Chiudi %1$s per modificarlo. + Impossibile modificare le impostazioni di %1$s. + %1$s: attiva %2$s nelle impostazioni del gioco con IP 127.0.0.1 e porta %3$d. + Salvati in Pictures/DroidDeck. Qui compaiono anche gli screenshot di Steam. + Acquisisci screenshot + Screenshot salvato. + Impossibile acquisire uno screenshot. + Ancora nessuno screenshot. + Chat & voce + Leggi e rispondi alla chat di Steam qui mentre il gioco continua sullo schermo principale. Usa l\'account con cui è stato effettuato l\'accesso a Steam. + Steam non ha ancora un accesso web da condividere, quindi accedi qui una volta. + Android ha chiuso la pagina della chat mentre DroidDeck era in background. Si riapre quando torni su DroidDeck. + Apri Steam Chat + Chiudi chat + Microfono attivo + Microfono disattivato + Il microfono è disattivato per questo gioco. Per parlare, attiva Microfono nelle impostazioni Steam di DroidDeck (Audio) e riavvia il gioco. + Volume %1$d%% + Statistiche di gioco + FPS + CPU + GPU + Batteria + Giro + Posizione + Giro attuale + Ultimo giro + Carburante + Carico + Pip SYS · ENG · WEP + Stato legale + Saldo + War Thunder + Elite Dangerous + Attiva l\'output dati di un gioco di corse (spesso chiamato telemetria o UDP) per vedere qui un cruscotto in tempo reale. I dati restano su questo dispositivo. + La porta %1$d riceve dati (%2$d pacchetti da %3$d byte), ma in un formato che questa pagina non sa ancora leggere. + Un altro programma usa già la porta %1$s, quindi quel gioco non può essere mostrato qui. Rimozione del runtime Linux @@ -2117,6 +2208,7 @@ %1$s Imposta il ridimensionamento su %2$s. Adatta allo schermo (%1$d×%2$d) Segui lo schermo (si ridimensiona quando lo pieghi o lo apri) + Scalato %1$s Steam Mostra dettagli dispositivo Nascondi dettagli dispositivo @@ -3277,6 +3369,113 @@ Le visualizzazioni salvate possono essere ricalcolate. Avvia una nuova calibrazione per acquisire il sistema di coordinate di tracciamento corrente. Mancata corrispondenza del checksum del modello di mano Impossibile caricare OpenCV + Syncthing + Syncthing + Sincronizza con Syncthing + Basata su Syncthing-Fork, è un\'implementazione di Syncthing che ti permette di sincronizzare i file del file system interno di DroidDeck con altre istanze di Syncthing. Vale solo per i file che conservi all\'interno del file system interno di DroidDeck. + Questa build non include Syncthing. + Avvio di Syncthing + Avvio in corso + Aggiornato + Sincronizzazione + In pausa + Messa in pausa + Ripresa in corso + Richiede attenzione + Syncthing si è fermato + Riprova + In pausa fino al Wi-Fi + In pausa durante il gioco + %1$d cartelle · %2$d di %3$d dispositivi connessi + %1$s rimanenti + Apri l\'interfaccia web + Pausa + Riprendi + In esecuzione + Disattiva per fermare completamente Syncthing. + Questo dispositivo + ID dispositivo + Mostra QR + Avanzate + Porte, quando eseguire, ripristino + Apri + In attesa di te + %1$s vuole connettersi + %1$s vuole condividere “%2$s” + Accetta per scegliere dove salvarla in DroidDeck. + Controlla nell\'interfaccia web + Cartelle + Ancora nessuna cartella. + %1$s · %2$s + Aggiornata + Sincronizzazione %1$d%% + Scansione + In pausa + Non sincronizzata + Impossibile sincronizzare %1$d file + Bloccata + Questa cartella è fuori dall\'archiviazione di DroidDeck, quindi DroidDeck l\'ha messa in pausa. Per sincronizzare cartelle fuori da DroidDeck, usa l\'app Syncthing-Fork. + Rimuovi da Syncthing + La rimozione non elimina alcun file. + Dispositivi + Ancora nessun dispositivo. + Connesso + Offline + Ultima connessione %1$s + ID di questo dispositivo + Sull\'altro dispositivo, aggiungi un dispositivo in Syncthing e scansiona questo codice o incolla l\'ID. Poi accettalo qui nell\'interfaccia web. + Copia + Condividi + Fatto + Disattivare Syncthing? + Syncthing si ferma ora e resta fermo finché non lo riattivi. I file sincronizzati restano dove sono. + Elimina anche la configurazione di Syncthing + Rimuove l\'ID, le cartelle e i dispositivi di questo dispositivo. Riattivandolo si riparte da zero. + Disattiva + Porte + Sincronizzazione + Rilevamento locale + Scegli nuove porte + Ricontrollate a ogni avvio. + Riscegli + Ripristino + Chiave API + Rigenera + Ripristina Syncthing + Nuovo ID dispositivo, cartelle e dispositivi rimossi. I file nell\'archiviazione di DroidDeck vengono mantenuti. + Ripristina + Ripristinare Syncthing? + Esecuzione + Esegui + Mentre DroidDeck è aperto + In background + Solo Wi-Fi + Pausa durante i giochi + Log + Output di Syncthing per questa sessione + Condividi + Syncthing + Configura prima il runtime Linux. + Accetta + Salva “%1$s” in + Aggiungi cartella + Sincronizza una cartella nell\'archiviazione di DroidDeck + Scegli + Aggiungi + Impossibile aggiungere la cartella. + Condividi con + Ancora nessun dispositivo. Potrai condividere questa cartella dall\'interfaccia web in seguito. + Usa questa cartella + Nuova cartella + Home del container + Cartella Sync + Archiviazione condivisa di DroidDeck + DroidDeck su %1$s + Dati dell\'app DroidDeck + Le nuove cartelle vengono salvate nella cartella Sync di DroidDeck. Per sincronizzare un\'altra cartella di DroidDeck, usa Aggiungi cartella nella scheda Syncthing. + Syncthing + Ricarica + Syncthing non è in esecuzione. Steam si è arrestato con un\'istruzione non valida (SIGILL). Può trattarsi di un problema di compatibilità delle istruzioni della CPU o di un crash del client. Condividi i log con i dettagli del tuo dispositivo. Puoi provare Impostazioni di Steam → Client Steam → x86-64 (sperimentale). Richiede un download aggiuntivo e potrebbe essere più lento. Il driver grafico Linux non ha trovato un dispositivo Vulkan utilizzabile. Verifica che il driver GPU Linux selezionato supporti questa GPU. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 01f717e30..0cf7c75de 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -88,6 +88,7 @@ 1に設定すると、このゲームでdroiddeck-fsyncを使用しません。パックがある場合はdroiddeck-esync、ない場合はdroiddeck-ntsyncを使用します。 1に設定するとこのゲームをdroiddeck-fsyncで実行します。0に設定すると、Protonに合うパックがある場合はdroiddeck-esyncを優先します。 0に設定すると、このゲームのProtonコントローラーナビゲーション補助を無効にします。 + デフォルトでオンです。ゲームがオフラインプレイに対応している場合、Easy Anti-Cheatのゲームをアンチチートなしで起動します。オフにすると、通常どおりEasy Anti-Cheat経由で起動します。Easy Anti-Cheatが必要なオンラインモードは引き続き利用できません。 D3D9~11のDXVKオーバーレイ。fpsはフレームレートを表示します。設定を解除するとオーバーレイが非表示になります。 セミコロンで区切られたDXVK設定。最近のバージョンではdxvk.maxFrameRateでフレームレートを制限します。通常のプレイにはセッションフレームレートリミッターを推奨します。 このゲームで使用するCPUコア(例:0-4、0,2,4)。セッションのゲームコアの代わりに使用されます。コアが少ないほど発熱が抑えられます。 @@ -298,6 +299,8 @@ Xbox 360コントローラー Deck:クイックアクセスボタンとジャイロを提供します。Xbox 360:Guide+Aでクイックアクセスを開きます。 戻る + 戻る長押しでSteamショートカット + 戻るを長押しするとクイックアクセスを開くか、他のボタンとSteamの同時押しになります。オフにすると長押しを無視します。タップとダブルタップは引き続き使えます。 戻るを700ミリ秒長押しするか、戻るを押したまま別のボタンを押すとSteamのショートカットを使用できます。 ダブルタップの間隔 %1$dミリ秒 @@ -326,6 +329,12 @@ 最新です · %1$s ローダー 最新バージョンをインストール + アップデートチャンネル + インストールするDecky Loaderのリリースです。Decky自身のアップデーターも同じチャンネルを使用します。 + 安定版 + プレリリース版 + テスト版 + Deckyのアップデートチャンネルを保存できませんでした Steamと共に起動します。有効にすると、このデバイス上の他のアプリがSteamを制御できる場合があります。 オフ。Steamのリモートデバッグポートは閉じたままになります。 Decky Loaderをアンインストールしますか? @@ -335,6 +344,8 @@ Deckモードでゲームがクラッシュしたり画面が真っ暗になる場合は、これをオフにしてください。次回のセッションで有効になります。 ゲーム終了後にDroidDeckへ戻る DroidDeckや他のランチャーから起動したゲームを閉じると、Steamが正しく終了し(クラウドセーブの同期を先に完了)、起動元に戻ります。オフ:Steamに留まります。 + シェーダーの事前キャッシュ + Steamがゲーム用にビルド済みのシェーダーをダウンロードしてバックグラウンドで処理するため、ゲームのカクつきが減ります。オフにするとデータ通信量、ストレージ、バッテリーを節約できますが、ゲームはプレイ中にシェーダーをビルドします。Steamの「ダウンロード」>「シェーダーの事前キャッシュ」と同じ設定です。次回のセッションから適用されます。 Steamクライアントを修復 次回Steamの起動時にSteamクライアントを再ダウンロードします。Steamが読み込み画面のまま進まない場合に使用してください。ゲーム、ログイン情報、設定は保持されます。 次回Steamの起動時にSteamクライアントを再ダウンロードします。 @@ -591,6 +602,8 @@ prootファストパス prootのseccompフィルターが必要です。 ファイル検索が速くなり、スタッターが減ります。プログラムがファイルを見つけられない場合は、これをオフにしてください。 + gamescope内でカーソルを描画 + ポインターをAndroidに任せず、Gamescope自身が描画します。コントローラーで操作するカーソルにはオンにしてください。スマートフォンではオフの方が高速です。次回のセッションから適用されます。 セッションの識別情報 ホスト名 Steamがこのデバイスを認識する名前。空にすると%1$sに戻ります。 @@ -641,6 +654,8 @@ バー 位置を固定 HUDはドラッグで移動できます。長押しで固定または解除します。固定中も、下にある画面上のコントロールは使えます。 + 左右に吸着 + HUD を離すと、画面の近い方の端に移動します。はじくとその方向へ飛びます。 外観 画面上のサイズ ソフト @@ -1180,6 +1195,9 @@ 他のGPUのドライバーを非表示 他のGPUのドライバーを表示 詳細設定 + UBWC互換性 + SteamやAndroidで縞模様やちらつきが出る場合は、オンまたはオフを試してください。DroidDeckの再起動後、両方のドライバーに適用されます。 + 適用するにはDroidDeckを再起動してください。 ディスプレイドライバーと一緒に設定:%1$s は両方を含みます。 ランタイムドライバーと一緒に設定:%1$s は両方を含みます。 ゲームとデスクトップで使用されます。 @@ -1288,6 +1306,8 @@ 自動でピクチャーインピクチャー 開いた状態の画面のコントロール 開いた状態では、ゲームがSteam Deckのトラックパッドと背面グリップの上に表示されます。折りたたんだ状態では、ゲームが全画面で表示されます。 + コントローラー接続時も表示 + コントローラーが接続されている間も、これらのタッチ操作を表示します。 一時停止中 セッションの準備ができたらピクチャーインピクチャーを使用できます。 Android設定でDroidDeckのピクチャーインピクチャーを許可してください。 @@ -1354,6 +1374,9 @@ インストールする Linux実行環境を削除 実行環境、その中のSteamクライアント、そこにインストールされたすべてのゲームが削除されます。 + Linux実行環境 + 「修復」は実行環境のシステムファイルを再インストールし、Steam、ゲーム、設定は保持します。デスクトップなどの追加機能は、次に使用するときに再度インストールされます。「削除」はすべてを消去します。 + 実行環境を修復 適用しました:%1$s メイン画面で%1$sを開けませんでした %2$sで%1$sを開けませんでした @@ -1480,6 +1503,10 @@ 実行環境のカタログに接続できませんでした。接続を確認して、もう一度Playをタップしてください。 Linux実行環境をインストールできませんでした。「設定」で実行環境を確認して、もう一度Playをタップしてください。 デスクトップをインストールできませんでした(%1$s)。接続を確認して、もう一度「デスクトップ」をタップしてください。 + 中断されたインストールを完了できませんでした(%1$s)。接続と空き容量を確認して、もう一度お試しください。 + + Linux実行環境が破損しています。%1$d個のライブラリリンクが存在しないファイルを指しています(%2$sなど)。通常はインストールが中断された後に発生します。修復してもゲームと設定は保持されます。 + %1$sが予期せず停止しました。バグレポートにログを添付して共有するか、もう一度お試しください。 プログラムが予期せず停止しました。バグレポートにログを添付して共有するか、もう一度お試しください。 デスクトップが予期せず停止しました。バグレポートにログを添付して共有するか、もう一度お試しください。 @@ -1493,6 +1520,12 @@ 次回のSteamセッション用にプラグインのzipをインストールします。 ダウンロード中… zipからインストール + サードパーティ製プラグインのインストール + このプラグインはDecky Loaderチームによる審査を受けていません。Steamアカウントを盗んだりデバイスに害を与えたりするソフトウェアなど、マルウェアが含まれている可能性があります。このプラグインをインストールすることで、デバイスに関するすべてのリスクを負うことに同意したものとみなされます。 + 続行 + + %1$d秒お待ちください + ゲーム オーディオ・ビデオ グラフィック @@ -1721,6 +1754,93 @@ キーボード + トラックパッド ターミナル Deckグリップ + トラックパッド + ダッシュボード + まだ表示するものはありません。 + ダウンロード + 実行中のダウンロードはありません。 + Steam + シェーダーキャッシュをダウンロード中 + 一時停止中 + 開始待ち + 通知 + DroidDeck下部画面 + プレイ中、メッセージなどの通知をここに表示します。通知はこの画面にのみ表示され、保存されません。Androidによる確認は一度だけです。この画面に通知へのアクセスのページが開くので、そこでDroidDeckをオンにすると、このページに自動的に戻ります。 + 通知へのアクセスを許可 + 通知はありません。 + %1$d件の通知 + クリア + スクリーンショット + FPSの表示にはHUDをオンにする必要があります + 速度 + ペダル + Gフォース + タイム + ベストラップ + タイヤ °C + スロットルとブレーキ(直近10 s) + 速度(%1$s) + ギア + 速度の単位 + 自動(%1$s) + Steamクライアント ARM + Steamクライアント x86 + スロットル + ブレーキ + インジケーター + シフト + サイドブレーキ + ピットリミッター + TC + ABS + ターボ %1$s bar + エンジン %1$s °C + 油温 %1$s °C + パワーディストリビューター + 燃料(t) + メイン %1$s t + 予備 %1$s t + 上部のバーまたはダッシュをタップすると、表示スタイルを変更できます。 + ダッシュボード用データを送信 + 自動 + ここに表示されているすべてのゲーム(後から見つかったゲームを含む)でオンにします。オフにしたゲームはオフのままです。 + ゲーム自体のデータ出力設定を変更します。次回ゲームを起動したときに反映されます。オフにすると元の設定に戻ります。 + 変更するには%1$sを終了してください。 + %1$sの設定を変更できませんでした。 + %1$s:ゲームの設定で%2$sをオンにし、IP 127.0.0.1、ポート%3$dを指定してください。 + Pictures/DroidDeckに保存されます。Steamのスクリーンショットもここに表示されます。 + スクリーンショットを撮る + スクリーンショットを保存しました。 + スクリーンショットを撮れませんでした。 + スクリーンショットはまだありません。 + チャット&ボイス + ゲームをメイン画面で実行したまま、ここでSteamチャットを読んで返信できます。Steamにサインインしているアカウントを使用します。 + Steamにはまだ共有できるWebサインインがないため、ここで一度サインインしてください。 + DroidDeckがバックグラウンドにある間に、Androidがチャットページを閉じました。DroidDeckに戻ると再度開きます。 + Steamチャットを開く + チャットを閉じる + マイク オン + マイク オフ + このゲームではマイクがオフです。話すには、DroidDeckのSteam設定(オーディオ)でマイクをオンにして、ゲームを再起動してください。 + 音量 %1$d%% + ゲーム内統計 + FPS + CPU + GPU + バッテリー + ラップ + 順位 + 現在のラップ + 前回のラップ + 燃料 + 積荷 + 電力配分 SYS · ENG · WEP + 法的状態 + 残高 + War Thunder + Elite Dangerous + レースゲームのデータ出力(テレメトリーやUDPと呼ばれることが多い)をオンにすると、ここにライブダッシュが表示されます。データはこのデバイス内にとどまります。 + ポート%1$dでデータを受信していますが(%3$dバイトのパケット%2$d個)、このページではまだ読み取れない形式です。 + 別のプログラムがすでにポート%1$sを使用しているため、そのゲームはここに表示できません。 Linux実行環境を削除中 @@ -1953,6 +2073,7 @@ Adreno GPUではありません サポート対象 実験的:ドライバーはテストビルドです + Adreno 850の実験的サポート:互換性のある実験版のディスプレイドライバーとゲームドライバーが必要です。このデバイスでは未検証です テスト済みハードウェア(Adreno 725以降)未満:動作しない可能性があります サポート対象外:DroidDeckにはAdreno(Snapdragon)GPUが必要です @@ -2049,6 +2170,7 @@ %1$s スケーリングを%2$sに設定します。 画面に合わせる(%1$d×%2$d) 画面に追従(開閉時にサイズを変更) + スケール %1$s Steam デバイスの詳細を表示 デバイスの詳細を非表示 @@ -2288,6 +2410,9 @@ ダウンロードマネージャー 同時ダウンロード数 インストール先 + ゲームの言語 + GOGとEpicはこの言語でインストールします。「システム」はアプリの言語に従います。 + システム %1$sをインストール: 内部ストレージ 空き %1$s @@ -2593,6 +2718,22 @@ 「自動」では、この 2D ゲームは視界の中で固定されます。ヘッドトラッキングを使うには、「表示」で「仮想スクリーン」を選んでください。 ヘッドトラッキングは、仮想スクリーンまたは実行中の SteamVR ゲームに適用されます。 デバイス + コントローラー + SteamVRコントローラー + VRでのゲームパッド + コントローラーが担当する2つのVRの手です。変更はSteamVRの実行中でもすぐに反映されます。Joy-Conやトラッキングされた手は、引き続きそれぞれの側を優先して操作します。 + 通常のゲームパッド + Valve Indexコントローラー + Meta Quest 3コントローラー + HTC Viveワンド + スティックの初期動作 + VR中に両方のスティックを同時に押し込むと切り替わります。 + ゲーム内で移動 + 手を動かす + ボタン + トリガーはトリガー、バンパーはグリップ、A/BとX/Yはそれぞれの手のフェイスボタン、スティック押し込みはサムスティックのクリック、ガイドボタンはSteamVRメニューを開きます。十字キーの上下で手を近づけたり遠ざけたりします。 + スティックはトラックパッドとして動作し、A/Xまたはスティック押し込みで押します。トリガーはトリガー、バンパーはグリップ、StartとSelectはメニュー、ガイドボタンはSteamVRメニューを開きます。 + VRの手はありません。ゲームパッドに対応したゲームとSteamVRメニューでは、通常どおりコントローラーを使用できます。 明るさ 音量 サンブロック @@ -2650,6 +2791,12 @@ カメラの位置 プレイ前に、カメラにあなたが映っているか確認してください。 確認 + ハンドトラッキングのダウンロード + ハンドトラッキングには%1$sの1回限りのダウンロードが必要です。 + ダウンロード + 再試行 + ハンドトラッキングをダウンロード中… %1$d%% + ハンドトラッキングをダウンロードできませんでした。接続を確認して、もう一度お試しください。 カメラの位置 デバイスを胸の高さくらいで1.5〜2m離れたところに立て、背面カメラがあなたの方を向くようにしてください。そのあと、手を広げて画面内に入ってください。 デバイスを胸の高さくらいで1.5〜2m離れたところに立て、画面があなたの方を向くようにしてください。そのあと、手を広げて画面内に入ってください。 @@ -3159,6 +3306,113 @@ 保存されたビューは再計算できます。新しいキャリブレーションを開始して、現在のトラッキング座標系でキャプチャします。 ハンドモデルのチェックサムの不一致 OpenCVをロードできませんでした + Syncthing + Syncthing + Syncthingで同期 + Syncthing-ForkをベースにしたSyncthingの実装で、DroidDeckの内部ファイルシステム内のファイルを他のSyncthingインスタンスと同期できます。DroidDeckの内部ファイルシステム内に保存しているファイル専用です。 + このビルドにはSyncthingが含まれていません。 + Syncthingを起動中 + 起動中 + 最新 + 同期中 + 一時停止中 + 一時停止しています + 再開しています + 要確認 + Syncthingが停止しました + 再試行 + Wi-Fi接続まで一時停止中 + ゲーム中は一時停止中 + %1$d個のフォルダ · %3$d台中%2$d台のデバイスが接続中 + 残り%1$s + Web UIを開く + 一時停止 + 再開 + 実行中 + オフにするとSyncthingを完全に停止します。 + このデバイス + デバイスID + QRコードを表示 + 詳細設定 + ポート、実行タイミング、リセット + 開く + 確認待ち + %1$sが接続を求めています + %1$sが「%2$s」を共有しようとしています + 承認すると、DroidDeck内の保存先を選択できます。 + Web UIで確認 + フォルダ + フォルダはまだありません。 + %1$s · %2$s + 最新 + 同期中 %1$d%% + スキャン中 + 一時停止中 + 未同期 + %1$d個のファイルを同期できませんでした + ブロック済み + このフォルダはDroidDeckのストレージ外にあるため、DroidDeckが一時停止しました。DroidDeck外のフォルダを同期するには、Syncthing-Forkアプリを使用してください。 + Syncthingから削除 + 削除してもファイルは消去されません。 + デバイス + デバイスはまだありません。 + 接続中 + オフライン + 最終接続:%1$s + このデバイスのID + もう一方のデバイスのSyncthingでデバイスを追加し、このコードをスキャンするかIDを貼り付けてください。その後、ここのWeb UIで承認してください。 + コピー + 共有 + 完了 + Syncthingをオフにしますか? + Syncthingはすぐに停止し、再びオンにするまで停止したままになります。同期済みのファイルはそのまま残ります。 + Syncthingの設定も削除 + このデバイスのID、フォルダ、デバイスを削除します。再びオンにすると最初からやり直しになります。 + オフにする + ポート + 同期 + ローカル探索 + 新しいポートを選択 + 起動のたびに再確認されます。 + 選び直す + リセット + APIキー + 再生成 + Syncthingをリセット + デバイスIDが新しくなり、フォルダとデバイスは削除されます。DroidDeckのストレージ内のファイルは保持されます。 + リセット + Syncthingをリセットしますか? + 実行 + 実行タイミング + DroidDeckを開いている間 + バックグラウンドで + Wi-Fiのみ + ゲーム中は一時停止 + ログ + このセッションのSyncthing出力 + 共有 + Syncthing + 先にLinux実行環境をセットアップしてください。 + 承認 + 「%1$s」の保存先 + フォルダを追加 + DroidDeckのストレージ内のフォルダを同期 + 選択 + 追加 + フォルダを追加できませんでした。 + 共有先 + デバイスはまだありません。このフォルダは後でWeb UIから共有できます。 + このフォルダを使用 + 新しいフォルダ + コンテナのホーム + Syncフォルダ + DroidDeckの共有ストレージ + %1$s上のDroidDeck + DroidDeckのアプリデータ + 新しいフォルダはDroidDeckのSyncフォルダに保存されます。DroidDeckの別のフォルダを同期するには、「Syncthing」タブの「フォルダを追加」を使用してください。 + Syncthing + 再読み込み + Syncthingは実行されていません。 Steamが不正な命令(SIGILL)で停止しました。CPU命令の互換性の問題か、クライアントのクラッシュの可能性があります。デバイスの詳細とあわせてログを共有してください。 Steamの設定 → Steamクライアント → x86-64(試験運用)を試すことができます。追加のダウンロードが必要で、動作が遅くなる場合があります。 Linuxグラフィックスドライバーが使用可能なVulkanデバイスを見つけられませんでした。選択したLinux GPUドライバーがこのGPUに対応しているか確認してください。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ed10e6890..5bdf2659b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -299,6 +299,8 @@ Xbox 360 컨트롤러 Deck: 빠른 액세스 버튼과 자이로스코프를 제공합니다. Xbox 360: Guide+A로 빠른 액세스를 엽니다. 뒤로 + 뒤로 길게 눌러 Steam 단축키 + 뒤로를 길게 누르면 빠른 실행이 열리거나 다른 버튼과 Steam 조합이 됩니다. 끄면 길게 누르기를 무시합니다. 탭과 두 번 탭은 계속 작동합니다. 뒤로 버튼을 700ms 동안 누르거나, 뒤로 버튼을 누른 채 다른 버튼을 누르면 Steam 단축키를 사용할 수 있습니다. 두 번 탭 간격 %1$dms @@ -652,6 +654,8 @@ 바 위치 고정 HUD를 드래그하여 이동하고, 길게 눌러 고정하거나 해제합니다. 고정된 동안에도 아래에 있는 화면 컨트롤은 계속 작동합니다. + 양옆에 맞추기 + HUD를 놓으면 화면에서 더 가까운 쪽으로 이동합니다. 튕기면 그 방향으로 이동합니다. 외관 화면상 크기 은은함 @@ -1750,6 +1754,93 @@ 키보드 + 터치패드 터미널 Deck 그립 + 터치패드 + 대시보드 + 아직 표시할 내용이 없습니다. + 다운로드 + 진행 중인 다운로드가 없습니다. + Steam + 셰이더 캐시 다운로드 중 + 일시 중지됨 + 시작 대기 중 + 알림 + DroidDeck 하단 화면 + 플레이하는 동안 메시지와 기타 알림을 여기에 표시합니다. 알림은 이 화면에만 표시되며 저장되지 않습니다. Android에서 한 번만 묻습니다. 이 화면에 알림 접근 권한 페이지가 열리면 DroidDeck을 켜세요. 그러면 이 페이지로 자동으로 돌아옵니다. + 알림 접근 허용 + 알림이 없습니다. + 알림 %1$d개 + 지우기 + 스크린샷 + FPS를 보려면 HUD를 켜야 합니다 + 속도 + 페달 + G-포스 + 타이밍 + 베스트 랩 + 타이어 °C + 스로틀 및 브레이크, 최근 10 s + 속도, %1$s + 기어 + 속도 단위 + 자동(%1$s) + Steam 클라이언트 ARM + Steam 클라이언트 x86 + 스로틀 + 브레이크 + 계기판 표시등 + 변속 + 핸드브레이크 + 피트 리미터 + TC + ABS + 터보 %1$s bar + 엔진 %1$s °C + 오일 %1$s °C + 파워 분배기 + 연료, t + 메인 %1$s t + 예비 %1$s t + 상단 막대나 계기판을 탭하면 스타일이 바뀝니다. + 계기판 데이터 보내기 + 자동 + 나중에 발견된 게임을 포함해 여기에 나열된 모든 게임에서 켭니다. 직접 끈 게임은 꺼진 상태로 유지됩니다. + 게임 자체의 데이터 출력 설정을 변경합니다. 다음에 게임을 시작할 때 적용되며, 끄면 이전 설정으로 되돌립니다. + 이 설정을 변경하려면 %1$s을(를) 종료하세요. + %1$s의 설정을 변경할 수 없습니다. + %1$s: 게임 설정에서 IP 127.0.0.1, 포트 %3$d(으)로 %2$s을(를) 켜세요. + Pictures/DroidDeck에 저장됩니다. Steam 스크린샷도 여기에 표시됩니다. + 스크린샷 찍기 + 스크린샷이 저장되었습니다. + 스크린샷을 찍을 수 없습니다. + 아직 스크린샷이 없습니다. + 채팅 및 음성 + 게임이 메인 화면에서 계속 실행되는 동안 여기에서 Steam 채팅을 읽고 답장하세요. Steam에 로그인된 계정을 사용합니다. + Steam에 아직 공유할 웹 로그인이 없으므로 여기에서 한 번 로그인하세요. + DroidDeck이 백그라운드에 있는 동안 Android가 채팅 페이지를 닫았습니다. DroidDeck으로 돌아오면 다시 열립니다. + Steam Chat 열기 + 채팅 닫기 + 마이크 켜짐 + 마이크 꺼짐 + 이 게임에서는 마이크가 꺼져 있습니다. 대화하려면 DroidDeck의 Steam 설정(오디오)에서 마이크를 켜고 게임을 다시 시작하세요. + 볼륨 %1$d%% + 게임 내 통계 + FPS + CPU + GPU + 배터리 + 랩 + 순위 + 현재 랩 + 지난 랩 + 연료 + 화물 + 파워 SYS · ENG · WEP + 법적 상태 + 잔액 + War Thunder + Elite Dangerous + 레이싱 게임의 데이터 출력(흔히 텔레메트리 또는 UDP라고 함)을 켜면 여기에서 실시간 계기판을 볼 수 있습니다. 데이터는 이 기기에만 남습니다. + 포트 %1$d에서 데이터를 받고 있지만(%3$d바이트 패킷 %2$d개), 이 페이지가 아직 읽을 수 없는 형식입니다. + 다른 프로그램이 이미 포트 %1$s을(를) 사용하고 있어 해당 게임을 여기에 표시할 수 없습니다. Linux 런타임 제거 중 @@ -2079,6 +2170,7 @@ %1$s 스케일링을 %2$s(으)로 설정합니다. 화면에 맞춤(%1$d×%2$d) 화면 따라가기(접거나 펼치면 크기 변경) + 배율 %1$s Steam 기기 세부 정보 표시 기기 세부 정보 숨기기 @@ -3131,6 +3223,113 @@ 거리 확인이 통과되었습니다. 손 모델 체크섬 불일치 OpenCV를 로드할 수 없습니다. + Syncthing + Syncthing + Syncthing으로 동기화 + Syncthing-Fork를 기반으로 한 Syncthing 구현으로, DroidDeck 내부 파일 시스템의 파일을 다른 Syncthing 인스턴스와 동기화할 수 있습니다. DroidDeck 내부 파일 시스템에 저장한 파일에만 적용됩니다. + 이 빌드에는 Syncthing이 포함되어 있지 않습니다. + Syncthing 시작 중 + 시작 중 + 최신 상태 + 동기화 중 + 일시 중지됨 + 일시 중지 중 + 재개 중 + 확인 필요 + Syncthing이 중지됨 + 다시 시도 + Wi-Fi 연결 시까지 일시 중지됨 + 게임 중 일시 중지됨 + 폴더 %1$d개 · 기기 %3$d대 중 %2$d대 연결됨 + %1$s 남음 + 웹 UI 열기 + 일시 중지 + 재개 + 실행 중 + 끄면 Syncthing이 완전히 중지됩니다. + 이 기기 + 기기 ID + QR 코드 표시 + 고급 + 포트, 실행 시점, 초기화 + 열기 + 확인 대기 중 + %1$s에서 연결을 요청합니다 + %1$s에서 “%2$s” 폴더를 공유하려고 합니다 + 수락하면 DroidDeck에서 저장할 위치를 선택할 수 있습니다. + 웹 UI에서 검토 + 폴더 + 아직 폴더가 없습니다. + %1$s · %2$s + 최신 상태 + 동기화 중 %1$d%% + 스캔 중 + 일시 중지됨 + 동기화되지 않음 + 파일 %1$d개를 동기화하지 못했습니다 + 차단됨 + 이 폴더는 DroidDeck 저장 공간 밖에 있어서 DroidDeck이 일시 중지했습니다. DroidDeck 밖의 폴더를 동기화하려면 Syncthing-Fork 앱을 사용하세요. + Syncthing에서 제거 + 제거해도 파일은 삭제되지 않습니다. + 기기 + 아직 기기가 없습니다. + 연결됨 + 오프라인 + 마지막 접속: %1$s + 이 기기의 ID + 다른 기기의 Syncthing에서 기기를 추가하고 이 코드를 스캔하거나 ID를 붙여넣으세요. 그런 다음 여기 웹 UI에서 수락하세요. + 복사 + 공유 + 완료 + Syncthing을 끄시겠습니까? + Syncthing이 지금 중지되며 다시 켤 때까지 중지된 상태로 유지됩니다. 동기화된 파일은 그대로 유지됩니다. + Syncthing 구성도 삭제 + 이 기기의 ID, 폴더 및 기기를 제거합니다. 다시 켜면 처음부터 시작합니다. + 끄기 + 포트 + 동기화 + 로컬 탐색 + 새 포트 선택 + 시작할 때마다 다시 확인합니다. + 다시 선택 + 초기화 + API 키 + 다시 생성 + Syncthing 초기화 + 새 기기 ID가 생성되고 폴더와 기기가 제거됩니다. DroidDeck 저장 공간의 파일은 유지됩니다. + 초기화 + Syncthing을 초기화하시겠습니까? + 실행 + 실행 + DroidDeck이 열려 있는 동안 + 백그라운드에서 + Wi-Fi에서만 + 게임 중 일시 중지 + 로그 + 이 세션의 Syncthing 출력 + 공유 + Syncthing + 먼저 Linux 런타임을 설치하세요. + 수락 + “%1$s” 저장 위치 + 폴더 추가 + DroidDeck 저장 공간의 폴더 동기화 + 선택 + 추가 + 폴더를 추가할 수 없습니다. + 공유 대상 + 아직 기기가 없습니다. 나중에 웹 UI에서 이 폴더를 공유할 수 있습니다. + 이 폴더 사용 + 새 폴더 + 컨테이너 홈 + Sync 폴더 + DroidDeck 공유 저장 공간 + %1$s의 DroidDeck + DroidDeck 앱 데이터 + 새 폴더는 DroidDeck의 Sync 폴더에 저장됩니다. 다른 DroidDeck 폴더를 동기화하려면 Syncthing 탭에서 \"폴더 추가\"를 사용하세요. + Syncthing + 새로고침 + Syncthing이 실행 중이 아닙니다. Steam이 잘못된 명령어(SIGILL)로 중지되었습니다. CPU 명령어 호환성 문제이거나 클라이언트 충돌일 수 있습니다. 기기 정보와 함께 로그를 공유하세요. Steam 설정 → Steam 클라이언트 → x86-64(실험적)를 시도해 볼 수 있습니다. 추가 다운로드가 필요하며 더 느리게 실행될 수 있습니다. Linux 그래픽 드라이버가 사용 가능한 Vulkan 장치를 찾지 못했습니다. 선택한 Linux GPU 드라이버가 이 GPU를 지원하는지 확인하세요. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 43f8be426..988031cfb 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -299,6 +299,8 @@ Pengawal Xbox 360 Deck: butang Quick Access dan giro. Steam Controller: sama, dengan grip sense pada butang belakang. DualShock 4 dan DualSense: giro, dan trackpad sebagai pad sentuhnya. Xbox 360: buka Quick Access dengan Guide+A. Kembali + Tahan Kembali untuk pintasan Steam + Menahan Kembali membuka Akses Pantas atau membuat kombinasi Steam dengan butang lain. Matikan untuk mengabaikan Kembali yang ditahan; ketik dan ketik dua kali masih berfungsi. Tahan Kembali selama 700 ms, atau tahan Kembali dan tekan butang lain untuk pintasan Steam. Tempoh ketik dua kali %1$d ms @@ -652,6 +654,8 @@ Jalur Kunci di tempat Seret HUD untuk mengalihkannya; tekan lama untuk mengunci atau membuka kuncinya. Semasa dikunci, kawalan skrin di bawahnya masih berfungsi. + Lekap ke sisi + Lepaskan HUD dan ia akan beralih ke sisi skrin yang lebih dekat. Kuis pantas melemparkannya ke arah itu. Rupa Saiz pada skrin Lembut @@ -1750,6 +1754,93 @@ Papan kekunci + trackpad Terminal Pemegang Deck + trackpad + Papan pemuka + Belum ada apa-apa untuk dipaparkan di sini. + Muat turun + Tiada muat turun yang sedang berjalan. + Steam + Memuat turun cache shader + Dijeda + Menunggu untuk bermula + Pemberitahuan + Skrin bawah DroidDeck + Paparkan mesej dan pemberitahuan lain di sini semasa anda bermain. Ia hanya kekal pada skrin ini dan tidak disimpan. Android bertanya sekali sahaja: halaman akses pemberitahuannya dibuka pada skrin ini; hidupkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya. + Benarkan akses pemberitahuan + Tiada pemberitahuan. + %1$d pemberitahuan + Kosongkan + Tangkapan skrin + FPS memerlukan HUD dihidupkan + Kelajuan + Pedal + Daya G + Masa + Pusingan terbaik + Tayar °C + Pendikit dan brek, 10 s terakhir + Kelajuan, %1$s + Gear + Unit kelajuan + Automatik (%1$s) + Klien Steam ARM + Klien Steam x86 + Pendikit + Brek + Lampu penunjuk + Tukar gear + Brek tangan + Pengehad pit + TC + ABS + Turbo %1$s bar + Enjin %1$s °C + Minyak %1$s °C + Pengagih kuasa + Bahan api, t + Utama %1$s t + Simpanan %1$s t + Ketik jalur atas atau papan pemuka untuk menukar gayanya. + Hantar data papan pemuka + Automatik + Menghidupkannya untuk setiap permainan yang disenaraikan di sini, termasuk permainan yang ditemui kemudian. Permainan yang anda matikan kekal mati. + Ini menukar tetapan output data permainan itu sendiri. Ia berkuat kuasa apabila permainan dimulakan seterusnya; mematikannya memulihkan tetapan lama. + Tutup %1$s untuk menukar ini. + Tidak dapat menukar tetapan %1$s. + %1$s: hidupkan %2$s dalam tetapan permainan dengan IP 127.0.0.1 dan port %3$d. + Disimpan ke Pictures/DroidDeck. Tangkapan skrin Steam juga dipaparkan di sini. + Ambil tangkapan skrin + Tangkapan skrin disimpan. + Tidak dapat mengambil tangkapan skrin. + Belum ada tangkapan skrin. + Sembang & suara + Baca dan balas sembang Steam di sini sementara permainan terus berjalan pada skrin utama. Ia menggunakan akaun yang digunakan untuk log masuk ke Steam. + Steam belum mempunyai log masuk web untuk dikongsi, jadi log masuk di sini sekali. + Android menutup halaman sembang semasa DroidDeck berada di latar belakang. Ia dibuka semula apabila anda kembali ke DroidDeck. + Buka Steam Chat + Tutup sembang + Mikrofon hidup + Mikrofon mati + Mikrofon dimatikan untuk permainan ini. Untuk bercakap, hidupkan Mikrofon dalam tetapan Steam DroidDeck (Audio) dan mulakan permainan semula. + Kelantangan %1$d%% + Statistik dalam permainan + FPS + CPU + GPU + Bateri + Pusingan + Kedudukan + Pusingan ini + Pusingan terakhir + Bahan api + Kargo + Pip SYS · ENG · WEP + Status undang-undang + Baki + War Thunder + Elite Dangerous + Hidupkan output data permainan lumba (sering dipanggil telemetri atau UDP) untuk melihat papan pemuka langsung di sini. Data kekal pada peranti ini. + Port %1$d menerima data (%2$d paket bersaiz %3$d bait), tetapi dalam format yang belum dapat dibaca oleh halaman ini. + Program lain sudah menggunakan port %1$s, jadi permainan itu tidak dapat dipaparkan di sini. Mengalih keluar masa jalan Linux @@ -2079,6 +2170,7 @@ %1$s Menetapkan penskalaan ke %2$s. Sepadan skrin (%1$d×%2$d) Ikut skrin (mengubah saiz apabila dilipat atau dibuka) + Diskalakan %1$s Steam Papar butiran peranti Sembunyikan butiran peranti @@ -3214,6 +3306,113 @@ Pandangan yang disimpan boleh dikira semula. Mulakan penentukuran baharu untuk ditangkap dalam sistem koordinat penjejakan semasa. Ketakpadanan checksum model tangan OpenCV tidak dapat dimuatkan + Syncthing + Syncthing + Segerakkan dengan Syncthing + Berasaskan Syncthing-Fork, ini ialah pelaksanaan Syncthing yang membolehkan anda menyegerakkan fail dalam sistem fail dalaman DroidDeck dengan tika Syncthing lain. Ini hanya untuk fail yang anda simpan dalam sistem fail dalaman DroidDeck. + Binaan ini tidak menyertakan Syncthing. + Memulakan Syncthing + Memulakan + Terkini + Menyegerakkan + Dijeda + Menjeda + Menyambung semula + Perlu perhatian + Syncthing berhenti + Cuba lagi + Dijeda sehingga ada Wi-Fi + Dijeda semasa permainan + %1$d folder · %2$d daripada %3$d peranti disambungkan + %1$s lagi + Buka UI web + Jeda + Sambung semula + Berjalan + Matikan untuk menghentikan Syncthing sepenuhnya. + Peranti ini + ID Peranti + Tunjukkan QR + Lanjutan + Port, bila hendak dijalankan, tetapkan semula + Buka + Menunggu anda + %1$s mahu bersambung + %1$s mahu berkongsi “%2$s” + Terima untuk memilih tempat ia disimpan dalam DroidDeck. + Semak dalam UI web + Folder + Belum ada folder. + %1$s · %2$s + Terkini + Menyegerakkan %1$d%% + Mengimbas + Dijeda + Tidak segerak + %1$d fail tidak dapat disegerakkan + Disekat + Folder ini berada di luar storan DroidDeck, jadi DroidDeck menjedanya. Untuk menyegerakkan folder di luar DroidDeck, gunakan apl Syncthing-Fork. + Alih keluar daripada Syncthing + Mengalih keluarnya tidak memadamkan sebarang fail. + Peranti + Belum ada peranti. + Disambungkan + Luar talian + Kali terakhir dilihat %1$s + ID peranti ini + Pada peranti anda yang lain, tambah peranti dalam Syncthing dan imbas kod ini atau tampal ID. Kemudian terimanya di sini dalam UI web. + Salin + Kongsi + Selesai + Matikan Syncthing? + Syncthing berhenti sekarang dan kekal berhenti sehingga dihidupkan semula. Fail yang disegerakkan kekal di tempatnya. + Padamkan juga konfigurasi Syncthing + Mengalih keluar ID, folder dan peranti bagi peranti ini. Menghidupkannya semula akan bermula dari awal. + Matikan + Port + Penyegerakan + Penemuan setempat + Pilih port baharu + Disemak semula pada setiap permulaan. + Pilih semula + Tetapkan semula + Kunci API + Jana semula + Tetapkan semula Syncthing + ID peranti baharu, folder dan peranti dialih keluar. Fail dalam storan DroidDeck dikekalkan. + Tetapkan semula + Tetapkan semula Syncthing? + Berjalan + Jalankan + Semasa DroidDeck dibuka + Di latar belakang + Wi-Fi sahaja + Jeda semasa permainan + Log + Output Syncthing untuk sesi ini + Kongsi + Syncthing + Sediakan runtime Linux dahulu. + Terima + Simpan “%1$s” ke + Tambah folder + Segerakkan folder dalam storan DroidDeck + Pilih + Tambah + Folder tidak dapat ditambah. + Kongsi dengan + Belum ada peranti. Anda boleh berkongsi folder ini daripada UI web kemudian. + Gunakan folder ini + Folder baharu + Folder rumah kontena + Folder Sync + Storan kongsi DroidDeck + DroidDeck pada %1$s + Data apl DroidDeck + Folder baharu disimpan dalam folder Sync DroidDeck. Untuk menyegerakkan folder DroidDeck yang lain, gunakan Tambah folder pada tab Syncthing. + Syncthing + Muat semula + Syncthing tidak berjalan. Steam berhenti dengan arahan tidak sah (SIGILL). Ini mungkin masalah keserasian arahan CPU atau ranap klien. Kongsi log bersama butiran peranti anda. Anda boleh cuba Tetapan Steam → Klien Steam → x86-64 (eksperimen). Ia memerlukan muat turun tambahan dan mungkin berjalan lebih perlahan. Pemacu grafik Linux tidak menemui peranti Vulkan yang boleh digunakan. Pastikan pemacu GPU Linux yang dipilih menyokong GPU ini. diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index e41ce66f8..da78e7d85 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -299,6 +299,8 @@ Xbox 360-kontroller Deck: Hurtigmeny-knapp og gyro. Steam Controller: det samme, med gripesensor på bakknappene. DualShock 4 og DualSense: gyro, og styreflatene som styreflate. Xbox 360: åpne Hurtigmenyen med Guide+A. Tilbake + Hold Tilbake for Steam-snarveier + Hold Tilbake for å åpne Hurtigtilgang eller lage Steam-kombinasjoner med andre knapper. Slå av for å ignorere holdt Tilbake; trykk og dobbelttrykk virker fortsatt. Hold Tilbake i 700 ms, eller hold Tilbake og trykk en annen knapp for Steam-snarveier. Tidsvindu for dobbelttrykk %1$d ms @@ -654,6 +656,8 @@ Linje Lås på plass Dra HUD-en for å flytte den; et langt trykk låser eller låser den opp. Mens den er låst, fungerer skjermkontrollene under den fortsatt. + Fest til sidene + Slipp HUD-en, så flytter den seg til nærmeste side av skjermen. Et raskt sveip kaster den den veien. Utseende Størrelse på skjermen Myk @@ -757,7 +761,7 @@ Sesjonsoppførsel Gjelder nå og for fremtidige sesjoner i denne modusen. Neste sesjon - Support + Støtte Sesjonslogger Legg ved en feilrapport Del logger @@ -1772,6 +1776,93 @@ Tastatur + styreflate Terminal Deck-grep + styreflater + Dashbord + Ingenting å vise her ennå. + Nedlastinger + Ingen nedlastinger pågår. + Steam + Laster ned shaderbuffer + Pauset + Venter på å starte + Varslinger + DroidDeck nedre skjerm + Vis meldinger og andre varslinger her mens du spiller. De vises bare på denne skjermen og lagres ikke. Android spør én gang: siden for varseltilgang åpnes på denne skjermen; slå på DroidDeck der, så kommer denne siden tilbake av seg selv. + Tillat varseltilgang + Ingen varslinger. + %1$d varslinger + Fjern + Skjermbilder + FPS krever at HUD-en er på + Fart + Pedaler + G-kraft + Tider + Beste runde + Dekk °C + Gass og brems, siste 10 s + Fart, %1$s + Gir + Fartsenheter + Automatisk (%1$s) + Steam-klient ARM + Steam-klient x86 + Gass + Brems + Varsellamper + Girskift + Håndbrekk + Pitbegrenser + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Olje %1$s °C + Kraftfordeler + Drivstoff, t + Hoved %1$s t + Reserve %1$s t + Trykk på den øverste stripen eller dashbordet for å endre stilen. + Send dashborddata + Automatisk + Slår det på for alle spill som er oppført her, også spill som blir funnet senere. Spill du slår av, forblir av. + Dette endrer spillets egen innstilling for datautgang. Det trer i kraft neste gang spillet starter; slår du det av, settes den gamle innstillingen tilbake. + Lukk %1$s for å endre dette. + Kunne ikke endre innstillingene for %1$s. + %1$s: slå på %2$s i spillets innstillinger med IP 127.0.0.1 og port %3$d. + Lagret i Pictures/DroidDeck. Skjermbilder fra Steam vises også her. + Ta skjermbilde + Skjermbildet er lagret. + Kunne ikke ta et skjermbilde. + Ingen skjermbilder ennå. + Chat & tale + Les og svar på Steam-chat her mens spillet fortsetter på hovedskjermen. Den bruker kontoen Steam er logget inn med. + Steam har ingen nettinnlogging å dele ennå, så logg inn her én gang. + Android lukket chatsiden mens DroidDeck var i bakgrunnen. Den åpnes igjen når du kommer tilbake til DroidDeck. + Åpne Steam Chat + Lukk chat + Mikrofon på + Mikrofon av + Mikrofonen er av for dette spillet. For å snakke, slå på Mikrofon i DroidDecks Steam-innstillinger (Lyd) og start spillet på nytt. + Volum %1$d%% + Statistikk i spillet + FPS + CPU + GPU + Batteri + Runde + Plassering + Denne runden + Forrige runde + Drivstoff + Last + Pips SYS · ENG · WEP + Juridisk status + Saldo + War Thunder + Elite Dangerous + Slå på datautgangen i et racingspill (ofte kalt telemetri eller UDP) for å se et direkte dashbord her. Dataene blir på denne enheten. + Port %1$d mottar data (%2$d pakker på %3$d byte), men i et format denne siden ikke kan lese ennå. + Et annet program bruker allerede port %1$s, så det spillet kan ikke vises her. Fjerner Linux-kjøretid @@ -2102,6 +2193,7 @@ %1$s Setter skalering til %2$s. Tilpass skjerm (%1$d×%2$d) Følg skjermen (endrer størrelse ved sammenslåing eller utbretting) + Skalert %1$s Steam Vis enhetsdetaljer Skjul enhetsdetaljer @@ -3243,6 +3335,113 @@ Lagrede visninger kan beregnes på nytt. Start en ny kalibrering for å fange opp i gjeldende sporingskoordinatsystem. Uoverensstemmelse med håndmodellsjekksum OpenCV kunne ikke laste + Syncthing + Syncthing + Synkroniser med Syncthing + Dette er en Syncthing-implementasjon basert på Syncthing-Fork som lar deg synkronisere filer i DroidDecks interne filsystem med andre Syncthing-instanser. Dette gjelder bare filer du lagrer i DroidDecks interne filsystem. + Denne versjonen inneholder ikke Syncthing. + Starter Syncthing + Starter + Oppdatert + Synkroniserer + Pauset + Pauser + Gjenopptar + Trenger oppmerksomhet + Syncthing stoppet + Prøv igjen + Pauset til Wi-Fi er tilgjengelig + Pauset under spillet + %1$d mapper · %2$d av %3$d enheter tilkoblet + %1$s igjen + Åpne nettgrensesnittet + Pause + Gjenoppta + Kjører + Slå av for å stoppe Syncthing helt. + Denne enheten + Enhets-ID + Vis QR + Avansert + Porter, når den skal kjøre, nullstilling + Åpne + Venter på deg + %1$s vil koble til + %1$s vil dele «%2$s» + Godta for å velge hvor den lagres i DroidDeck. + Se gjennom i nettgrensesnittet + Mapper + Ingen mapper ennå. + %1$s · %2$s + Oppdatert + Synkroniserer %1$d%% + Skanner + Pauset + Ikke synkronisert + %1$d filer kunne ikke synkroniseres + Blokkert + Denne mappen ligger utenfor DroidDecks lagring, så DroidDeck har pauset den. Bruk Syncthing-Fork-appen for å synkronisere mapper utenfor DroidDeck. + Fjern fra Syncthing + Fjerning sletter ingen filer. + Enheter + Ingen enheter ennå. + Tilkoblet + Frakoblet + Sist sett %1$s + Denne enhetens ID + Legg til en enhet i Syncthing på den andre enheten din, og skann denne koden eller lim inn ID-en. Godta den deretter her i nettgrensesnittet. + Kopier + Del + Ferdig + Slå av Syncthing? + Syncthing stopper nå og forblir stoppet til den slås på igjen. Synkroniserte filer blir liggende der de er. + Slett også Syncthing-konfigurasjonen + Fjerner denne enhetens ID, mapper og enheter. Når den slås på igjen, starter den på nytt fra bunnen av. + Slå av + Porter + Synkronisering + Lokal oppdaging + Velg nye porter + Kontrolleres på nytt ved hver oppstart. + Velg på nytt + Nullstill + API-nøkkel + Generer på nytt + Nullstill Syncthing + Ny enhets-ID, mapper og enheter fjernes. Filer i DroidDecks lagring beholdes. + Nullstill + Nullstille Syncthing? + Kjøring + Kjør + Mens DroidDeck er åpen + I bakgrunnen + Bare Wi-Fi + Pause under spilling + Logger + Syncthing-utdata for denne sesjonen + Del + Syncthing + Konfigurer Linux-kjøretiden først. + Godta + Lagre «%1$s» i + Legg til mappe + Synkroniser en mappe i DroidDecks lagring + Velg + Legg til + Mappen kunne ikke legges til. + Del med + Ingen enheter ennå. Du kan dele denne mappen fra nettgrensesnittet senere. + Bruk denne mappen + Ny mappe + Hjemmemappe i containeren + Sync-mappe + DroidDecks delte lagring + DroidDeck på %1$s + DroidDeck-appdata + Nye mapper lagres i DroidDecks Sync-mappe. For å synkronisere en annen DroidDeck-mappe, bruk Legg til mappe på Syncthing-fanen. + Syncthing + Last på nytt + Syncthing kjører ikke. Steam stoppet med en ulovlig instruksjon (SIGILL). Dette kan være et kompatibilitetsproblem med CPU-instruksjoner eller et klientkrasj. Del loggene sammen med detaljer om enheten din. Du kan prøve Steam-innstillinger → Steam-klient → x86-64 (eksperimentell). Det krever en ekstra nedlasting og kan kjøre tregere. Linux-grafikkdriveren fant ingen brukbar Vulkan-enhet. Sjekk at den valgte Linux-GPU-driveren støtter denne GPU-en. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 635f6e47b..21fb36981 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -299,6 +299,8 @@ Xbox 360-controller Deck: knop Snelle toegang en gyro. Steam Controller: hetzelfde, met gripsensor op de achterknoppen. DualShock 4 en DualSense: gyro, en de trackpads als hun touchpad. Xbox 360: open Snelle toegang met Guide+A. Terug + Terug ingedrukt houden voor Steam-sneltoetsen + Terug ingedrukt houden opent Snelle toegang of maakt Steam-combinaties met andere knoppen. Zet uit om ingedrukt houden te negeren; tikken en dubbeltikken blijven werken. Houd Terug 700 ms ingedrukt, of houd Terug ingedrukt en druk op een andere knop voor Steam-sneltoetsen. Tijdvenster voor dubbeltikken %1$d ms @@ -654,6 +656,8 @@ Balk Vastzetten Sleep de HUD om hem te verplaatsen; lang drukken zet hem vast of los. Zolang hij vastzit, blijven de schermknoppen eronder werken. + Vastklikken aan zijkanten + Laat de HUD los en hij gaat naar de dichtstbijzijnde kant van het scherm. Met een snelle veeg gooi je hem die kant op. Weergave Grootte op scherm Zacht @@ -944,7 +948,7 @@ Installeren… Installeren Stel eerst Flatpak in - %1$s download + %1$s te downloaden ● Geïnstalleerd Kan de Flathub-pagina van deze app niet laden. Laden vanuit Flathub… @@ -1772,6 +1776,93 @@ Toetsenbord + trackpad Terminal Deck-grepen + trackpads + Dashboard + Hier is nog niets te zien. + Downloads + Er lopen geen downloads. + Steam + Shadercache downloaden + Gepauzeerd + Wacht om te starten + Meldingen + DroidDeck onderste scherm + Toon berichten en andere meldingen hier terwijl je speelt. Ze blijven alleen op dit scherm en worden niet opgeslagen. Android vraagt dit één keer: de pagina voor meldingstoegang opent op dit scherm; zet DroidDeck daar aan en deze pagina komt vanzelf terug. + Meldingstoegang toestaan + Geen meldingen. + %1$d meldingen + Wissen + Screenshots + Voor FPS moet de HUD aan staan + Snelheid + Pedalen + G-kracht + Tijden + Snelste ronde + Banden °C + Gas en rem, laatste 10 s + Snelheid, %1$s + Versnelling + Snelheidseenheid + Automatisch (%1$s) + Steam-client ARM + Steam-client x86 + Gas + Rem + Dashboard + Schakelen + Handrem + Pitlimiter + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Olie %1$s °C + Energieverdeler + Brandstof, t + Hoofdtank %1$s t + Reserve %1$s t + Tik op de bovenste balk of het dashboard om de stijl te wijzigen. + Dashboardgegevens verzenden + Automatisch + Zet het aan voor elke game in deze lijst, ook voor games die later worden gevonden. Games die je uitzet, blijven uit. + Dit wijzigt de eigen instelling voor gegevensuitvoer van de game. Het werkt vanaf de volgende keer dat de game start; uitzetten zet de oude instelling terug. + Sluit %1$s om dit te wijzigen. + Kan de instellingen van %1$s niet wijzigen. + %1$s: zet %2$s aan in de instellingen van de game met IP 127.0.0.1 en poort %3$d. + Opgeslagen in Pictures/DroidDeck. Steam-screenshots verschijnen hier ook. + Screenshot maken + Screenshot opgeslagen. + Kan geen screenshot maken. + Nog geen screenshots. + Chat & spraak + Lees en beantwoord Steam-chats hier terwijl de game op het hoofdscherm doorloopt. Het gebruikt het account waarmee Steam is ingelogd. + Steam heeft nog geen webaanmelding om te delen, dus log hier één keer in. + Android heeft de chatpagina gesloten terwijl DroidDeck op de achtergrond was. Hij opent weer wanneer je terugkeert naar DroidDeck. + Steam Chat openen + Chat sluiten + Microfoon aan + Microfoon uit + De microfoon staat uit voor deze game. Zet Microfoon aan in de Steam-instellingen van DroidDeck (Audio) en start de game opnieuw om te praten. + Volume %1$d%% + In-game statistieken + FPS + CPU + GPU + Accu + Ronde + Positie + Deze ronde + Vorige ronde + Brandstof + Lading + Pips SYS · ENG · WEP + Juridische status + Saldo + War Thunder + Elite Dangerous + Zet de gegevensuitvoer van een racegame aan (vaak telemetrie of UDP genoemd) om hier een live dashboard te zien. De gegevens blijven op dit apparaat. + Poort %1$d ontvangt gegevens (%2$d pakketten van %3$d bytes), maar in een formaat dat deze pagina nog niet kan lezen. + Een ander programma gebruikt poort %1$s al, dus die game kan hier niet worden getoond. Linux-runtime verwijderen @@ -2102,6 +2193,7 @@ %1$s Zet schaling op %2$s. Schermformaat (%1$d×%2$d) Scherm volgen (past formaat aan bij vouwen of uitvouwen) + Geschaald %1$s Steam Apparaatgegevens tonen Apparaatgegevens verbergen @@ -3241,6 +3333,113 @@ Opgeslagen weergaven kunnen opnieuw worden berekend. Start een nieuwe kalibratie om vast te leggen in het huidige volgcoördinatensysteem. Controlesom handmodel komt niet overeen OpenCV kon niet laden + Syncthing + Syncthing + Synchroniseren met Syncthing + Dit is een Syncthing-implementatie op basis van Syncthing-Fork waarmee je bestanden in het interne bestandssysteem van DroidDeck synchroniseert met andere Syncthing-installaties. Dit geldt alleen voor bestanden die je in het interne bestandssysteem van DroidDeck bewaart. + Deze build bevat geen Syncthing. + Syncthing starten + Starten + Bijgewerkt + Synchroniseren + Gepauzeerd + Pauzeren + Hervatten + Aandacht nodig + Syncthing is gestopt + Opnieuw proberen + Gepauzeerd tot er Wi-Fi is + Gepauzeerd tijdens de game + %1$d mappen · %2$d van %3$d apparaten verbonden + Nog %1$s + Webinterface openen + Pauzeren + Hervatten + Actief + Zet dit uit om Syncthing volledig te stoppen. + Dit apparaat + Apparaat-ID + QR-code tonen + Geavanceerd + Poorten, wanneer uitvoeren, resetten + Openen + Wacht op jou + %1$s wil verbinding maken + %1$s wil “%2$s” delen + Accepteer om te kiezen waar het in DroidDeck wordt opgeslagen. + Bekijken in webinterface + Mappen + Nog geen mappen. + %1$s · %2$s + Bijgewerkt + Synchroniseren %1$d%% + Scannen + Gepauzeerd + Niet gesynchroniseerd + %1$d bestanden konden niet worden gesynchroniseerd + Geblokkeerd + Deze map staat buiten de opslag van DroidDeck, dus DroidDeck heeft hem gepauzeerd. Gebruik de Syncthing-Fork-app om mappen buiten DroidDeck te synchroniseren. + Verwijderen uit Syncthing + Hierbij worden geen bestanden verwijderd. + Apparaten + Nog geen apparaten. + Verbonden + Offline + Laatst gezien: %1$s + ID van dit apparaat + Voeg op je andere apparaat in Syncthing een apparaat toe en scan deze code of plak de ID. Accepteer het daarna hier in de webinterface. + Kopiëren + Delen + Klaar + Syncthing uitzetten? + Syncthing stopt nu en blijft gestopt tot je het weer aanzet. Gesynchroniseerde bestanden blijven waar ze zijn. + Ook de configuratie van Syncthing verwijderen + Verwijdert de ID, mappen en apparaten van dit apparaat. Als je het weer aanzet, begint het opnieuw. + Uitzetten + Poorten + Synchronisatie + Lokale detectie + Nieuwe poorten kiezen + Wordt bij elke start opnieuw gecontroleerd. + Opnieuw kiezen + Resetten + API-sleutel + Opnieuw genereren + Syncthing resetten + Nieuwe apparaat-ID, mappen en apparaten verwijderd. Bestanden in de opslag van DroidDeck blijven bewaard. + Resetten + Syncthing resetten? + Uitvoering + Uitvoeren + Zolang DroidDeck open is + Op de achtergrond + Alleen via Wi-Fi + Pauzeren tijdens games + Logs + Syncthing-uitvoer van deze sessie + Delen + Syncthing + Stel eerst de Linux-runtime in. + Accepteren + “%1$s” opslaan in + Map toevoegen + Een map in de opslag van DroidDeck synchroniseren + Kiezen + Toevoegen + De map kan niet worden toegevoegd. + Delen met + Nog geen apparaten. Je kunt deze map later via de webinterface delen. + Deze map gebruiken + Nieuwe map + Thuismap van container + Sync-map + Gedeelde opslag van DroidDeck + DroidDeck op %1$s + App-gegevens van DroidDeck + Nieuwe mappen worden opgeslagen in de Sync-map van DroidDeck. Gebruik Map toevoegen op het tabblad Syncthing om een andere DroidDeck-map te synchroniseren. + Syncthing + Opnieuw laden + Syncthing is niet actief. Steam is gestopt met een ongeldige instructie (SIGILL). Dit kan een compatibiliteitsprobleem met CPU-instructies of een crash van de client zijn. Deel de logs met de gegevens van je apparaat. Je kunt Steam-instellingen → Steam-client → x86-64 (experimenteel) proberen. Dit vereist een extra download en kan trager draaien. De Linux-grafische driver kon geen bruikbaar Vulkan-apparaat vinden. Controleer of de gekozen Linux-GPU-driver deze GPU ondersteunt. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 09f8be7a1..7f4b1e343 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -299,6 +299,8 @@ Kontroler Xbox 360 Deck: przycisk szybkiego dostępu i żyroskop. Steam Controller: to samo oraz wykrywanie chwytu na tylnych przyciskach. DualShock 4 i DualSense: żyroskop, a gładziki działają jako ich touchpad. Xbox 360: szybki dostęp otwiera Guide+A. Wstecz + Przytrzymaj Wstecz dla skrótów Steam + Przytrzymanie Wstecz otwiera Szybki dostęp lub tworzy skróty Steam z innymi przyciskami. Wyłącz, aby ignorować przytrzymanie; dotknięcie i podwójne dotknięcie nadal działają. Przytrzymaj Wstecz przez 700 ms lub przytrzymaj Wstecz i naciśnij inny przycisk, aby użyć skrótów Steam. Czas podwójnego dotknięcia %1$d ms @@ -658,6 +660,8 @@ Pasek Zablokuj w miejscu Przeciągnij HUD, aby go przesunąć; długie naciśnięcie blokuje go lub odblokowuje. Gdy jest zablokowany, sterowanie ekranowe pod nim nadal działa. + Przyciągaj do boków + Puść HUD, a przesunie się do bliższego boku ekranu. Szybki ruch rzuca go w tę stronę. Wygląd Rozmiar na ekranie Stonowany @@ -1816,6 +1820,93 @@ Klawiatura + trackpad Terminal Uchwyty Deck + trackpady + Panel + Na razie nie ma tu nic do pokazania. + Pobieranie + Nic nie jest pobierane. + Steam + Pobieranie pamięci podręcznej shaderów + Wstrzymano + Oczekuje na rozpoczęcie + Powiadomienia + Dolny ekran DroidDeck + Wyświetlaj tu wiadomości i inne powiadomienia podczas gry. Pozostają tylko na tym ekranie i nie są zapisywane. Android zapyta raz: na tym ekranie otworzy się jego strona dostępu do powiadomień; włącz tam DroidDeck, a ta strona wróci sama. + Zezwól na dostęp do powiadomień + Brak powiadomień. + Powiadomienia: %1$d + Wyczyść + Zrzuty ekranu + FPS wymaga włączonego HUD + Prędkość + Pedały + Przeciążenie G + Czasy + Najlepsze okrążenie + Opony °C + Gaz i hamulec, ostatnie 10 s + Prędkość, %1$s + Bieg + Jednostki prędkości + Automatycznie (%1$s) + Klient Steam ARM + Klient Steam x86 + Gaz + Hamulec + Kontrolki + Zmiana biegu + Hamulec ręczny + Ogranicznik pit + TC + ABS + Turbo %1$s bar + Silnik %1$s °C + Olej %1$s °C + Rozdzielacz mocy + Paliwo, t + Główne %1$s t + Rezerwa %1$s t + Dotknij górnego paska lub zegarów, aby zmienić ich wygląd. + Wysyłaj dane do panelu + Automatycznie + Włącza wysyłanie dla każdej gry z tej listy, także dla gier znalezionych później. Gry, które wyłączysz, pozostaną wyłączone. + To zmienia ustawienie wyjścia danych w samej grze. Zadziała przy następnym uruchomieniu gry; wyłączenie przywraca poprzednie ustawienie. + Zamknij %1$s, aby to zmienić. + Nie udało się zmienić ustawień %1$s. + %1$s: włącz %2$s w ustawieniach gry, podając IP 127.0.0.1 i port %3$d. + Zapisywane w Pictures/DroidDeck. Zrzuty ekranu ze Steam też się tu pojawiają. + Zrób zrzut ekranu + Zapisano zrzut ekranu. + Nie udało się zrobić zrzutu ekranu. + Brak zrzutów ekranu. + Czat i głos + Czytaj czat Steam i odpowiadaj tutaj, a gra działa dalej na głównym ekranie. Używane jest konto, na które zalogowany jest Steam. + Steam nie ma jeszcze logowania przez przeglądarkę, które można udostępnić, więc zaloguj się tu raz. + Android zamknął stronę czatu, gdy DroidDeck działał w tle. Otworzy się ponownie, gdy wrócisz do DroidDeck. + Otwórz Steam Chat + Zamknij czat + Mikrofon wł. + Mikrofon wył. + Mikrofon jest wyłączony dla tej gry. Aby rozmawiać, włącz Mikrofon w ustawieniach Steam w DroidDeck (Dźwięk) i uruchom grę ponownie. + Głośność %1$d%% + Statystyki w grze + FPS + CPU + GPU + Bateria + Okrążenie + Pozycja + Bieżące okrążenie + Ostatnie okrążenie + Paliwo + Ładunek + Punkty mocy SYS · ENG · WEP + Status prawny + Saldo + War Thunder + Elite Dangerous + Włącz wyjście danych w grze wyścigowej (często nazywane telemetrią lub UDP), aby zobaczyć tu zegary na żywo. Dane nie opuszczają tego urządzenia. + Port %1$d odbiera dane (%2$d pakietów po %3$d bajtów), ale w formacie, którego ta strona jeszcze nie odczytuje. + Port %1$s jest już używany przez inny program, więc tej gry nie da się tu pokazać. Usuwanie środowiska Linux @@ -2148,6 +2239,7 @@ %1$s Ustawia skalowanie na %2$s. Dopasuj do ekranu (%1$d×%2$d) Dopasuj do ekranu (zmienia rozmiar po złożeniu lub rozłożeniu) + Skalowanie %1$s Steam Pokaż szczegóły urządzenia Skryj szczegóły urządzenia @@ -3311,6 +3403,113 @@ Zapisane widoki można przeliczyć. Rozpocznij nową kalibrację, aby przechwycić bieżący układ współrzędnych śledzenia. Niezgodność sumy kontrolnej modelu ręki Nie można załadować OpenCV + Syncthing + Syncthing + Synchronizuj przez Syncthing + Oparta na Syncthing-Fork implementacja Syncthing, która pozwala synchronizować pliki z wewnętrznego systemu plików DroidDeck z innymi instancjami Syncthing. Dotyczy tylko plików przechowywanych w wewnętrznym systemie plików DroidDeck. + Ta wersja nie zawiera Syncthing. + Uruchamianie Syncthing + Uruchamianie + Aktualne + Synchronizowanie + Wstrzymane + Wstrzymywanie + Wznawianie + Wymaga uwagi + Syncthing zatrzymany + Spróbuj ponownie + Wstrzymane do połączenia z Wi-Fi + Wstrzymane na czas gry + Foldery: %1$d · połączone urządzenia: %2$d z %3$d + Pozostało: %1$s + Otwórz interfejs WWW + Wstrzymaj + Wznów + Działa + Wyłącz, aby całkowicie zatrzymać Syncthing. + To urządzenie + ID urządzenia + Pokaż kod QR + Zaawansowane + Porty, kiedy uruchamiać, reset + Otwórz + Czeka na Ciebie + %1$s chce się połączyć + %1$s chce udostępnić „%2$s” + Zaakceptuj, aby wybrać miejsce zapisu w DroidDeck. + Sprawdź w interfejsie WWW + Foldery + Brak folderów. + %1$s · %2$s + Aktualny + Synchronizowanie %1$d%% + Skanowanie + Wstrzymany + Niezsynchronizowany + Nie udało się zsynchronizować plików: %1$d + Zablokowany + Ten folder znajduje się poza pamięcią DroidDeck, więc DroidDeck go wstrzymał. Aby synchronizować foldery spoza DroidDeck, użyj aplikacji Syncthing-Fork. + Usuń z Syncthing + Usunięcie nie kasuje żadnych plików. + Urządzenia + Brak urządzeń. + Połączone + Offline + Ostatnio widziane: %1$s + ID tego urządzenia + Na drugim urządzeniu dodaj urządzenie w Syncthing i zeskanuj ten kod lub wklej ID. Następnie zaakceptuj je tutaj w interfejsie WWW. + Kopiuj + Udostępnij + Gotowe + Wyłączyć Syncthing? + Syncthing zatrzyma się teraz i pozostanie zatrzymany, dopóki nie zostanie ponownie włączony. Zsynchronizowane pliki pozostaną na miejscu. + Usuń też konfigurację Syncthing + Usuwa ID tego urządzenia, foldery i urządzenia. Po ponownym włączeniu Syncthing zacznie od nowa. + Wyłącz + Porty + Synchronizacja + Wykrywanie lokalne + Wybierz nowe porty + Sprawdzane ponownie przy każdym uruchomieniu. + Wybierz ponownie + Reset + Klucz API + Wygeneruj ponownie + Zresetuj Syncthing + Nowe ID urządzenia, foldery i urządzenia zostaną usunięte. Pliki w pamięci DroidDeck zostaną zachowane. + Resetuj + Zresetować Syncthing? + Działanie + Uruchamiaj + Gdy DroidDeck jest otwarty + W tle + Tylko przez Wi-Fi + Wstrzymuj podczas gier + Logi + Dane wyjściowe Syncthing z tej sesji + Udostępnij + Syncthing + Najpierw skonfiguruj środowisko Linux. + Akceptuj + Zapisz „%1$s” w + Dodaj folder + Synchronizuj folder w pamięci DroidDeck + Wybierz + Dodaj + Nie udało się dodać folderu. + Udostępnij dla + Brak urządzeń. Ten folder możesz udostępnić później w interfejsie WWW. + Użyj tego folderu + Nowy folder + Katalog domowy kontenera + Folder Sync + Pamięć współdzielona DroidDeck + DroidDeck na %1$s + Dane aplikacji DroidDeck + Nowe foldery są zapisywane w folderze Sync DroidDeck. Aby synchronizować inny folder DroidDeck, użyj opcji Dodaj folder na karcie Syncthing. + Syncthing + Odśwież + Syncthing nie działa. Steam zatrzymał się na niedozwolonej instrukcji (SIGILL). Może to być problem ze zgodnością instrukcji CPU lub awaria klienta. Udostępnij logi wraz z danymi urządzenia. Możesz spróbować Ustawienia Steam → Klient Steam → x86-64 (eksperymentalny). Wymaga dodatkowego pobrania i może działać wolniej. Sterownik graficzny Linux nie znalazł używalnego urządzenia Vulkan. Sprawdź, czy wybrany sterownik GPU Linux obsługuje to GPU. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 95f89e1ca..43971b011 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -299,6 +299,8 @@ Comando Xbox 360 Deck: botão de Acesso rápido e giroscópio. Steam Controller: o mesmo, com sensor de pega nos botões traseiros. DualShock 4 e DualSense: giroscópio, e os trackpads como o painel tátil do comando. Xbox 360: abra o Acesso rápido com Guide+A. Voltar + Manter Voltar premido para atalhos do Steam + Manter Voltar premido abre o Acesso rápido ou cria combinações do Steam com outros botões. Desative para ignorar Voltar premido; toque e toque duplo continuam a funcionar. Mantenha Voltar premido durante 700 ms, ou mantenha Voltar premido e prima outro botão para atalhos do Steam. Intervalo do toque duplo %1$d ms @@ -656,6 +658,8 @@ Barra Bloquear posição Arraste o HUD para o mover; um toque longo bloqueia-o ou desbloqueia-o. Enquanto estiver bloqueado, os controlos no ecrã por baixo dele continuam a funcionar. + Encaixar nas laterais + Solte o HUD e ele vai para a lateral mais próxima do ecrã. Um gesto rápido lança-o nessa direção. Aspeto Tamanho no ecrã Suave @@ -1786,6 +1790,93 @@ Teclado + trackpad Terminal Pegas + trackpads do Deck + Painel + Ainda não há nada para mostrar aqui. + Transferências + Nenhuma transferência em curso. + Steam + A transferir a cache de shaders + Em pausa + A aguardar início + Notificações + Ecrã inferior do DroidDeck + Mostre aqui mensagens e outras notificações enquanto joga. Ficam apenas neste ecrã e não são guardadas. O Android pergunta uma vez: a página de acesso às notificações abre neste ecrã; ative lá o DroidDeck e esta página volta automaticamente. + Permitir acesso às notificações + Sem notificações. + %1$d notificações + Limpar + Capturas de ecrã + O FPS requer o HUD ativado + Velocidade + Pedais + Força G + Tempos + Melhor volta + Pneus °C + Acelerador e travão, últimos 10 s + Velocidade, %1$s + Mudança + Unidade de velocidade + Automático (%1$s) + Cliente Steam ARM + Cliente Steam x86 + Acelerador + Travão + Painel + Engrenar mudança + Travão de mão + Limitador de pit + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Óleo %1$s °C + Distribuidor de energia + Combustível, t + Principal %1$s t + Reserva %1$s t + Toque na faixa superior ou no painel para alterar o estilo. + Enviar dados para o painel + Automático + Ativa para todos os jogos aqui listados, incluindo os encontrados mais tarde. Os jogos que desativar permanecem desativados. + Isto altera a definição de saída de dados do próprio jogo. Aplica-se da próxima vez que o jogo iniciar; desativar repõe a definição anterior. + Feche %1$s para alterar isto. + Não foi possível alterar as definições de %1$s. + %1$s: ative %2$s nas definições do jogo com o IP 127.0.0.1 e a porta %3$d. + Guardadas em Pictures/DroidDeck. As capturas de ecrã do Steam também aparecem aqui. + Fazer captura de ecrã + Captura de ecrã guardada. + Não foi possível fazer a captura de ecrã. + Ainda não há capturas de ecrã. + Chat e voz + Leia e responda ao chat do Steam aqui enquanto o jogo continua a correr no ecrã principal. Utiliza a conta com que o Steam tem sessão iniciada. + O Steam ainda não tem uma sessão web para partilhar, por isso inicie sessão aqui uma vez. + O Android fechou a página do chat enquanto o DroidDeck estava em segundo plano. Volta a abrir quando regressar ao DroidDeck. + Abrir o Steam Chat + Fechar chat + Microfone ligado + Microfone desligado + O microfone está desligado neste jogo. Para falar, ative Microfone nas definições do Steam do DroidDeck (Áudio) e volte a iniciar o jogo. + Volume %1$d%% + Estatísticas no jogo + FPS + CPU + GPU + Bateria + Volta + Posição + Volta atual + Última volta + Combustível + Carga + Pips SYS · ENG · WEP + Estado legal + Saldo + War Thunder + Elite Dangerous + Ative a saída de dados de um jogo de corridas (muitas vezes chamada telemetria ou UDP) para ver aqui um painel em direto. Os dados ficam neste dispositivo. + A porta %1$d está a receber dados (%2$d pacotes de %3$d bytes), mas num formato que esta página ainda não consegue ler. + Outro programa já utiliza a porta %1$s, por isso esse jogo não pode aparecer aqui. A remover o runtime Linux @@ -2117,6 +2208,7 @@ %1$s Define a escala como %2$s. Igual ao ecrã (%1$d×%2$d) Seguir o ecrã (redimensiona ao dobrar ou desdobrar) + Escalado %1$s Steam Mostrar detalhes do dispositivo Ocultar detalhes do dispositivo @@ -3278,6 +3370,113 @@ As visualizações guardadas podem ser recalculadas. Inicie uma nova calibração para captar no sistema de coordenadas de seguimento atual. Incompatibilidade de checksum do modelo de mãos O OpenCV não conseguiu carregar + Syncthing + Syncthing + Sincronizar com o Syncthing + Baseada no Syncthing-Fork, esta é uma implementação do Syncthing que lhe permite sincronizar ficheiros do sistema de ficheiros interno do DroidDeck com outras instâncias do Syncthing. Aplica-se apenas aos ficheiros que guarda no sistema de ficheiros interno do DroidDeck. + Esta compilação não inclui o Syncthing. + A iniciar o Syncthing + A iniciar + Atualizado + A sincronizar + Em pausa + A pausar + A retomar + Requer atenção + O Syncthing parou + Tentar novamente + Em pausa até haver Wi-Fi + Em pausa durante o jogo + %1$d pastas · %2$d de %3$d dispositivos ligados + Faltam %1$s + Abrir interface web + Pausar + Retomar + Em execução + Desative para parar o Syncthing por completo. + Este dispositivo + ID do dispositivo + Mostrar QR + Avançado + Portas, quando executar, repor + Abrir + À sua espera + %1$s quer ligar-se + %1$s quer partilhar “%2$s” + Aceite para escolher onde é guardada no DroidDeck. + Rever na interface web + Pastas + Ainda não há pastas. + %1$s · %2$s + Atualizada + A sincronizar %1$d%% + A analisar + Em pausa + Dessincronizada + Não foi possível sincronizar %1$d ficheiros + Bloqueada + Esta pasta está fora do armazenamento do DroidDeck, por isso o DroidDeck colocou-a em pausa. Para sincronizar pastas fora do DroidDeck, utilize a app Syncthing-Fork. + Remover do Syncthing + Removê-la não elimina nenhum ficheiro. + Dispositivos + Ainda não há dispositivos. + Ligado + Offline + Visto pela última vez: %1$s + ID deste dispositivo + No outro dispositivo, adicione um dispositivo no Syncthing e leia este código ou cole o ID. Depois, aceite-o aqui na interface web. + Copiar + Partilhar + Concluído + Desativar o Syncthing? + O Syncthing para agora e continua parado até voltar a ser ativado. Os ficheiros sincronizados ficam onde estão. + Eliminar também a configuração do Syncthing + Remove o ID deste dispositivo, as pastas e os dispositivos. Ao voltar a ativá-lo, começa do zero. + Desativar + Portas + Sincronização + Descoberta local + Escolher novas portas + Verificadas novamente em cada início. + Escolher novamente + Repor + Chave da API + Regenerar + Repor o Syncthing + Novo ID do dispositivo; pastas e dispositivos removidos. Os ficheiros no armazenamento do DroidDeck são mantidos. + Repor + Repor o Syncthing? + Execução + Executar + Enquanto o DroidDeck estiver aberto + Em segundo plano + Apenas Wi-Fi + Pausar durante os jogos + Registos + Saída do Syncthing nesta sessão + Partilhar + Syncthing + Configure primeiro o ambiente de execução Linux. + Aceitar + Guardar “%1$s” em + Adicionar pasta + Sincronizar uma pasta no armazenamento do DroidDeck + Escolher + Adicionar + Não foi possível adicionar a pasta. + Partilhar com + Ainda não há dispositivos. Pode partilhar esta pasta mais tarde a partir da interface web. + Utilizar esta pasta + Nova pasta + Pasta pessoal do contentor + Pasta Sync + Armazenamento partilhado do DroidDeck + DroidDeck em %1$s + Dados da app DroidDeck + As novas pastas são guardadas na pasta Sync do DroidDeck. Para sincronizar outra pasta do DroidDeck, utilize Adicionar pasta no separador Syncthing. + Syncthing + Recarregar + O Syncthing não está em execução. O Steam parou com uma instrução ilegal (SIGILL). Pode ser um problema de compatibilidade de instruções da CPU ou uma falha do cliente. Partilhe os registos com os detalhes do seu dispositivo. Pode experimentar Definições do Steam → Cliente Steam → x86-64 (experimental). Requer uma transferência adicional e pode ser mais lento. O controlador gráfico Linux não encontrou um dispositivo Vulkan utilizável. Verifique se o controlador de GPU Linux selecionado suporta esta GPU. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 881edaee6..47cfdd17e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -299,6 +299,8 @@ Controle de Xbox 360 Deck: botão de Acesso rápido e giroscópio. Steam Controller: o mesmo, com detecção de pegada nos botões traseiros. DualShock 4 e DualSense: giroscópio, e os trackpads como o touchpad deles. Xbox 360: abra o Acesso rápido com Guide+A. Voltar + Segurar Voltar para atalhos do Steam + Segurar Voltar abre o Acesso rápido ou cria combinações do Steam com outros botões. Desative para ignorar Voltar segurado; toque e toque duplo continuam funcionando. Segure Voltar por 700 ms, ou segure Voltar e pressione outro botão para atalhos da Steam. Intervalo do toque duplo %1$d ms @@ -656,6 +658,8 @@ Barra Travar posição Arraste o HUD para movê-lo; um toque longo o trava ou destrava. Enquanto estiver travado, os controles na tela embaixo dele continuam funcionando. + Encaixar nas laterais + Solte o HUD e ele vai para a lateral mais próxima da tela. Um gesto rápido o lança nessa direção. Aparência Tamanho na tela Suave @@ -1786,6 +1790,93 @@ Teclado + trackpad Terminal Botões traseiros + trackpads do Deck + Painel + Nada para mostrar aqui ainda. + Downloads + Nenhum download em andamento. + Steam + Baixando o cache de shaders + Pausado + Aguardando para começar + Notificações + Tela inferior do DroidDeck + Mostre aqui mensagens e outras notificações enquanto você joga. Elas ficam só nesta tela e não são salvas. O Android pergunta uma vez: a página de acesso a notificações dele abre nesta tela; ative o DroidDeck lá e esta página volta sozinha. + Permitir acesso a notificações + Nenhuma notificação. + %1$d notificações + Limpar + Capturas de tela + O FPS precisa do HUD ativado + Velocidade + Pedais + Força G + Tempos + Melhor volta + Pneus °C + Acelerador e freio, últimos 10 s + Velocidade, %1$s + Marcha + Unidade de velocidade + Automático (%1$s) + Cliente Steam ARM + Cliente Steam x86 + Acelerador + Freio + Painel + Troca de marcha + Freio de mão + Limitador de pit + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Óleo %1$s °C + Distribuidor de energia + Combustível, t + Principal %1$s t + Reserva %1$s t + Toque na faixa superior ou no painel para mudar o estilo. + Enviar dados para o painel + Automático + Ativa para todos os jogos listados aqui, incluindo os encontrados depois. Os jogos que você desativar continuam desativados. + Isso altera a configuração de saída de dados do próprio jogo. Vale a partir da próxima vez que o jogo iniciar; desativar restaura a configuração anterior. + Feche %1$s para alterar isso. + Não foi possível alterar as configurações de %1$s. + %1$s: ative %2$s nas configurações do jogo com o IP 127.0.0.1 e a porta %3$d. + Salvas em Pictures/DroidDeck. As capturas de tela da Steam também aparecem aqui. + Fazer captura de tela + Captura de tela salva. + Não foi possível fazer a captura de tela. + Nenhuma captura de tela ainda. + Chat e voz + Leia e responda o chat da Steam aqui enquanto o jogo continua rodando na tela principal. Usa a conta com que a Steam está conectada. + A Steam ainda não tem um login web para compartilhar, então entre aqui uma vez. + O Android fechou a página do chat enquanto o DroidDeck estava em segundo plano. Ela abre de novo quando você voltar ao DroidDeck. + Abrir o Steam Chat + Fechar chat + Microfone ligado + Microfone desligado + O microfone está desligado neste jogo. Para falar, ative Microfone nas configurações da Steam do DroidDeck (Áudio) e inicie o jogo de novo. + Volume %1$d%% + Estatísticas no jogo + FPS + CPU + GPU + Bateria + Volta + Posição + Volta atual + Última volta + Combustível + Carga + Pips SYS · ENG · WEP + Situação legal + Saldo + War Thunder + Elite Dangerous + Ative a saída de dados de um jogo de corrida (muitas vezes chamada de telemetria ou UDP) para ver um painel ao vivo aqui. Os dados ficam neste dispositivo. + A porta %1$d está recebendo dados (%2$d pacotes de %3$d bytes), mas em um formato que esta página ainda não consegue ler. + Outro programa já usa a porta %1$s, então esse jogo não pode aparecer aqui. Removendo o runtime Linux @@ -2117,6 +2208,7 @@ %1$s Define a escala como %2$s. Igual à tela (%1$d×%2$d) Seguir a tela (redimensiona ao dobrar ou desdobrar) + Escalado %1$s Steam Mostrar detalhes do dispositivo Ocultar detalhes do dispositivo @@ -3278,6 +3370,113 @@ As visualizações salvas podem ser recalculadas. Inicie uma nova calibração para capturar no sistema de coordenadas de rastreamento atual. Incompatibilidade de soma de verificação do modelo de mãos OpenCV não pôde carregar + Syncthing + Syncthing + Sincronizar com o Syncthing + Baseada no Syncthing-Fork, esta é uma implementação do Syncthing que permite sincronizar arquivos do sistema de arquivos interno do DroidDeck com outras instâncias do Syncthing. Vale apenas para arquivos que você guarda no sistema de arquivos interno do DroidDeck. + Esta versão não inclui o Syncthing. + Iniciando o Syncthing + Iniciando + Atualizado + Sincronizando + Pausado + Pausando + Retomando + Requer atenção + O Syncthing parou + Tentar de novo + Pausado até conectar ao Wi-Fi + Pausado durante o jogo + %1$d pastas · %2$d de %3$d dispositivos conectados + Faltam %1$s + Abrir interface web + Pausar + Retomar + Em execução + Desative para parar o Syncthing completamente. + Este dispositivo + ID do dispositivo + Mostrar QR + Avançado + Portas, quando executar, redefinir + Abrir + Aguardando você + %1$s quer se conectar + %1$s quer compartilhar “%2$s” + Aceite para escolher onde salvar no DroidDeck. + Revisar na interface web + Pastas + Nenhuma pasta ainda. + %1$s · %2$s + Atualizada + Sincronizando %1$d%% + Verificando + Pausada + Fora de sincronia + %1$d arquivos não puderam ser sincronizados + Bloqueada + Esta pasta está fora do armazenamento do DroidDeck, então o DroidDeck a pausou. Para sincronizar pastas fora do DroidDeck, use o app Syncthing-Fork. + Remover do Syncthing + Remover não apaga nenhum arquivo. + Dispositivos + Nenhum dispositivo ainda. + Conectado + Offline + Visto por último: %1$s + ID deste dispositivo + No outro dispositivo, adicione um dispositivo no Syncthing e escaneie este código ou cole o ID. Depois, aceite-o aqui na interface web. + Copiar + Compartilhar + Concluído + Desativar o Syncthing? + O Syncthing para agora e continua parado até ser ativado de novo. Os arquivos sincronizados ficam onde estão. + Também apagar a configuração do Syncthing + Remove o ID deste dispositivo, as pastas e os dispositivos. Ao ativar de novo, ele começa do zero. + Desativar + Portas + Sincronização + Descoberta local + Escolher novas portas + Verificadas novamente a cada início. + Escolher de novo + Redefinir + Chave da API + Gerar novamente + Redefinir o Syncthing + Novo ID do dispositivo; pastas e dispositivos removidos. Os arquivos no armazenamento do DroidDeck são mantidos. + Redefinir + Redefinir o Syncthing? + Execução + Executar + Enquanto o DroidDeck estiver aberto + Em segundo plano + Somente Wi-Fi + Pausar durante os jogos + Logs + Saída do Syncthing nesta sessão + Compartilhar + Syncthing + Configure primeiro o runtime do Linux. + Aceitar + Salvar “%1$s” em + Adicionar pasta + Sincronizar uma pasta no armazenamento do DroidDeck + Escolher + Adicionar + Não foi possível adicionar a pasta. + Compartilhar com + Nenhum dispositivo ainda. Você pode compartilhar esta pasta pela interface web depois. + Usar esta pasta + Nova pasta + Pasta pessoal do contêiner + Pasta Sync + Armazenamento compartilhado do DroidDeck + DroidDeck em %1$s + Dados do app DroidDeck + Novas pastas são salvas na pasta Sync do DroidDeck. Para sincronizar outra pasta do DroidDeck, use Adicionar pasta na aba Syncthing. + Syncthing + Recarregar + O Syncthing não está em execução. A Steam parou com uma instrução ilegal (SIGILL). Pode ser um problema de compatibilidade de instruções da CPU ou uma falha do cliente. Compartilhe os logs com os detalhes do seu dispositivo. Você pode tentar Configurações da Steam → Cliente Steam → x86-64 (experimental). Requer um download adicional e pode rodar mais devagar. O driver gráfico do Linux não encontrou um dispositivo Vulkan utilizável. Verifique se o driver de GPU Linux selecionado é compatível com esta GPU. diff --git a/app/src/main/res/values-ro/gestures.xml b/app/src/main/res/values-ro/gestures.xml index 50ab4e0ab..da13d5f14 100644 --- a/app/src/main/res/values-ro/gestures.xml +++ b/app/src/main/res/values-ro/gestures.xml @@ -71,7 +71,7 @@ Backspace Enter Tab - Space + Spațiu Delete Insert Home diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c6c6b2ce9..d972f63c6 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -299,6 +299,8 @@ Controller Xbox 360 Deck: butonul Acces rapid și giroscopul. Steam Controller: la fel, cu senzori de strângere pe butoanele din spate. DualShock 4 și DualSense: giroscop, și trackpadurile ca touchpad propriu. Xbox 360: deschide Acces rapid cu Guide+A. Înapoi + Ține apăsat Înapoi pentru comenzi Steam + Dacă ții apăsat Înapoi se deschide Acces rapid sau se fac combinații Steam cu alte butoane. Dezactivează pentru a ignora apăsarea lungă; atingerea și dubla atingere funcționează în continuare. Ține apăsat Înapoi 700 ms sau ține apăsat Înapoi și apasă alt buton pentru comenzile rapide Steam. Interval pentru atingere dublă %1$d ms @@ -656,6 +658,8 @@ Bară Blochează pe loc Trage HUD-ul pentru a-l muta; o apăsare lungă îl blochează sau îl deblochează. Cât timp e blocat, comenzile de pe ecran de sub el funcționează în continuare. + Fixare în lateral + Eliberează HUD-ul și se mută în partea mai apropiată a ecranului. Un gest rapid îl aruncă în acea direcție. Aspect Dimensiune pe ecran Discretă @@ -1609,7 +1613,7 @@ Ctrl Super Alt - Space + Spațiu AltGr Tab %1$s ✓ @@ -1794,6 +1798,93 @@ Tastatură + trackpad Terminal Mânere + trackpaduri Deck + Tablou de bord + Nimic de afișat aici încă. + Descărcări + Nicio descărcare în curs. + Steam + Se descarcă cache-ul de shadere + Întrerupt + Așteaptă să înceapă + Notificări + Ecranul de jos DroidDeck + Afișează aici mesajele și alte notificări în timp ce te joci. Rămân doar pe acest ecran și nu sunt salvate. Android întreabă o singură dată: pagina sa de acces la notificări se deschide pe acest ecran; activează DroidDeck acolo, iar această pagină revine singură. + Permite accesul la notificări + Nicio notificare. + Notificări: %1$d + Șterge + Capturi de ecran + FPS necesită HUD-ul activat + Viteză + Pedale + Forță G + Cronometrare + Cel mai bun tur + Anvelope °C + Accelerație și frână, ultimele 10 s + Viteză, %1$s + Treaptă + Unități de viteză + Automat (%1$s) + Client Steam ARM + Client Steam x86 + Accelerație + Frână + Bord + Schimbă treapta + Frână de mână + Limitator de boxe + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Ulei %1$s °C + Distribuitor de putere + Combustibil, t + Principal %1$s t + Rezervă %1$s t + Atinge banda de sus sau bordul pentru a-i schimba stilul. + Trimite date pentru tabloul de bord + Automat + Activează trimiterea pentru fiecare joc din listă, inclusiv pentru jocurile găsite mai târziu. Jocurile pe care le dezactivezi rămân dezactivate. + Aceasta modifică setarea de ieșire a datelor din joc. Intră în vigoare la următoarea pornire a jocului; dezactivarea readuce setarea veche. + Închide %1$s pentru a schimba asta. + Nu s-au putut modifica setările pentru %1$s. + %1$s: activează %2$s în setările jocului, cu IP-ul 127.0.0.1 și portul %3$d. + Salvate în Pictures/DroidDeck. Capturile de ecran Steam apar și ele aici. + Fă o captură de ecran + Captură de ecran salvată. + Nu s-a putut face o captură de ecran. + Încă nu există capturi de ecran. + Chat și voce + Citește și răspunde aici la chatul Steam, în timp ce jocul continuă să ruleze pe ecranul principal. Folosește contul cu care este conectat Steam. + Steam nu are încă o conectare web de partajat, așa că conectează-te aici o dată. + Android a închis pagina de chat cât timp DroidDeck era în fundal. Se redeschide când revii în DroidDeck. + Deschide Steam Chat + Închide chatul + Microfon pornit + Microfon oprit + Microfonul este oprit pentru acest joc. Pentru a vorbi, activează Microfon în setările Steam din DroidDeck (Sunet) și pornește din nou jocul. + Volum %1$d%% + Statistici în joc + FPS + CPU + GPU + Baterie + Tur + Poziție + Turul curent + Ultimul tur + Combustibil + Încărcătură + Puncte SYS · ENG · WEP + Statut legal + Sold + War Thunder + Elite Dangerous + Activează ieșirea de date a unui joc de curse (numită adesea telemetrie sau UDP) pentru a vedea aici un bord în timp real. Datele rămân pe acest dispozitiv. + Portul %1$d primește date (%2$d pachete de %3$d octeți), dar într-un format pe care această pagină nu îl poate citi încă. + Alt program folosește deja portul %1$s, așa că acel joc nu poate fi afișat aici. Se elimină mediul de execuție Linux @@ -2125,6 +2216,7 @@ %1$s Setează scalarea la %2$s. Potrivire ecran (%1$d×%2$d) Urmărește ecranul (se redimensionează la împăturire sau desfacere) + Scalat %1$s Steam Arată detaliile dispozitivului Ascunde detaliile dispozitivului @@ -2744,7 +2836,7 @@ Nu acum Urmărirea mâinilor cu camera Folosește camera telefonului pentru gesturi cu mâinile goale sau pentru a poziționa controlerele Joy-Con asociate în VR. Controlerele Joy-Con oferă comenzi de rotație și comenzi de butoane. Pune pauză în timp ce ochelarii inteligenți urmăresc mâinile sau controlerele Joy-Con. Imaginile sunt procesate numai pe acest dispozitiv. - Camera + Cameră Acces la cameră Permite accesul la cameră pentru a putea începe urmărirea mâinilor. Accesul la cameră este dezactivat. Activează-l din setările Android. @@ -3279,6 +3371,113 @@ Vizualizările salvate pot fi recalculate. Începe o nouă calibrare pentru a captura în sistemul de coordonate de urmărire curent. Nepotrivire a sumei de control a modelului de mâini OpenCV nu s-a putut încărca + Syncthing + Syncthing + Sincronizează cu Syncthing + Bazată pe Syncthing-Fork, aceasta este o implementare Syncthing care îți permite să sincronizezi fișierele din sistemul de fișiere intern al DroidDeck cu alte instanțe Syncthing. Este doar pentru fișierele pe care le păstrezi în sistemul de fișiere intern al DroidDeck. + Această versiune nu include Syncthing. + Se pornește Syncthing + Se pornește + Actualizat + Se sincronizează + Întrerupt + Se întrerupe + Se reia + Necesită atenție + Syncthing s-a oprit + Încearcă din nou + Întrerupt până la Wi-Fi + Întrerupt în timpul jocului + Foldere: %1$d · dispozitive conectate: %2$d din %3$d + Rămas: %1$s + Deschide interfața web + Întrerupe + Reia + Rulează + Dezactivează pentru a opri complet Syncthing. + Acest dispozitiv + ID dispozitiv + Arată codul QR + Avansat + Porturi, când să ruleze, resetare + Deschide + Te așteaptă + %1$s vrea să se conecteze + %1$s vrea să partajeze „%2$s” + Acceptă pentru a alege unde este salvat în DroidDeck. + Verifică în interfața web + Foldere + Niciun folder încă. + %1$s · %2$s + Actualizat + Se sincronizează %1$d%% + Se scanează + Întrerupt + Nesincronizat + Fișiere care nu s-au putut sincroniza: %1$d + Blocat + Acest folder se află în afara spațiului de stocare DroidDeck, așa că DroidDeck l-a întrerupt. Pentru a sincroniza foldere din afara DroidDeck, folosește aplicația Syncthing-Fork. + Elimină din Syncthing + Eliminarea nu șterge niciun fișier. + Dispozitive + Niciun dispozitiv încă. + Conectat + Offline + Văzut ultima dată %1$s + ID-ul acestui dispozitiv + Pe celălalt dispozitiv, adaugă un dispozitiv în Syncthing și scanează acest cod sau lipește ID-ul. Apoi acceptă-l aici, în interfața web. + Copiază + Partajează + Gata + Dezactivezi Syncthing? + Syncthing se oprește acum și rămâne oprit până când îl activezi din nou. Fișierele sincronizate rămân unde sunt. + Șterge și configurația Syncthing + Elimină ID-ul acestui dispozitiv, folderele și dispozitivele. La reactivare, pornește de la zero. + Dezactivează + Porturi + Sincronizare + Descoperire locală + Alege porturi noi + Verificate din nou la fiecare pornire. + Alege din nou + Resetare + Cheie API + Regenerează + Resetează Syncthing + ID de dispozitiv nou, folderele și dispozitivele sunt eliminate. Fișierele din spațiul de stocare DroidDeck sunt păstrate. + Resetează + Resetezi Syncthing? + Rulare + Rulează + Cât timp DroidDeck este deschis + În fundal + Doar prin Wi-Fi + Întrerupe în timpul jocurilor + Jurnale + Ieșirea Syncthing pentru această sesiune + Partajează + Syncthing + Instalează mai întâi mediul de execuție Linux. + Acceptă + Salvează „%1$s” în + Adaugă folder + Sincronizează un folder din spațiul de stocare DroidDeck + Alege + Adaugă + Folderul nu a putut fi adăugat. + Partajează cu + Niciun dispozitiv încă. Poți partaja acest folder mai târziu din interfața web. + Folosește acest folder + Folder nou + Directorul home al containerului + Folderul Sync + Stocarea partajată DroidDeck + DroidDeck pe %1$s + Datele aplicației DroidDeck + Folderele noi sunt salvate în folderul Sync al DroidDeck. Pentru a sincroniza alt folder DroidDeck, folosește Adaugă folder din fila Syncthing. + Syncthing + Reîncarcă + Syncthing nu rulează. Steam s-a oprit cu o instrucțiune ilegală (SIGILL). Poate fi o problemă de compatibilitate a instrucțiunilor CPU sau o blocare a clientului. Partajează jurnalele împreună cu detaliile dispozitivului. Poți încerca Setări Steam → Client Steam → x86-64 (experimental). Necesită o descărcare suplimentară și poate rula mai lent. Driverul grafic Linux nu a găsit un dispozitiv Vulkan utilizabil. Verifică dacă driverul GPU Linux selectat acceptă acest GPU. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 150314ef5..ab175f12e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -299,6 +299,8 @@ Контроллер Xbox 360 Deck: кнопка быстрого доступа и гироскоп. Steam Controller: то же самое, с датчиком хвата на задних кнопках. DualShock 4 и DualSense: гироскоп, а трекпады работают как их тачпад. Xbox 360: используйте Guide+A для быстрого доступа. Назад + Удержание «Назад» для сочетаний Steam + Удержание «Назад» открывает быстрый доступ или создаёт сочетания Steam с другими кнопками. Выключите, чтобы удержание игнорировалось; нажатие и двойное нажатие работают. Удерживайте «Назад» 700 мс или удерживайте «Назад» и нажмите другую кнопку для сочетаний Steam. Интервал двойного нажатия %1$d мс @@ -658,6 +660,8 @@ Полоса Закрепить на месте Перетащите HUD, чтобы переместить его; долгое нажатие закрепляет или открепляет его. Пока он закреплён, экранные элементы управления под ним продолжают работать. + Прилипание к бокам + Отпустите HUD, и он переместится к ближайшей стороне экрана. Резкий жест отбросит его в ту сторону. Внешний вид Размер на экране Мягкий @@ -1816,6 +1820,93 @@ Клавиатура + тачпад Терминал Грипсы и тачпады Deck + Дашборд + Здесь пока ничего нет. + Загрузки + Нет активных загрузок. + Steam + Загрузка кэша шейдеров + Приостановлено + Ожидает начала + Уведомления + Нижний экран DroidDeck + Показывает сообщения и другие уведомления здесь, пока вы играете. Они отображаются только на этом экране и не сохраняются. Android спросит один раз: на этом экране откроется страница доступа к уведомлениям; включите там DroidDeck, и эта страница вернется сама. + Разрешить доступ к уведомлениям + Нет уведомлений. + Уведомлений: %1$d + Очистить + Скриншоты + Для FPS нужно включить HUD + Скорость + Педали + Перегрузка + Время + Лучший круг + Шины, °C + Газ и тормоз за последние 10 s + Скорость, %1$s + Передача + Единицы скорости + Автоматически (%1$s) + Клиент Steam ARM + Клиент Steam x86 + Газ + Тормоз + Приборная панель + Переключение + Ручник + Ограничитель скорости + TC + ABS + Турбо %1$s bar + Двигатель %1$s °C + Масло %1$s °C + Распределитель энергии + Топливо, t + Основное %1$s t + Резерв %1$s t + Коснитесь верхней полосы или панели, чтобы сменить ее стиль. + Передавать данные на дашборд + Автоматически + Включает передачу для всех игр в этом списке, в том числе найденных позже. Игры, для которых вы ее выключили, остаются выключенными. + Это меняет собственную настройку вывода данных в игре. Изменение вступит в силу при следующем запуске игры; при выключении восстанавливается прежняя настройка. + Закройте %1$s, чтобы изменить это. + Не удалось изменить настройки %1$s. + %1$s: включите %2$s в настройках игры, указав IP 127.0.0.1 и порт %3$d. + Сохраняются в Pictures/DroidDeck. Скриншоты Steam тоже отображаются здесь. + Сделать скриншот + Скриншот сохранен. + Не удалось сделать скриншот. + Скриншотов пока нет. + Чат и голос + Читайте чат Steam и отвечайте в нем здесь, пока игра продолжает работать на основном экране. Используется аккаунт, в который выполнен вход в Steam. + У Steam пока нет веб-входа, которым можно поделиться, поэтому войдите здесь один раз. + Android закрыл страницу чата, пока DroidDeck работал в фоне. Она откроется снова, когда вы вернетесь в DroidDeck. + Открыть Steam Chat + Закрыть чат + Микрофон вкл. + Микрофон выкл. + Микрофон для этой игры выключен. Чтобы говорить, включите «Микрофон» в настройках Steam в DroidDeck (Аудио) и запустите игру снова. + Громкость %1$d%% + Игровая статистика + FPS + CPU + GPU + Батарея + Круг + Позиция + Текущий круг + Прошлый круг + Топливо + Груз + Деления SYS · ENG · WEP + Правовой статус + Баланс + War Thunder + Elite Dangerous + Включите вывод данных в гоночной игре (часто он называется телеметрией или UDP), чтобы видеть здесь приборную панель в реальном времени. Данные остаются на этом устройстве. + Порт %1$d получает данные (пакетов: %2$d, размер: %3$d байт), но в формате, который эта страница пока не умеет читать. + Порт %1$s уже занят другой программой, поэтому эта игра не может отображаться здесь. Удаление среды выполнения Linux @@ -2148,6 +2239,7 @@ %1$s Устанавливает масштабирование: %2$s. По размеру экрана (%1$d×%2$d) По экрану (меняется при складывании и раскладывании) + Масштаб %1$s Steam Показать сведения об устройстве Скрыть сведения об устройстве @@ -3311,6 +3403,113 @@ Сохраненные представления можно пересчитать. Запустите новую калибровку для захвата текущей системы координат отслеживания. Несоответствие контрольной суммы модели руки. OpenCV не удалось загрузить. + Syncthing + Syncthing + Синхронизация через Syncthing + Это реализация Syncthing на основе Syncthing-Fork, которая позволяет синхронизировать файлы из внутренней файловой системы DroidDeck с другими экземплярами Syncthing. Она работает только с файлами, которые хранятся во внутренней файловой системе DroidDeck. + Эта сборка не включает Syncthing. + Запуск Syncthing + Запуск + Актуально + Синхронизация + Приостановлено + Приостановка + Возобновление + Требует внимания + Syncthing остановлен + Повторить + Приостановлено до подключения к Wi-Fi + Приостановлено на время игры + Папок: %1$d · подключено устройств: %2$d из %3$d + Осталось: %1$s + Открыть веб-интерфейс + Приостановить + Возобновить + Работает + Выключите, чтобы полностью остановить Syncthing. + Это устройство + ID устройства + Показать QR-код + Дополнительно + Порты, режим работы, сброс + Открыть + Ожидает вашего ответа + %1$s хочет подключиться + %1$s хочет предоставить доступ к папке «%2$s» + Примите, чтобы выбрать, куда сохранить ее в DroidDeck. + Просмотреть в веб-интерфейсе + Папки + Папок пока нет. + %1$s · %2$s + Актуально + Синхронизация: %1$d%% + Сканирование + Приостановлено + Не синхронизировано + Не удалось синхронизировать файлов: %1$d + Заблокировано + Эта папка находится вне хранилища DroidDeck, поэтому DroidDeck приостановил ее. Чтобы синхронизировать папки вне DroidDeck, используйте приложение Syncthing-Fork. + Удалить из Syncthing + При удалении файлы не удаляются. + Устройства + Устройств пока нет. + Подключено + Не в сети + Последний раз в сети: %1$s + ID этого устройства + На другом устройстве добавьте устройство в Syncthing и отсканируйте этот код или вставьте ID. Затем примите его здесь, в веб-интерфейсе. + Копировать + Поделиться + Готово + Выключить Syncthing? + Syncthing остановится сейчас и не запустится, пока вы снова его не включите. Синхронизированные файлы останутся на месте. + Также удалить конфигурацию Syncthing + Удаляет ID этого устройства, папки и устройства. При повторном включении все начнется с нуля. + Выключить + Порты + Синхронизация + Локальное обнаружение + Выбрать новые порты + Проверяются заново при каждом запуске. + Выбрать заново + Сброс + Ключ API + Создать заново + Сбросить Syncthing + Новый ID устройства, папки и устройства удаляются. Файлы в хранилище DroidDeck сохраняются. + Сбросить + Сбросить Syncthing? + Работа + Работать + Пока DroidDeck открыт + В фоновом режиме + Только по Wi-Fi + Приостанавливать во время игр + Журналы + Вывод Syncthing за эту сессию + Поделиться + Syncthing + Сначала установите среду выполнения Linux. + Принять + Сохранить «%1$s» в + Добавить папку + Синхронизировать папку в хранилище DroidDeck + Выбрать + Добавить + Не удалось добавить папку. + Поделиться с + Устройств пока нет. Вы сможете предоставить доступ к этой папке позже в веб-интерфейсе. + Использовать эту папку + Новая папка + Домашняя папка контейнера + Папка Sync + Общее хранилище DroidDeck + DroidDeck на %1$s + Данные приложения DroidDeck + Новые папки сохраняются в папку Sync DroidDeck. Чтобы синхронизировать другую папку DroidDeck, используйте «Добавить папку» на вкладке Syncthing. + Syncthing + Перезагрузить + Syncthing не запущен. Steam остановился из-за недопустимой инструкции (SIGILL). Это может быть проблема совместимости инструкций CPU или сбой клиента. Отправьте журналы вместе с данными о вашем устройстве. Можно попробовать «Настройки Steam → Клиент Steam → x86-64 (экспериментально)». Потребуется дополнительная загрузка, и работа может быть медленнее. Графический драйвер Linux не нашёл подходящее устройство Vulkan. Проверьте, что выбранный драйвер GPU для Linux поддерживает этот GPU. diff --git a/app/src/main/res/values-sv/gestures.xml b/app/src/main/res/values-sv/gestures.xml index e8034b112..064ee4168 100644 --- a/app/src/main/res/values-sv/gestures.xml +++ b/app/src/main/res/values-sv/gestures.xml @@ -71,7 +71,7 @@ Backsteg Retur Tab - Space + Mellanslag Delete Insert Home diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index dae0c8db9..1115912ba 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -298,7 +298,9 @@ DualSense Xbox 360-kontroll Deck: snabbåtkomstknappen och gyro. Steam Controller: samma, med greppkänsla på bakknapparna. DualShock 4 och DualSense: gyro, och styrplattorna som deras pekplatta. Xbox 360: öppna snabbåtkomst med Guide+A. - Back + Bakåt + Håll Bakåt för Steam-genvägar + Håll Bakåt för att öppna Snabbåtkomst eller göra Steam-kombinationer med andra knappar. Stäng av för att ignorera hållen Bakåt; tryck och dubbeltryck fungerar fortfarande. Håll Back i 700 ms, eller håll Back och tryck på en annan knapp för Steam-genvägar. Tidsfönster för dubbeltryck %1$d ms @@ -638,10 +640,10 @@ Batteritemperatur Prestandagraf Färg - Mint + Mintgrön Vit Himmelsblå - Amber + Bärnsten Lila Opacitet Storlek @@ -654,6 +656,8 @@ List Lås på plats Dra HUD:en för att flytta den; ett långt tryck låser eller låser upp den. När den är låst fungerar skärmkontrollerna under den fortfarande. + Fäst mot sidorna + Släpp HUD:en så flyttas den till skärmens närmaste sida. En snabb svepning kastar den åt det hållet. Utseende Storlek på skärmen Mjuk @@ -1593,7 +1597,7 @@ Ctrl Super Alt - Space + Mellanslag AltGr Tab %1$s ✓ @@ -1745,7 +1749,7 @@ Vit Lila Grön - Amber + Bärnsten Röd Rosa A-knappen @@ -1772,6 +1776,93 @@ Tangentbord + styrplatta Terminal Deck-grepp + styrplattor + Översikt + Inget att visa här än. + Nedladdningar + Inga nedladdningar pågår. + Steam + Laddar ned shadercache + Pausad + Väntar på att starta + Aviseringar + DroidDecks nedre skärm + Visa meddelanden och andra aviseringar här medan du spelar. De visas bara på den här skärmen och sparas inte. Android frågar en gång: sidan för aviseringsåtkomst öppnas på den här skärmen; slå på DroidDeck där så kommer den här sidan tillbaka av sig själv. + Tillåt aviseringsåtkomst + Inga aviseringar. + %1$d aviseringar + Rensa + Skärmbilder + FPS kräver att HUD:en är på + Hastighet + Pedaler + G-kraft + Tidtagning + Bästa varv + Däck °C + Gas och broms, senaste 10 s + Hastighet, %1$s + Växel + Hastighetsenhet + Automatisk (%1$s) + Steam-klient ARM + Steam-klient x86 + Gas + Broms + Instrumentpanel + Växla + Handbroms + Depåbegränsare + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Olja %1$s °C + Kraftfördelare + Bränsle, t + Huvudtank %1$s t + Reserv %1$s t + Tryck på den övre remsan eller instrumentpanelen för att byta stil. + Skicka data till instrumentpanelen + Automatiskt + Slår på det för alla spel i listan, även spel som hittas senare. Spel som du stänger av förblir avstängda. + Detta ändrar spelets egen inställning för datautmatning. Ändringen gäller nästa gång spelet startar; när du stänger av den återställs den gamla inställningen. + Stäng %1$s för att ändra detta. + Det gick inte att ändra inställningarna för %1$s. + %1$s: slå på %2$s i spelets inställningar med IP 127.0.0.1 och port %3$d. + Sparas i Pictures/DroidDeck. Steam-skärmbilder visas också här. + Ta skärmbild + Skärmbilden har sparats. + Det gick inte att ta en skärmbild. + Inga skärmbilder än. + Chatt och röst + Läs och svara på Steam-chatten här medan spelet fortsätter att köras på huvudskärmen. Kontot som Steam är inloggat med används. + Steam har ingen webbinloggning att dela än, så logga in här en gång. + Android stängde chattsidan medan DroidDeck var i bakgrunden. Den öppnas igen när du kommer tillbaka till DroidDeck. + Öppna Steam Chat + Stäng chatten + Mikrofon på + Mikrofon av + Mikrofonen är avstängd för det här spelet. Slå på Mikrofon i DroidDecks Steam-inställningar (Ljud) och starta spelet igen för att prata. + Volym %1$d%% + Statistik i spelet + FPS + CPU + GPU + Batteri + Varv + Placering + Detta varv + Senaste varv + Bränsle + Last + Pips SYS · ENG · WEP + Rättslig status + Saldo + War Thunder + Elite Dangerous + Slå på datautmatning i ett racingspel (kallas ofta telemetri eller UDP) för att se en liveinstrumentpanel här. Datan stannar på den här enheten. + Port %1$d tar emot data (%2$d paket på %3$d byte), men i ett format som den här sidan inte kan läsa än. + Ett annat program använder redan port %1$s, så det spelet kan inte visas här. Tar bort Linux-körmiljön @@ -2102,6 +2193,7 @@ %1$s Ställer in skalning till %2$s. Matcha skärmen (%1$d×%2$d) Följ skärmen (ändrar storlek vid vikning) + Skalad %1$s Steam Visa enhetsinformation Dölj enhetsinformation @@ -3242,6 +3334,113 @@ Sparade vyer kan räknas om. Starta en ny kalibrering för att fånga i det aktuella spårningskoordinatsystemet. Kontrollsumman för handmodellen inte överensstämmer OpenCV kunde inte laddas + Syncthing + Syncthing + Synkronisera med Syncthing + Det här är en Syncthing-implementering baserad på Syncthing-Fork som låter dig synkronisera filer i DroidDecks interna filsystem med andra Syncthing-instanser. Den gäller bara filer som du lagrar i DroidDecks interna filsystem. + Den här versionen innehåller inte Syncthing. + Startar Syncthing + Startar + Aktuell + Synkroniserar + Pausad + Pausar + Återupptar + Behöver åtgärdas + Syncthing har stoppats + Försök igen + Pausad tills Wi-Fi finns + Pausad under spelet + %1$d mappar · %2$d av %3$d enheter anslutna + %1$s kvar + Öppna webbgränssnittet + Pausa + Återuppta + Körs + Stäng av för att stoppa Syncthing helt. + Den här enheten + Enhets-ID + Visa QR-kod + Avancerat + Portar, när den ska köras, återställning + Öppna + Väntar på dig + %1$s vill ansluta + %1$s vill dela ”%2$s” + Acceptera för att välja var den sparas i DroidDeck. + Granska i webbgränssnittet + Mappar + Inga mappar än. + %1$s · %2$s + Aktuell + Synkroniserar %1$d %% + Skannar + Pausad + Inte synkroniserad + %1$d filer kunde inte synkroniseras + Blockerad + Den här mappen ligger utanför DroidDecks lagring, så DroidDeck har pausat den. Använd appen Syncthing-Fork för att synkronisera mappar utanför DroidDeck. + Ta bort från Syncthing + Inga filer raderas när den tas bort. + Enheter + Inga enheter än. + Ansluten + Offline + Senast sedd %1$s + Den här enhetens ID + Lägg till en enhet i Syncthing på din andra enhet och skanna den här koden eller klistra in ID:t. Acceptera den sedan här i webbgränssnittet. + Kopiera + Dela + Klar + Stänga av Syncthing? + Syncthing stoppas nu och förblir stoppat tills det slås på igen. Synkroniserade filer ligger kvar där de är. + Radera även Syncthings konfiguration + Tar bort enhetens ID, mappar och enheter. När du slår på det igen börjar det från början. + Stäng av + Portar + Synkronisering + Lokal identifiering + Välj nya portar + Kontrolleras igen vid varje start. + Välj om + Återställ + API-nyckel + Generera ny + Återställ Syncthing + Nytt enhets-ID, mappar och enheter tas bort. Filer i DroidDecks lagring behålls. + Återställ + Återställa Syncthing? + Körning + Kör + Medan DroidDeck är öppet + I bakgrunden + Endast Wi-Fi + Pausa under spel + Loggar + Syncthing-utdata för den här sessionen + Dela + Syncthing + Konfigurera Linux-körmiljön först. + Acceptera + Spara ”%1$s” i + Lägg till mapp + Synkronisera en mapp i DroidDecks lagring + Välj + Lägg till + Mappen kunde inte läggas till. + Dela med + Inga enheter än. Du kan dela den här mappen från webbgränssnittet senare. + Använd den här mappen + Ny mapp + Containerns hemmapp + Sync-mappen + DroidDecks delade lagring + DroidDeck på %1$s + DroidDecks appdata + Nya mappar sparas i DroidDecks Sync-mapp. Använd Lägg till mapp på fliken Syncthing för att synkronisera en annan DroidDeck-mapp. + Syncthing + Läs in igen + Syncthing körs inte. Steam stoppades av en otillåten instruktion (SIGILL). Det kan vara ett kompatibilitetsproblem med CPU-instruktioner eller en klientkrasch. Dela loggarna med uppgifter om din enhet. Du kan prova Steam-inställningar → Steam-klient → x86-64 (experimentellt). Det kräver en extra nedladdning och kan gå långsammare. Linux-grafikdrivrutinen hittade ingen användbar Vulkan-enhet. Kontrollera att den valda Linux-GPU-drivrutinen stöder den här GPU:n. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 0f26f3cf3..760bece48 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -299,6 +299,8 @@ คอนโทรลเลอร์ Xbox 360 Deck: ปุ่ม Quick Access และไจโร Steam Controller: เหมือนกัน พร้อมเซนเซอร์แรงจับที่ปุ่มด้านหลัง DualShock 4 และ DualSense: ไจโร และแทร็กแพดทำหน้าที่เป็นทัชแพด Xbox 360: เปิด Quick Access ด้วย Guide+A กลับ + กดค้างย้อนกลับสำหรับทางลัด Steam + กดค้างย้อนกลับเพื่อเปิดการเข้าถึงด่วนหรือใช้คอมโบ Steam กับปุ่มอื่น ปิดเพื่อไม่สนใจการกดค้าง การแตะและแตะสองครั้งยังใช้ได้ กดปุ่ม Back ค้างไว้ 700 ms หรือกด Back ค้างแล้วกดปุ่มอื่นเพื่อใช้ทางลัดของ Steam ช่วงเวลาแตะสองครั้ง %1$d ms @@ -453,7 +455,7 @@ นำเข้า AdrenoTools zip… ไม่เคย ตรวจสอบ Nightlies ครั้งล่าสุด %1$s - Components + คอมโพเนนต์ เกี่ยวกับ Components ตรวจสอบไดรเวอร์ใหม่ ตรวจสอบแพ็กเกจใหม่ใน Nightlies @@ -620,7 +622,7 @@ จำกัดเฟรมเรตของเกมนี้ หน้าจอจะเปลี่ยนไปใช้อัตรารีเฟรชที่หารด้วยค่าที่จำกัดได้ลงตัวหากมี ค่าอื่นอาจดูไม่สม่ำเสมอ เกม Steam ใช้ Frame Limit ของ Steam ในเมนู Quick Access เอฟเฟกต์ การควบคุม - Components + คอมโพเนนต์ คอนโทรลเลอร์ การตั้งค่า หน้า %1$s @@ -652,6 +654,8 @@ แถบ ล็อกตำแหน่ง ลาก HUD เพื่อย้าย กดค้างเพื่อล็อกหรือปลดล็อก ขณะล็อก ปุ่มควบคุมบนหน้าจอที่อยู่ด้านล่างยังใช้งานได้ + ยึดติดด้านข้าง + ปล่อย HUD แล้วจะย้ายไปด้านที่ใกล้กว่าของหน้าจอ ปัดเร็วๆ เพื่อเหวี่ยงไปทางนั้น รูปลักษณ์ ขนาดบนหน้าจอ อ่อน @@ -897,7 +901,7 @@ การกำหนดคอร์ CPU โฟลเดอร์ ROM ตำแหน่งที่เก็บเกมของโปรแกรมจำลอง - Components + คอมโพเนนต์ FEX, DXVK, VKD3D การควบคุม การกำหนดปุ่ม @@ -1056,7 +1060,7 @@ จับคู่แล้ว กรอกพอร์ตจากการตั้งค่า พบอุปกรณ์แล้ว กำลังปรับใช้การตั้งค่า… ไม่สามารถปรับใช้โดยอัตโนมัติได้ ตรวจสอบที่อยู่ด้านล่างแล้วลองใหม่ %1$s - Wireless debugging + การแก้ไขข้อบกพร่องแบบไร้สาย อัปเดตขีดจำกัดโพรเซสลูกแล้ว เปลี่ยนขีดจำกัดโพรเซสลูก หากไม่พบตัวเลือกนี้ ให้จับคู่ผ่าน Wireless debugging @@ -1251,7 +1255,7 @@ ไลบรารี เดสก์ท็อป สโตร์ - Components + คอมโพเนนต์ แอป การตั้งค่า การอัปเดต @@ -1750,6 +1754,93 @@ แป้นพิมพ์ + แทร็กแพด เทอร์มินัล ปุ่มด้านหลังและแทร็กแพดของ Deck + แดชบอร์ด + ยังไม่มีอะไรให้แสดงที่นี่ + ดาวน์โหลด + ไม่มีการดาวน์โหลดที่กำลังทำงาน + Steam + กำลังดาวน์โหลดแคชเชเดอร์ + หยุดชั่วคราว + กำลังรอเริ่ม + การแจ้งเตือน + หน้าจอล่างของ DroidDeck + แสดงข้อความและการแจ้งเตือนอื่นๆ ที่นี่ขณะที่คุณเล่น การแจ้งเตือนจะแสดงบนหน้าจอนี้เท่านั้นและจะไม่ถูกบันทึก Android จะถามเพียงครั้งเดียว: หน้าการเข้าถึงการแจ้งเตือนจะเปิดขึ้นบนหน้าจอนี้ เปิด DroidDeck ที่นั่น แล้วหน้านี้จะกลับมาเอง + อนุญาตการเข้าถึงการแจ้งเตือน + ไม่มีการแจ้งเตือน + การแจ้งเตือน %1$d รายการ + ล้าง + ภาพหน้าจอ + ต้องเปิด HUD เพื่อแสดง FPS + ความเร็ว + แป้นเหยียบ + แรง G + การจับเวลา + รอบที่ดีที่สุด + ยาง °C + คันเร่งและเบรก 10 s ล่าสุด + ความเร็ว, %1$s + เกียร์ + หน่วยความเร็ว + อัตโนมัติ (%1$s) + ไคลเอนต์ Steam ARM + ไคลเอนต์ Steam x86 + คันเร่ง + เบรก + แผงหน้าปัด + เปลี่ยนเกียร์ + เบรกมือ + ตัวจำกัดความเร็วพิต + TC + ABS + เทอร์โบ %1$s bar + เครื่องยนต์ %1$s °C + น้ำมันเครื่อง %1$s °C + ตัวกระจายพลังงาน + เชื้อเพลิง, t + หลัก %1$s t + สำรอง %1$s t + แตะแถบด้านบนหรือหน้าปัดเพื่อเปลี่ยนรูปแบบ + ส่งข้อมูลแดชบอร์ด + อัตโนมัติ + เปิดใช้สำหรับทุกเกมที่แสดงที่นี่ รวมถึงเกมที่พบในภายหลัง เกมที่คุณปิดไว้จะยังคงปิดอยู่ + การตั้งค่านี้จะเปลี่ยนการตั้งค่าการส่งออกข้อมูลของเกมเอง และมีผลในครั้งถัดไปที่เกมเริ่มทำงาน การปิดจะคืนค่าการตั้งค่าเดิม + ปิด %1$s เพื่อเปลี่ยนการตั้งค่านี้ + เปลี่ยนการตั้งค่าของ %1$s ไม่ได้ + %1$s: เปิด %2$s ในการตั้งค่าของเกม โดยใช้ IP 127.0.0.1 และพอร์ต %3$d + บันทึกไว้ที่ Pictures/DroidDeck ภาพหน้าจอของ Steam จะแสดงที่นี่ด้วย + ถ่ายภาพหน้าจอ + บันทึกภาพหน้าจอแล้ว + ถ่ายภาพหน้าจอไม่ได้ + ยังไม่มีภาพหน้าจอ + แชทและเสียง + อ่านและตอบแชท Steam ที่นี่ขณะที่เกมยังทำงานบนหน้าจอหลัก โดยใช้บัญชีที่ลงชื่อเข้าใช้ Steam อยู่ + Steam ยังไม่มีการลงชื่อเข้าใช้เว็บที่แชร์ได้ โปรดลงชื่อเข้าใช้ที่นี่หนึ่งครั้ง + Android ปิดหน้าแชทขณะที่ DroidDeck ทำงานอยู่ในเบื้องหลัง หน้าแชทจะเปิดอีกครั้งเมื่อคุณกลับมาที่ DroidDeck + เปิด Steam Chat + ปิดแชท + ไมค์เปิด + ไมค์ปิด + ไมค์ปิดอยู่สำหรับเกมนี้ หากต้องการพูด ให้เปิดไมโครโฟนในการตั้งค่า Steam ของ DroidDeck (เสียง) แล้วเริ่มเกมอีกครั้ง + ระดับเสียง %1$d%% + สถิติในเกม + FPS + CPU + GPU + แบตเตอรี่ + รอบ + อันดับ + รอบนี้ + รอบที่แล้ว + เชื้อเพลิง + สินค้า + พิป SYS · ENG · WEP + สถานะทางกฎหมาย + ยอดเงิน + War Thunder + Elite Dangerous + เปิดการส่งออกข้อมูลของเกมแข่งรถ (มักเรียกว่าเทเลเมทรีหรือ UDP) เพื่อดูหน้าปัดแบบสดที่นี่ ข้อมูลจะอยู่บนอุปกรณ์นี้เท่านั้น + พอร์ต %1$d ได้รับข้อมูล (%2$d แพ็กเก็ต ขนาด %3$d ไบต์) แต่อยู่ในรูปแบบที่หน้านี้ยังอ่านไม่ได้ + มีโปรแกรมอื่นใช้พอร์ต %1$s อยู่แล้ว เกมนั้นจึงแสดงที่นี่ไม่ได้ กำลังลบรันไทม์ Linux @@ -2079,6 +2170,7 @@ %1$s ตั้งการปรับขนาดเป็น %2$s พอดีหน้าจอ (%1$d×%2$d) ตามหน้าจอ (ปรับขนาดเมื่อพับหรือกาง) + ปรับสเกล %1$s Steam แสดงรายละเอียดอุปกรณ์ ซ่อนรายละเอียดอุปกรณ์ @@ -2269,7 +2361,7 @@ การเลือกของ Steam ต่อเกม เสียง DirectAudio - Components + คอมโพเนนต์ FEX, DXVK, VKD3D เอนจิน %1$s · เนทีฟ @@ -3215,6 +3307,113 @@ มุมมองที่บันทึกไว้สามารถคำนวณใหม่ได้ เริ่มการสอบเทียบใหม่เพื่อบันทึกในระบบพิกัดการติดตามปัจจุบัน ผลรวมตรวจสอบแบบจำลองมือไม่ตรงกัน OpenCV ไม่สามารถโหลดได้ + Syncthing + Syncthing + ซิงค์ด้วย Syncthing + นี่คือ Syncthing ที่อิงจาก Syncthing-Fork ซึ่งช่วยให้คุณซิงค์ไฟล์ในระบบไฟล์ภายในของ DroidDeck กับ Syncthing เครื่องอื่นได้ ใช้ได้เฉพาะกับไฟล์ที่คุณจัดเก็บไว้ในระบบไฟล์ภายในของ DroidDeck เท่านั้น + บิลด์นี้ไม่มี Syncthing + กำลังเริ่ม Syncthing + กำลังเริ่ม + เป็นปัจจุบัน + กำลังซิงค์ + หยุดชั่วคราว + กำลังหยุดชั่วคราว + กำลังทำต่อ + ต้องตรวจสอบ + Syncthing หยุดทำงาน + ลองอีกครั้ง + หยุดชั่วคราวจนกว่าจะเชื่อมต่อ Wi-Fi + หยุดชั่วคราวระหว่างเล่นเกม + %1$d โฟลเดอร์ · เชื่อมต่อแล้ว %2$d จาก %3$d อุปกรณ์ + เหลืออีก %1$s + เปิดเว็บ UI + หยุดชั่วคราว + ทำต่อ + กำลังทำงาน + ปิดเพื่อหยุด Syncthing ทั้งหมด + อุปกรณ์นี้ + ID อุปกรณ์ + แสดง QR + ขั้นสูง + พอร์ต เวลาที่ทำงาน การรีเซ็ต + เปิด + รอคุณอยู่ + %1$s ต้องการเชื่อมต่อ + %1$s ต้องการแชร์ “%2$s” + ยอมรับเพื่อเลือกตำแหน่งที่จะบันทึกใน DroidDeck + ตรวจสอบในเว็บ UI + โฟลเดอร์ + ยังไม่มีโฟลเดอร์ + %1$s · %2$s + เป็นปัจจุบัน + กำลังซิงค์ %1$d%% + กำลังสแกน + หยุดชั่วคราว + ไม่ตรงกัน + ซิงค์ไม่ได้ %1$d ไฟล์ + ถูกบล็อก + โฟลเดอร์นี้อยู่นอกพื้นที่จัดเก็บของ DroidDeck ดังนั้น DroidDeck จึงหยุดโฟลเดอร์นี้ชั่วคราว หากต้องการซิงค์โฟลเดอร์ที่อยู่นอก DroidDeck ให้ใช้แอป Syncthing-Fork + นำออกจาก Syncthing + การนำออกจะไม่ลบไฟล์ใดๆ + อุปกรณ์ + ยังไม่มีอุปกรณ์ + เชื่อมต่อแล้ว + ออฟไลน์ + พบล่าสุด %1$s + ID ของอุปกรณ์นี้ + บนอุปกรณ์อีกเครื่อง ให้เพิ่มอุปกรณ์ใน Syncthing แล้วสแกนโค้ดนี้หรือวาง ID จากนั้นยอมรับอุปกรณ์นั้นที่นี่ในเว็บ UI + คัดลอก + แชร์ + เสร็จสิ้น + ปิด Syncthing? + Syncthing จะหยุดทันทีและหยุดอยู่จนกว่าจะเปิดอีกครั้ง ไฟล์ที่ซิงค์แล้วจะยังอยู่ที่เดิม + ลบการกำหนดค่าของ Syncthing ด้วย + นำ ID ของอุปกรณ์นี้ โฟลเดอร์ และอุปกรณ์ออก เมื่อเปิดอีกครั้งจะเริ่มต้นใหม่ทั้งหมด + ปิด + พอร์ต + ซิงค์ + การค้นหาในเครือข่ายท้องถิ่น + เลือกพอร์ตใหม่ + ตรวจสอบอีกครั้งทุกครั้งที่เริ่ม + เลือกใหม่ + รีเซ็ต + คีย์ API + สร้างใหม่ + รีเซ็ต Syncthing + ได้ ID อุปกรณ์ใหม่ และนำโฟลเดอร์กับอุปกรณ์ออก ไฟล์ในพื้นที่จัดเก็บของ DroidDeck จะยังคงอยู่ + รีเซ็ต + รีเซ็ต Syncthing? + การทำงาน + ทำงาน + ขณะที่ DroidDeck เปิดอยู่ + ในเบื้องหลัง + Wi-Fi เท่านั้น + หยุดชั่วคราวระหว่างเล่นเกม + ล็อก + เอาต์พุตของ Syncthing สำหรับเซสชันนี้ + แชร์ + Syncthing + ตั้งค่ารันไทม์ Linux ก่อน + ยอมรับ + บันทึก “%1$s” ไปที่ + เพิ่มโฟลเดอร์ + ซิงค์โฟลเดอร์ในพื้นที่จัดเก็บของ DroidDeck + เลือก + เพิ่ม + เพิ่มโฟลเดอร์ไม่ได้ + แชร์กับ + ยังไม่มีอุปกรณ์ คุณสามารถแชร์โฟลเดอร์นี้จากเว็บ UI ได้ในภายหลัง + ใช้โฟลเดอร์นี้ + โฟลเดอร์ใหม่ + โฮมของคอนเทนเนอร์ + โฟลเดอร์ Sync + พื้นที่จัดเก็บที่ใช้ร่วมกันของ DroidDeck + DroidDeck บน %1$s + ข้อมูลแอป DroidDeck + โฟลเดอร์ใหม่จะบันทึกไว้ในโฟลเดอร์ Sync ของ DroidDeck หากต้องการซิงค์โฟลเดอร์อื่นของ DroidDeck ให้ใช้ เพิ่มโฟลเดอร์ ในแท็บ Syncthing + Syncthing + โหลดซ้ำ + Syncthing ไม่ได้ทำงานอยู่ Steam หยุดทำงานเพราะคำสั่งที่ไม่ถูกต้อง (SIGILL) อาจเป็นปัญหาความเข้ากันได้ของคำสั่ง CPU หรือไคลเอนต์ขัดข้อง แชร์บันทึกพร้อมรายละเอียดอุปกรณ์ของคุณ คุณสามารถลองการตั้งค่า Steam → ไคลเอนต์ Steam → x86-64 (ทดลอง) ต้องดาวน์โหลดเพิ่มเติมและอาจทำงานช้าลง ไดรเวอร์กราฟิก Linux ไม่พบอุปกรณ์ Vulkan ที่ใช้งานได้ ตรวจสอบว่าไดรเวอร์ GPU ของ Linux ที่เลือกรองรับ GPU นี้ @@ -3235,7 +3434,7 @@ ไม่เกิน 4 ไม่เกิน 1 ไม่มี - Wireless debugging + การแก้ไขข้อบกพร่องแบบไร้สาย จับคู่ DroidDeck กับ Wireless debugging ของ Android สำหรับตัวเลือกด้านบน ขีดจำกัดโพรเซสลูกยังคงปิดอยู่ จับคู่ กู้คืนมุมมองที่บันทึกไว้แล้ว %1$d รายการ คำนวณการสอบเทียบเพื่อลองอีกครั้ง หรือเริ่มชุดใหม่เพื่อบันทึกข้อมูลเพิ่มเติม diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 63df7aac7..6a995967c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -299,6 +299,8 @@ Xbox 360 kumandası Deck: Hızlı Erişim düğmesi ve jiroskop. Steam Controller: aynısı, ayrıca arka düğmelerde kavrama algılama. DualShock 4 ve DualSense: jiroskop; dokunmatik yüzeyler kumandanın dokunmatik paneli olur. Xbox 360: Hızlı Erişim\'i Guide+A ile açın. Geri + Steam kısayolları için Geri\'yi basılı tut + Geri\'yi basılı tutmak Hızlı Erişim\'i açar veya diğer düğmelerle Steam kombinasyonları yapar. Basılı tutmayı yok saymak için kapatın; dokunma ve çift dokunma çalışmaya devam eder. Steam kısayolları için Geri\'yi 700 ms basılı tutun veya Geri\'yi basılı tutup başka bir düğmeye basın. Çift dokunma aralığı %1$d ms @@ -654,6 +656,8 @@ Şerit Yerinde kilitle Taşımak için HUD\'u sürükleyin; uzun basmak onu kilitler veya kilidini açar. Kilitliyken altındaki ekran kontrolleri çalışmaya devam eder. + Yanlara yapıştır + HUD\'u bıraktığınızda ekranın daha yakın tarafına gider. Hızlı bir kaydırma onu o yöne fırlatır. Görünüm Ekrandaki boyut Yumuşak @@ -1764,6 +1768,93 @@ Klavye + dokunmatik yüzey Terminal Deck tutamakları + dokunmatik yüzeyler + Gösterge paneli + Burada henüz gösterilecek bir şey yok. + İndirmeler + Devam eden indirme yok. + Steam + Gölgelendirici önbelleği indiriliyor + Duraklatıldı + Başlamayı bekliyor + Bildirimler + DroidDeck alt ekranı + Oynarken mesajları ve diğer bildirimleri burada görün. Yalnızca bu ekranda kalırlar ve kaydedilmezler. Android bunu bir kez sorar: bildirim erişimi sayfası bu ekranda açılır; orada DroidDeck\'i açın, bu sayfa kendiliğinden geri gelir. + Bildirim erişimine izin ver + Bildirim yok. + %1$d bildirim + Temizle + Ekran görüntüleri + FPS için HUD açık olmalı + Hız + Pedallar + G kuvveti + Zamanlama + En iyi tur + Lastikler °C + Gaz ve fren, son 10 s + Hız, %1$s + Vites + Hız birimi + Otomatik (%1$s) + Steam İstemcisi ARM + Steam İstemcisi x86 + Gaz + Fren + Gösterge ışıkları + Vites uyarısı + El freni + Pit hız sınırlayıcı + TC + ABS + Turbo %1$s bar + Motor %1$s °C + Yağ %1$s °C + Güç dağıtıcı + Yakıt, t + Ana %1$s t + Yedek %1$s t + Stilini değiştirmek için üst şeride veya göstergeye dokunun. + Gösterge verilerini gönder + Otomatik + Sonradan bulunanlar dahil, burada listelenen her oyun için açar. Kapattığınız oyunlar kapalı kalır. + Bu, oyunun kendi veri çıkışı ayarını değiştirir. Oyun bir sonraki başlatılışında geçerli olur; kapatmak eski ayarı geri getirir. + Bunu değiştirmek için %1$s oyununu kapatın. + %1$s ayarları değiştirilemedi. + %1$s: oyunun ayarlarında IP 127.0.0.1 ve %3$d portuyla %2$s seçeneğini açın. + Pictures/DroidDeck klasörüne kaydedilir. Steam ekran görüntüleri de burada görünür. + Ekran görüntüsü al + Ekran görüntüsü kaydedildi. + Ekran görüntüsü alınamadı. + Henüz ekran görüntüsü yok. + Sohbet ve ses + Oyun ana ekranda çalışmaya devam ederken Steam sohbetini burada okuyun ve yanıtlayın. Steam\'de oturum açılmış hesabı kullanır. + Steam\'in paylaşabileceği bir web oturumu henüz yok, bu yüzden burada bir kez oturum açın. + DroidDeck arka plandayken Android sohbet sayfasını kapattı. DroidDeck\'e döndüğünüzde yeniden açılır. + Steam Chat\'i aç + Sohbeti kapat + Mikrofon açık + Mikrofon kapalı + Bu oyun için mikrofon kapalı. Konuşmak için DroidDeck\'in Steam ayarlarında (Ses) Mikrofon\'u açın ve oyunu yeniden başlatın. + Ses düzeyi %1$d%% + Oyun içi istatistikler + FPS + CPU + GPU + Pil + Tur + Sıra + Bu tur + Son tur + Yakıt + Kargo + Güç SYS · ENG · WEP + Yasal durum + Bakiye + War Thunder + Elite Dangerous + Burada canlı bir gösterge görmek için yarış oyununun veri çıkışını (genellikle telemetri veya UDP olarak geçer) açın. Veriler bu cihazda kalır. + %1$d portu veri alıyor (%3$d baytlık %2$d paket), ancak bu sayfanın henüz okuyamadığı bir biçimde. + %1$s portunu zaten başka bir program kullanıyor, bu yüzden o oyun burada gösterilemez. Linux çalışma ortamı kaldırılıyor @@ -2094,6 +2185,7 @@ %1$s Ölçeklemeyi %2$s olarak ayarlar. Ekrana Uydur (%1$d×%2$d) Ekranı takip et (katlanıp açıldığında yeniden boyutlanır) + Ölçekli %1$s Steam Cihaz ayrıntılarını göster Cihaz ayrıntılarını gizle @@ -3240,6 +3332,113 @@ Kaydedilen görünümler yeniden hesaplanabilir. Geçerli izleme koordinat sistemini yakalamak için yeni bir kalibrasyon başlatın. El modeli sağlama toplamı uyuşmazlığı OpenCV yüklenemedi + Syncthing + Syncthing + Syncthing ile eşitle + Syncthing-Fork tabanlı bu Syncthing uygulaması, DroidDeck\'in dahili dosya sistemindeki dosyaları diğer Syncthing örnekleriyle eşitlemenizi sağlar. Yalnızca DroidDeck\'in dahili dosya sisteminde sakladığınız dosyalar içindir. + Bu sürüm Syncthing içermiyor. + Syncthing başlatılıyor + Başlatılıyor + Güncel + Eşitleniyor + Duraklatıldı + Duraklatılıyor + Sürdürülüyor + İlgilenmeniz gerekiyor + Syncthing durdu + Tekrar dene + Wi-Fi\'ye bağlanana kadar duraklatıldı + Oyun sırasında duraklatıldı + %1$d klasör · %3$d cihazdan %2$d tanesi bağlı + %1$s kaldı + Web arayüzünü aç + Duraklat + Sürdür + Çalışıyor + Syncthing\'i tamamen durdurmak için kapatın. + Bu cihaz + Cihaz Kimliği + QR kodu göster + Gelişmiş + Bağlantı noktaları, ne zaman çalışacağı, sıfırlama + Aç + Onayınızı bekliyor + %1$s bağlanmak istiyor + %1$s “%2$s” klasörünü paylaşmak istiyor + DroidDeck\'te nereye kaydedileceğini seçmek için kabul edin. + Web arayüzünde incele + Klasörler + Henüz klasör yok. + %1$s · %2$s + Güncel + Eşitleniyor %%%1$d + Taranıyor + Duraklatıldı + Eşitlenmemiş + %1$d dosya eşitlenemedi + Engellendi + Bu klasör DroidDeck\'in depolama alanının dışında olduğu için DroidDeck onu duraklattı. DroidDeck dışındaki klasörleri eşitlemek için Syncthing-Fork uygulamasını kullanın. + Syncthing\'den kaldır + Kaldırmak hiçbir dosyayı silmez. + Cihazlar + Henüz cihaz yok. + Bağlı + Çevrimdışı + Son görülme: %1$s + Bu cihazın kimliği + Diğer cihazınızda Syncthing\'e bir cihaz ekleyin ve bu kodu tarayın ya da kimliği yapıştırın. Ardından burada web arayüzünden kabul edin. + Kopyala + Paylaş + Bitti + Syncthing kapatılsın mı? + Syncthing şimdi durur ve yeniden açılana kadar kapalı kalır. Eşitlenen dosyalar yerinde kalır. + Syncthing yapılandırmasını da sil + Bu cihazın kimliğini, klasörleri ve cihazları kaldırır. Yeniden açıldığında sıfırdan başlar. + Kapat + Bağlantı noktaları + Eşitleme + Yerel keşif + Yeni bağlantı noktaları seç + Her başlangıçta yeniden denetlenir. + Yeniden seç + Sıfırla + API anahtarı + Yeniden oluştur + Syncthing\'i sıfırla + Yeni cihaz kimliği oluşturulur, klasörler ve cihazlar kaldırılır. DroidDeck\'in depolama alanındaki dosyalar korunur. + Sıfırla + Syncthing sıfırlansın mı? + Çalışma + Çalıştırma + DroidDeck açıkken + Arka planda + Yalnızca Wi-Fi + Oyunlar sırasında duraklat + Günlükler + Bu oturumun Syncthing çıktısı + Paylaş + Syncthing + Önce Linux çalışma zamanını yükleyin. + Kabul et + “%1$s” klasörünün kaydedileceği yer + Klasör ekle + DroidDeck\'in depolama alanındaki bir klasörü eşitleyin + Seç + Ekle + Klasör eklenemedi. + Paylaşılacak cihazlar + Henüz cihaz yok. Bu klasörü daha sonra web arayüzünden paylaşabilirsiniz. + Bu klasörü kullan + Yeni klasör + Konteyner ana dizini + Sync klasörü + DroidDeck paylaşılan depolama alanı + %1$s üzerindeki DroidDeck + DroidDeck uygulama verileri + Yeni klasörler DroidDeck\'in Sync klasörüne kaydedilir. Başka bir DroidDeck klasörünü eşitlemek için Syncthing sekmesindeki Klasör ekle seçeneğini kullanın. + Syncthing + Yeniden yükle + Syncthing çalışmıyor. Steam geçersiz bir komutla durdu (SIGILL). Bu bir CPU komut uyumluluğu sorunu veya istemci çökmesi olabilir. Günlükleri cihaz bilgilerinizle paylaşın. Steam ayarları → Steam istemcisi → x86-64 (deneysel) seçeneğini deneyebilirsiniz. Ek bir indirme gerektirir ve daha yavaş çalışabilir. Linux grafik sürücüsü kullanılabilir bir Vulkan cihazı bulamadı. Seçilen Linux GPU sürücüsünün bu GPU\'yu desteklediğini kontrol edin. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f92771ae7..e2199fe60 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -299,6 +299,8 @@ Контролер Xbox 360 Deck: кнопка швидкого доступу й гіроскоп. Steam Controller: те саме плюс датчики хвату на задніх кнопках. DualShock 4 і DualSense: гіроскоп, а трекпади працюють як їхня сенсорна панель. Xbox 360: меню швидкого доступу відкривається комбінацією Guide+A. Кнопка Back + Утримання «Назад» для комбінацій Steam + Утримання «Назад» відкриває швидкий доступ або створює комбінації Steam з іншими кнопками. Вимкніть, щоб утримання ігнорувалося; натискання й подвійне натискання працюють. Утримуйте «Назад» 700 мс або утримуйте «Назад» і натисніть іншу кнопку для комбінацій Steam. Інтервал подвійного натискання %1$d мс @@ -658,6 +660,8 @@ Смуга Закріпити на місці Перетягніть HUD, щоб перемістити його; довге натискання закріплює або відкріплює його. Поки його закріплено, екранні елементи керування під ним працюють і далі. + Прилипання до боків + Відпустіть HUD, і він переміститься до ближчого боку екрана. Різкий жест відкине його в той бік. Вигляд Розмір на екрані М\'який @@ -1816,6 +1820,93 @@ Клавіатура + трекпад Термінал Задні кнопки + трекпади Deck + Панель + Тут поки нічого показати. + Завантаження + Немає активних завантажень. + Steam + Завантаження кешу шейдерів + Призупинено + Очікує початку + Сповіщення + Нижній екран DroidDeck + Показуйте тут повідомлення та інші сповіщення під час гри. Вони лишаються лише на цьому екрані й не зберігаються. Android запитає один раз: сторінка доступу до сповіщень відкриється на цьому екрані; увімкніть там DroidDeck, і ця сторінка повернеться сама. + Дозволити доступ до сповіщень + Немає сповіщень. + Сповіщень: %1$d + Очистити + Знімки екрана + Для FPS потрібно ввімкнути HUD + Швидкість + Педалі + Перевантаження + Час + Найкраще коло + Шини °C + Газ і гальмо, останні 10 s + Швидкість, %1$s + Передача + Одиниці швидкості + Автоматично (%1$s) + Клієнт Steam ARM + Клієнт Steam x86 + Газ + Гальмо + Панель приладів + Перемикання + Ручне гальмо + Обмежувач піт-лейну + TC + ABS + Турбо %1$s bar + Двигун %1$s °C + Олива %1$s °C + Розподільник енергії + Пальне, t + Основне %1$s t + Резерв %1$s t + Торкніться верхньої смуги або панелі, щоб змінити її стиль. + Надсилати дані для панелі + Автоматично + Вмикає це для кожної гри в цьому списку, зокрема для знайдених пізніше. Ігри, які ви вимкнете, лишаються вимкненими. + Це змінює власне налаштування виведення даних у грі. Зміна діє з наступного запуску гри; вимкнення повертає попереднє налаштування. + Закрийте %1$s, щоб змінити це. + Не вдалося змінити налаштування %1$s. + %1$s: увімкніть %2$s у налаштуваннях гри з IP 127.0.0.1 і портом %3$d. + Зберігаються в Pictures/DroidDeck. Знімки екрана Steam також показуються тут. + Зробити знімок екрана + Знімок екрана збережено. + Не вдалося зробити знімок екрана. + Ще немає знімків екрана. + Чат і голос + Читайте чат Steam і відповідайте в ньому тут, поки гра працює на головному екрані. Використовується обліковий запис, у який виконано вхід у Steam. + У Steam ще немає вебвходу, яким можна поділитися, тож увійдіть тут один раз. + Android закрив сторінку чату, поки DroidDeck був у фоні. Вона знову відкриється, коли ви повернетеся до DroidDeck. + Відкрити Steam Chat + Закрити чат + Мікрофон увімкнено + Мікрофон вимкнено + Для цієї гри мікрофон вимкнено. Щоб говорити, увімкніть «Мікрофон» у налаштуваннях Steam у DroidDeck (Звук) і запустіть гру знову. + Гучність %1$d%% + Ігрова статистика + FPS + CPU + GPU + Батарея + Коло + Позиція + Поточне коло + Останнє коло + Пальне + Вантаж + Енергія SYS · ENG · WEP + Правовий статус + Баланс + War Thunder + Elite Dangerous + Увімкніть виведення даних у гоночній грі (часто це називається телеметрією або UDP), щоб бачити тут живу панель. Дані лишаються на цьому пристрої. + Порт %1$d отримує дані (пакетів: %2$d, по %3$d байт), але у форматі, який ця сторінка ще не вміє читати. + Порт %1$s уже використовує інша програма, тому ця гра не може відображатися тут. Видалення середовища Linux @@ -2148,6 +2239,7 @@ %1$s Встановлює масштабування на %2$s. Під екран (%1$d×%2$d) Слідувати за екраном (змінює розмір при згортанні чи розгортанні) + Масштаб %1$s Steam Показати відомості про пристрій Сховати відомості про пристрій @@ -3314,6 +3406,113 @@ Збережені перегляди можна перерахувати. Розпочніть нове калібрування для запису в поточній системі координат відстеження. Невідповідність контрольної суми моделі руки Не вдалося завантажити OpenCV + Syncthing + Syncthing + Синхронізація через Syncthing + Це реалізація Syncthing на основі Syncthing-Fork, яка дає змогу синхронізувати файли у внутрішній файловій системі DroidDeck з іншими екземплярами Syncthing. Вона працює лише з файлами, які ви зберігаєте у внутрішній файловій системі DroidDeck. + Ця збірка не містить Syncthing. + Запуск Syncthing + Запуск + Синхронізовано + Синхронізація + Призупинено + Призупинення + Відновлення + Потрібна увага + Syncthing зупинено + Повторити + Призупинено до підключення до Wi-Fi + Призупинено на час гри + Папок: %1$d · підключено пристроїв: %2$d з %3$d + Залишилося: %1$s + Відкрити вебінтерфейс + Призупинити + Відновити + Працює + Вимкніть, щоб повністю зупинити Syncthing. + Цей пристрій + ID пристрою + Показати QR-код + Додатково + Порти, коли працювати, скидання + Відкрити + Чекає на вас + %1$s хоче підключитися + %1$s хоче надати доступ до «%2$s» + Прийміть, щоб вибрати, куди зберігати її в DroidDeck. + Переглянути у вебінтерфейсі + Папки + Папок ще немає. + %1$s · %2$s + Синхронізовано + Синхронізація: %1$d%% + Сканування + Призупинено + Не синхронізовано + Не вдалося синхронізувати файлів: %1$d + Заблоковано + Ця папка розташована поза сховищем DroidDeck, тому DroidDeck її призупинив. Щоб синхронізувати папки поза DroidDeck, скористайтеся додатком Syncthing-Fork. + Видалити із Syncthing + Видалення не стирає жодних файлів. + Пристрої + Пристроїв ще немає. + Підключено + Не в мережі + Востаннє в мережі: %1$s + ID цього пристрою + На іншому пристрої додайте пристрій у Syncthing і відскануйте цей код або вставте ID. Потім прийміть його тут у вебінтерфейсі. + Копіювати + Поділитися + Готово + Вимкнути Syncthing? + Syncthing зупиниться зараз і не працюватиме, доки ви його знову не ввімкнете. Синхронізовані файли залишаться на місці. + Також видалити конфігурацію Syncthing + Видаляє ID цього пристрою, папки й пристрої. Після повторного ввімкнення все почнеться з нуля. + Вимкнути + Порти + Синхронізація + Локальне виявлення + Вибрати нові порти + Перевіряється під час кожного запуску. + Вибрати знову + Скидання + Ключ API + Згенерувати знову + Скинути Syncthing + Новий ID пристрою, папки й пристрої буде видалено. Файли у сховищі DroidDeck збережуться. + Скинути + Скинути Syncthing? + Робота + Режим роботи + Поки DroidDeck відкрито + У фоновому режимі + Лише Wi-Fi + Призупиняти під час ігор + Журнали + Вивід Syncthing за цей сеанс + Поділитися + Syncthing + Спершу встановіть середовище Linux. + Прийняти + Куди зберегти «%1$s» + Додати папку + Синхронізувати папку у сховищі DroidDeck + Вибрати + Додати + Не вдалося додати папку. + Надати доступ + Пристроїв ще немає. Ви зможете надати доступ до цієї папки пізніше у вебінтерфейсі. + Використати цю папку + Нова папка + Домашня папка контейнера + Папка Sync + Спільне сховище DroidDeck + DroidDeck на %1$s + Дані додатка DroidDeck + Нові папки зберігаються в папці Sync у DroidDeck. Щоб синхронізувати іншу папку DroidDeck, скористайтеся кнопкою «Додати папку» на вкладці Syncthing. + Syncthing + Перезавантажити + Syncthing не запущено. Steam зупинився через недопустиму інструкцію (SIGILL). Це може бути проблема сумісності інструкцій CPU або збій клієнта. Надішліть журнали разом із даними про ваш пристрій. Можна спробувати «Налаштування Steam → Клієнт Steam → x86-64 (експериментально)». Потрібне додаткове завантаження, і робота може бути повільнішою. Графічний драйвер Linux не знайшов придатного пристрою Vulkan. Перевірте, чи вибраний драйвер GPU для Linux підтримує цей GPU. diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 394378c93..5ef884eeb 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -299,6 +299,8 @@ Tay cầm Xbox 360 Deck: nút Quick Access và con quay hồi chuyển. Steam Controller: tương tự, cộng với cảm biến nắm ở các nút phía sau. DualShock 4 và DualSense: con quay hồi chuyển, và touchpad dùng làm bàn di chuột. Xbox 360: mở Quick Access bằng Guide+A. Quay lại + Giữ Quay lại cho phím tắt Steam + Giữ Quay lại để mở Truy cập nhanh hoặc tạo tổ hợp Steam với nút khác. Tắt để bỏ qua thao tác giữ; nhấn và nhấn đúp vẫn hoạt động. Giữ Back 700 ms, hoặc giữ Back và nhấn nút khác để dùng phím tắt Steam. Khoảng thời gian nhấn đúp %1$d ms @@ -652,6 +654,8 @@ Thanh Khóa vị trí Kéo HUD để di chuyển; nhấn giữ để khóa hoặc mở khóa. Khi đã khóa, các nút điều khiển trên màn hình bên dưới vẫn hoạt động. + Bám vào cạnh trái/phải + Thả HUD ra và nó sẽ di chuyển sang cạnh màn hình gần hơn. Vuốt nhanh để ném nó theo hướng đó. Giao diện Kích thước trên màn hình Dịu @@ -1750,6 +1754,93 @@ Bàn phím + trackpad Terminal Grip + trackpad Deck + Bảng điều khiển + Chưa có gì để hiển thị ở đây. + Tải xuống + Không có lượt tải nào đang chạy. + Steam + Đang tải bộ nhớ đệm shader + Đã tạm dừng + Đang chờ bắt đầu + Thông báo + Màn hình dưới DroidDeck + Hiển thị tin nhắn và các thông báo khác ở đây khi bạn chơi. Chúng chỉ ở trên màn hình này và không được lưu. Android chỉ hỏi một lần: trang quyền truy cập thông báo sẽ mở trên màn hình này; hãy bật DroidDeck ở đó và trang này sẽ tự quay lại. + Cho phép truy cập thông báo + Không có thông báo. + %1$d thông báo + Xóa + Ảnh chụp màn hình + Cần bật HUD để xem FPS + Tốc độ + Bàn đạp + Lực G + Thời gian + Vòng nhanh nhất + Lốp °C + Ga và phanh, 10 s gần nhất + Tốc độ, %1$s + Số + Đơn vị tốc độ + Tự động (%1$s) + Client Steam ARM + Client Steam x86 + Ga + Phanh + Đèn báo + Sang số + Phanh tay + Giới hạn tốc độ pit + TC + ABS + Turbo %1$s bar + Động cơ %1$s °C + Dầu %1$s °C + Bộ phân phối năng lượng + Nhiên liệu, t + Chính %1$s t + Dự trữ %1$s t + Nhấn vào dải phía trên hoặc bảng đồng hồ để đổi kiểu hiển thị. + Gửi dữ liệu bảng đồng hồ + Tự động + Bật cho mọi game trong danh sách này, kể cả game được tìm thấy sau này. Game bạn đã tắt sẽ vẫn tắt. + Thao tác này thay đổi thiết lập xuất dữ liệu của chính game. Thay đổi có hiệu lực từ lần chạy game tiếp theo; tắt đi sẽ khôi phục thiết lập cũ. + Hãy đóng %1$s để thay đổi mục này. + Không thể thay đổi thiết lập của %1$s. + %1$s: hãy bật %2$s trong thiết lập của game với IP 127.0.0.1 và cổng %3$d. + Được lưu vào Pictures/DroidDeck. Ảnh chụp màn hình Steam cũng hiện ở đây. + Chụp màn hình + Đã lưu ảnh chụp màn hình. + Không thể chụp màn hình. + Chưa có ảnh chụp màn hình. + Chat & thoại + Đọc và trả lời chat Steam ở đây trong khi game vẫn chạy trên màn hình chính. Dùng tài khoản đang đăng nhập Steam. + Steam chưa có phiên đăng nhập web để chia sẻ, vì vậy hãy đăng nhập ở đây một lần. + Android đã đóng trang chat khi DroidDeck chạy nền. Trang sẽ mở lại khi bạn quay lại DroidDeck. + Mở Steam Chat + Đóng chat + Micro bật + Micro tắt + Micro đang tắt cho game này. Để nói chuyện, hãy bật Micro trong thiết lập Steam của DroidDeck (Âm thanh) rồi khởi động lại game. + Âm lượng %1$d%% + Thông số trong game + FPS + CPU + GPU + Pin + Vòng + Vị trí + Vòng này + Vòng trước + Nhiên liệu + Hàng hóa + Năng lượng SYS · ENG · WEP + Tình trạng pháp lý + Số dư + War Thunder + Elite Dangerous + Bật xuất dữ liệu của game đua xe (thường gọi là telemetry hoặc UDP) để xem bảng đồng hồ trực tiếp ở đây. Dữ liệu chỉ ở trên thiết bị này. + Cổng %1$d đang nhận dữ liệu (%2$d gói, mỗi gói %3$d byte), nhưng ở định dạng mà trang này chưa đọc được. + Một chương trình khác đang dùng cổng %1$s, nên game đó không thể hiển thị ở đây. Đang gỡ runtime Linux @@ -2079,6 +2170,7 @@ %1$s Đặt chia tỷ lệ thành %2$s. Khớp màn hình (%1$d×%2$d) Theo màn hình (đổi kích thước khi gập hoặc mở) + Thu nhỏ %1$s Steam Hiện chi tiết thiết bị Ẩn chi tiết thiết bị @@ -3215,6 +3307,113 @@ Các lượt xem đã lưu có thể được tính toán lại. Bắt đầu hiệu chuẩn mới để chụp trong hệ tọa độ theo dõi hiện tại. Tổng kiểm tra mô hình bàn tay không khớp OpenCV không thể tải + Syncthing + Syncthing + Đồng bộ với Syncthing + Dựa trên Syncthing-Fork, đây là bản triển khai Syncthing cho phép bạn đồng bộ các tệp trong hệ thống tệp nội bộ của DroidDeck với các phiên bản Syncthing khác. Tính năng này chỉ dành cho các tệp bạn lưu trong hệ thống tệp nội bộ của DroidDeck. + Bản dựng này không có Syncthing. + Đang khởi động Syncthing + Đang khởi động + Đã cập nhật + Đang đồng bộ + Đã tạm dừng + Đang tạm dừng + Đang tiếp tục + Cần chú ý + Syncthing đã dừng + Thử lại + Tạm dừng cho đến khi có Wi-Fi + Tạm dừng trong khi chơi game + %1$d thư mục · %2$d/%3$d thiết bị đã kết nối + Còn %1$s + Mở giao diện web + Tạm dừng + Tiếp tục + Đang chạy + Tắt để dừng hoàn toàn Syncthing. + Thiết bị này + ID thiết bị + Hiện mã QR + Nâng cao + Cổng, thời điểm chạy, đặt lại + Mở + Đang chờ bạn + %1$s muốn kết nối + %1$s muốn chia sẻ “%2$s” + Chấp nhận để chọn nơi lưu thư mục này trong DroidDeck. + Xem lại trong giao diện web + Thư mục + Chưa có thư mục nào. + %1$s · %2$s + Đã cập nhật + Đang đồng bộ %1$d%% + Đang quét + Đã tạm dừng + Chưa đồng bộ + %1$d tệp không thể đồng bộ + Bị chặn + Thư mục này nằm ngoài bộ nhớ của DroidDeck nên DroidDeck đã tạm dừng nó. Để đồng bộ các thư mục bên ngoài DroidDeck, hãy dùng app Syncthing-Fork. + Xóa khỏi Syncthing + Xóa khỏi Syncthing không xóa tệp nào. + Thiết bị + Chưa có thiết bị nào. + Đã kết nối + Ngoại tuyến + Lần cuối thấy %1$s + ID của thiết bị này + Trên thiết bị còn lại, thêm thiết bị trong Syncthing rồi quét mã này hoặc dán ID. Sau đó chấp nhận ở đây trong giao diện web. + Sao chép + Chia sẻ + Xong + Tắt Syncthing? + Syncthing sẽ dừng ngay và không chạy cho đến khi được bật lại. Các tệp đã đồng bộ vẫn giữ nguyên vị trí. + Xóa cả cấu hình của Syncthing + Xóa ID của thiết bị này, các thư mục và thiết bị. Khi bật lại sẽ bắt đầu từ đầu. + Tắt + Cổng + Đồng bộ + Khám phá cục bộ + Chọn cổng mới + Được kiểm tra lại mỗi lần khởi động. + Chọn lại + Đặt lại + Khóa API + Tạo lại + Đặt lại Syncthing + ID thiết bị mới, các thư mục và thiết bị bị xóa. Tệp trong bộ nhớ của DroidDeck được giữ lại. + Đặt lại + Đặt lại Syncthing? + Chạy + Chạy + Khi DroidDeck đang mở + Trong nền + Chỉ Wi-Fi + Tạm dừng khi chơi game + Log + Đầu ra của Syncthing trong phiên này + Chia sẻ + Syncthing + Cài runtime Linux trước. + Chấp nhận + Lưu “%1$s” vào + Thêm thư mục + Đồng bộ một thư mục trong bộ nhớ của DroidDeck + Chọn + Thêm + Không thể thêm thư mục. + Chia sẻ với + Chưa có thiết bị nào. Bạn có thể chia sẻ thư mục này từ giao diện web sau. + Dùng thư mục này + Thư mục mới + Thư mục home của container + Thư mục Sync + Bộ nhớ dùng chung DroidDeck + DroidDeck trên %1$s + Dữ liệu app DroidDeck + Thư mục mới được lưu trong thư mục Sync của DroidDeck. Để đồng bộ một thư mục DroidDeck khác, hãy dùng Thêm thư mục trong thẻ Syncthing. + Syncthing + Tải lại + Syncthing không chạy. Steam đã dừng do lệnh không hợp lệ (SIGILL). Đây có thể là vấn đề tương thích lệnh CPU hoặc client bị lỗi. Hãy chia sẻ nhật ký cùng thông tin thiết bị của bạn. Bạn có thể thử Thiết lập Steam → Client Steam → x86-64 (thử nghiệm). Cần tải thêm và có thể chạy chậm hơn. Driver đồ họa Linux không tìm thấy thiết bị Vulkan dùng được. Hãy kiểm tra driver GPU Linux đã chọn có hỗ trợ GPU này không. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0ba87013e..66e8216ab 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -88,6 +88,7 @@ 设为 1 可让此游戏禁用 droiddeck-fsync;存在对应包时使用 droiddeck-esync,否则使用 droiddeck-ntsync。 设为 1 可让此游戏使用 droiddeck-fsync;设为 0 则在有匹配其 Proton 的包时优先使用 droiddeck-esync。 设为 0 可为此游戏禁用 Proton 的手柄导航助手。 + 默认开启。当游戏支持离线游玩时,以不加载反作弊的方式启动 Easy Anti-Cheat 游戏。关闭后将照常通过 Easy Anti-Cheat 启动。需要 Easy Anti-Cheat 的在线模式仍不可用。 针对 D3D9–11 的 DXVK 覆盖层。fps 显示帧率;不设置则隐藏。 以英文分号(;)分隔的 DXVK 设置。新版本使用 dxvk.maxFrameRate 限制帧率;日常游戏建议优先使用会话帧率限制。 此游戏使用的 CPU 核心,例如 0-4 或 0,2,4,用于替代会话的游戏核心。核心越少,温度越低。 @@ -298,6 +299,8 @@ Xbox 360 手柄 Deck:快捷访问按钮与陀螺仪。Xbox 360:用 Guide+A 打开快捷访问。 返回 + 长按返回键使用 Steam 快捷操作 + 长按返回键打开快速访问,或与其他按键组成 Steam 组合键。关闭后忽略长按;单击和双击仍然有效。 按住返回键 700 毫秒,或按住返回键再按另一个按钮以使用 Steam 快捷键。 双击间隔 %1$d 毫秒 @@ -326,6 +329,12 @@ 已是最新 · %1$s 加载器 安装最新版 + 更新通道 + 要安装哪种 Decky Loader 版本。Decky 自带的更新程序也使用相同的通道。 + 稳定版 + 预发布版 + 测试版 + 无法保存 Decky 更新通道 随 Steam 启动。开启期间,本设备上的其他应用可能可以控制 Steam。 已关闭。Steam 的远程调试端口保持关闭。 卸载 Decky Loader? @@ -335,6 +344,8 @@ 若游戏在 Deck 模式下崩溃或黑屏,请关闭性能覆盖层。将在下次会话生效。 游戏结束后返回 DroidDeck 从 DroidDeck 或其他启动器启动的游戏关闭后,Steam 会正常退出(先完成云存档同步),并返回启动位置。关闭:留在 Steam 中。 + 着色器预缓存 + Steam 会为你的游戏下载预编译的着色器并在后台处理,以减少游戏卡顿。关闭可节省流量、存储空间和电量;游戏会改为在运行时编译着色器。与 Steam 的“下载”>“着色器预缓存”为同一设置。下次会话时生效。 修复 Steam 客户端 下次启动 Steam 时重新下载 Steam 客户端。如果 Steam 一直停在加载画面,请使用此项。游戏、登录信息和设置都会保留。 下次启动 Steam 时将重新下载 Steam 客户端。 @@ -591,6 +602,8 @@ proot 快速路径 需要 proot 的 seccomp 过滤器。 文件查找更快、卡顿更少。若某个程序找不到它的文件,请关闭。 + 在 gamescope 内绘制光标 + 由 Gamescope 自行绘制指针,而不是交给 Android。使用手柄控制光标时请开启。在手机上关闭会更流畅。下次会话时生效。 会话标识 主机名 Steam 看到的本设备名称。留空恢复为 %1$s。 @@ -641,6 +654,8 @@ 横条 锁定位置 拖动 HUD 可移动位置;长按可锁定或解锁。锁定时,下方的屏幕控件仍可使用。 + 吸附到两侧 + 松开 HUD 后,它会移到屏幕较近的一侧。轻甩可将它甩向该方向。 外观 屏幕显示大小 柔和 @@ -1264,6 +1279,9 @@ 隐藏其他 GPU 的驱动 显示其他 GPU 的驱动 高级 + UBWC 兼容性 + 如果 Steam 或 Android 出现条纹或闪烁,请尝试开启或关闭。重启 DroidDeck 后对两种驱动均生效。 + 重启 DroidDeck 以应用。 与显示驱动一起设置:%1$s 同时携带两者。 与运行时驱动一起设置:%1$s 同时携带两者。 由游戏和桌面使用。 @@ -1372,6 +1390,8 @@ 自动进入画中画 展开屏幕上的控制 展开时,游戏画面位于 Steam Deck 触控板和背键上方。折叠时,游戏全屏显示。 + 连接手柄时也保留 + 连接手柄时也显示这些触控按键。 已暂停 会话就绪后即可使用画中画。 请在 Android 设置中允许 DroidDeck 的画中画。 @@ -1438,6 +1458,9 @@ 仍要安装 移除 Linux 运行时 这会删除运行时、其中的 Steam 客户端,以及安装在其中的所有游戏。 + Linux 运行时 + “修复”会重新安装运行时的系统文件,并保留 Steam、游戏和设置。桌面等附加组件会在下次使用时重新安装。“移除”会删除所有内容。 + 修复运行时 已应用:%1$s 无法在主屏幕上打开 %1$s 无法在 %2$s 上打开 %1$s @@ -1558,6 +1581,10 @@ 无法连接运行时目录。请检查网络连接,然后再次点“游玩”。 Linux 运行时未能安装。请在“配置”中检查运行时,然后再次点“游玩”。 桌面未能安装(%1$s)。请检查网络连接,然后再次点“桌面”。 + 无法完成之前中断的安装(%1$s)。请检查网络连接和存储空间,然后重试。 + + Linux 运行时已损坏:%1$d 个库链接指向缺失的文件(%2$s 等),通常是安装中断所致。修复会保留你的游戏和设置。 + %1$s 意外停止。请将日志随错误报告一起分享,或重试。 程序意外停止。请将日志随错误报告一起分享,或重试。 桌面意外停止。请将日志随错误报告一起分享,或重试。 @@ -1571,6 +1598,12 @@ 安装插件 ZIP 包,将在下次 Steam 会话中生效。 正在下载… 从 ZIP 安装 + 安装第三方插件 + Decky Loader 团队尚未审核此插件。它可能包含恶意软件,例如窃取你的 Steam 账户或损害设备的软件。安装此插件即表示你同意自行承担设备的一切风险。 + 继续 + + 请等待 %1$d 秒 + 游戏 影音 图形 @@ -1799,6 +1832,93 @@ 键盘 + 触控板 终端 Deck 背键 + 触控板 + 仪表盘 + 暂无内容。 + 下载 + 没有正在进行的下载。 + Steam + 正在下载着色器缓存 + 已暂停 + 等待开始 + 通知 + DroidDeck 底部屏幕 + 在游戏时于此处显示消息和其他通知。通知只显示在此屏幕上,不会被保存。Android 只会询问一次:其通知使用权页面会在此屏幕上打开,在那里开启 DroidDeck 后,会自动返回此页面。 + 允许通知使用权 + 没有通知。 + %1$d 条通知 + 清除 + 截图 + 显示 FPS 需要开启 HUD + 速度 + 踏板 + G 值 + 计时 + 最佳圈速 + 轮胎 °C + 油门和刹车,最近 10 s + 速度,%1$s + 挡位 + 速度单位 + 自动(%1$s) + Steam 客户端 ARM + Steam 客户端 x86 + 油门 + 刹车 + 仪表指示灯 + 换挡 + 手刹 + 维修区限速 + TC + ABS + 涡轮 %1$s bar + 发动机 %1$s °C + 机油 %1$s °C + 能量分配器 + 燃料,t + 主油箱 %1$s t + 备用 %1$s t + 点按顶部横条或仪表可更改其样式。 + 发送仪表盘数据 + 自动 + 为此处列出的所有游戏开启,包括之后发现的游戏。你手动关闭的游戏会保持关闭。 + 这会更改游戏自身的数据输出设置。将在游戏下次启动时生效;关闭后会恢复原来的设置。 + 请关闭 %1$s 后再更改此项。 + 无法更改 %1$s 的设置。 + %1$s:请在游戏设置中开启 %2$s,IP 设为 127.0.0.1,端口设为 %3$d。 + 保存到 Pictures/DroidDeck。Steam 截图也会显示在这里。 + 截图 + 截图已保存。 + 无法截图。 + 暂无截图。 + 聊天和语音 + 在主屏幕继续运行游戏的同时,在此阅读和回复 Steam 聊天。使用 Steam 当前登录的账户。 + Steam 目前还没有可共享的网页登录,请在此登录一次。 + DroidDeck 在后台时,Android 关闭了聊天页面。返回 DroidDeck 后会重新打开。 + 打开 Steam 聊天 + 关闭聊天 + 麦克风已开 + 麦克风已关 + 此游戏的麦克风已关闭。如需语音通话,请在 DroidDeck 的 Steam 设置(音频)中开启麦克风,然后重新启动游戏。 + 音量 %1$d%% + 游戏内数据 + FPS + CPU + GPU + 电池 + 圈数 + 名次 + 本圈 + 上一圈 + 燃料 + 货物 + 能量分配 SYS · ENG · WEP + 法律状态 + 余额 + War Thunder + Elite Dangerous + 开启赛车游戏的数据输出(通常称为遥测或 UDP),即可在此查看实时仪表。数据仅保留在此设备上。 + 端口 %1$d 正在接收数据(%2$d 个 %3$d 字节的数据包),但其格式本页面暂时无法读取。 + 端口 %1$s 已被其他程序占用,因此该游戏无法在此显示。 正在移除 Linux 运行时 @@ -2130,6 +2250,7 @@ %1$s 将缩放设为 %2$s。 匹配屏幕(%1$d×%2$d) 跟随屏幕(折叠或展开时调整大小) + 缩放 %1$s Steam 显示设备详情 隐藏设备详情 @@ -2369,6 +2490,9 @@ 下载管理 同时下载数 安装到 + 游戏语言 + GOG 和 Epic 会安装此语言。“系统”跟随应用语言。 + 系统 安装 %1$s: 内部存储 可用 %1$s @@ -2597,6 +2721,22 @@ “自动”会让这款 2D 游戏固定在视野中。要使用头部追踪,请在“显示”中选择“虚拟屏幕”。 头部追踪适用于虚拟屏幕或正在运行的 SteamVR 游戏。 设备 + 控制器 + SteamVR 控制器 + VR 中的手柄 + 你的手柄所模拟的两只 VR 手。更改会立即生效,SteamVR 运行时也是如此。Joy-Con 和手部追踪仍会接管各自对应的一侧。 + 普通手柄 + Valve Index 控制器 + Meta Quest 3 控制器 + HTC Vive 控制器 + 摇杆初始用途 + 在 VR 中同时按下两个摇杆即可切换。 + 在游戏中移动 + 移动双手 + 按键 + 扳机即扳机,肩键为握持,A/B 和 X/Y 为各只手的正面按键,按下摇杆即按下拇指摇杆,Guide 键打开 SteamVR 菜单。方向键上下可让双手靠近或远离。 + 摇杆作为触控板使用;按 A/X 或按下摇杆即按压触控板。扳机即扳机,肩键为握持,Start 和 Select 为菜单键,Guide 键打开 SteamVR 菜单。 + 不使用 VR 手。支持手柄的游戏以及 SteamVR 菜单会照常使用你的手柄。 亮度 音量 遮光 @@ -2654,6 +2794,12 @@ 相机位置 开始游戏前,请确认相机能看到你。 检查 + 手部追踪下载 + 手部追踪需要一次性下载 %1$s。 + 下载 + 重试 + 正在下载手部追踪… %1$d%% + 无法下载手部追踪。请检查网络连接后重试。 相机位置 将设备立在距离 1.5 到 2 米、约胸口高度的地方,让后置相机对着你。然后张开双手走进画面。 将设备立在距离 1.5 到 2 米、约胸口高度的地方,让屏幕对着你。然后张开双手走进画面。 @@ -3162,6 +3308,113 @@ 可以重新计算保存的视图。开始新的校准以在当前跟踪坐标系中捕获。 手模型校验和不匹配 OpenCV 无法加载 + Syncthing + Syncthing + 使用 Syncthing 同步 + 这是基于 Syncthing-Fork 的 Syncthing 实现,可将 DroidDeck 内部文件系统中的文件与其他 Syncthing 实例同步。仅适用于你存放在 DroidDeck 内部文件系统中的文件。 + 此版本不包含 Syncthing。 + 正在启动 Syncthing + 正在启动 + 最新 + 同步中 + 已暂停 + 正在暂停 + 正在恢复 + 需要处理 + Syncthing 已停止 + 重试 + 已暂停,等待连接 Wi-Fi + 游戏期间已暂停 + %1$d 个文件夹 · 已连接 %2$d/%3$d 台设备 + 剩余 %1$s + 打开 Web 管理页面 + 暂停 + 恢复 + 运行中 + 关闭后将完全停止 Syncthing。 + 本设备 + 设备 ID + 显示二维码 + 高级 + 端口、运行时机、重置 + 打开 + 等待你处理 + %1$s 请求连接 + %1$s 想要共享“%2$s” + 接受后可选择在 DroidDeck 中的保存位置。 + 在 Web 管理页面中查看 + 文件夹 + 暂无文件夹。 + %1$s · %2$s + 最新 + 同步中 %1$d%% + 正在扫描 + 已暂停 + 未同步 + %1$d 个文件无法同步 + 已阻止 + 此文件夹位于 DroidDeck 的存储空间之外,因此 DroidDeck 已将其暂停。要同步 DroidDeck 之外的文件夹,请使用 Syncthing-Fork 应用。 + 从 Syncthing 中移除 + 移除不会删除任何文件。 + 设备 + 暂无设备。 + 已连接 + 离线 + 最后在线:%1$s + 本设备的 ID + 在你的另一台设备上,于 Syncthing 中添加设备,然后扫描此二维码或粘贴 ID。之后在这里的 Web 管理页面中接受该设备。 + 复制 + 分享 + 完成 + 关闭 Syncthing? + Syncthing 将立即停止,并保持停止状态,直到你重新开启。已同步的文件保留在原处。 + 同时删除 Syncthing 的配置 + 将移除本设备的 ID、文件夹和设备。重新开启后将从头开始。 + 关闭 + 端口 + 同步 + 本地发现 + 选择新端口 + 每次启动时都会重新检查。 + 重新选择 + 重置 + API 密钥 + 重新生成 + 重置 Syncthing + 将生成新的设备 ID,并移除文件夹和设备。DroidDeck 存储空间中的文件会保留。 + 重置 + 重置 Syncthing? + 运行 + 运行时机 + DroidDeck 打开时 + 在后台 + 仅限 Wi-Fi + 游戏期间暂停 + 日志 + 本次会话的 Syncthing 输出 + 分享 + Syncthing + 请先设置 Linux 运行时。 + 接受 + 将“%1$s”保存到 + 添加文件夹 + 同步 DroidDeck 存储空间中的文件夹 + 选择 + 添加 + 无法添加该文件夹。 + 共享给 + 暂无设备。你稍后可以在 Web 管理页面中共享此文件夹。 + 使用此文件夹 + 新建文件夹 + 容器主目录 + Sync 文件夹 + DroidDeck 共享存储 + %1$s 上的 DroidDeck + DroidDeck 应用数据 + 新文件夹会保存在 DroidDeck 的 Sync 文件夹中。要同步其他 DroidDeck 文件夹,请在“Syncthing”标签页中使用“添加文件夹”。 + Syncthing + 重新加载 + Syncthing 未在运行。 6DoF 虚拟屏幕 在光线充足的房间内缓慢环顾四周。\n不要遮挡追踪摄像头,并面向家具等有明显细节的物体。 DroidDeck ChArUco 30 mm diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index f8c90c08b..e6e0a3462 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -88,6 +88,7 @@ 設為 1 讓此遊戲不使用 droiddeck-fsync;有可用套件時會改用 droiddeck-esync,否則使用 droiddeck-ntsync。 設為 1 讓此遊戲使用 droiddeck-fsync;設為 0 則在有配合其 Proton 的套件時優先使用 droiddeck-esync。 設為 0 以為此遊戲停用 Proton 的控制器導覽輔助程式。 + 預設為開啟。當遊戲支援離線遊玩時,以不載入反作弊的方式啟動 Easy Anti-Cheat 遊戲。關閉後會照常透過 Easy Anti-Cheat 啟動。需要 Easy Anti-Cheat 的線上模式仍然無法使用。 D3D9–11 的 DXVK 疊加層。fps 會顯示幀率;不設定則隱藏。 以分號分隔的 DXVK 設定。較新版本使用 dxvk.maxFrameRate 限制幀率;一般遊玩建議使用會話的幀率限制。 此遊戲使用的 CPU 核心,例如 0-4 或 0,2,4,用來取代會話的遊戲核心。核心越少,溫度越低。 @@ -298,6 +299,8 @@ Xbox 360 控制器 Deck:提供快速存取按鈕和陀螺儀。Xbox 360:按 Guide+A 開啟快速存取。 返回 + 長按返回鍵使用 Steam 快捷操作 + 長按返回鍵開啟快速存取,或與其他按鍵組成 Steam 組合鍵。關閉後忽略長按;輕觸和輕觸兩下仍然有效。 按住返回鍵 700 毫秒,或按住返回鍵並按下其他按鈕以使用 Steam 快速鍵。 連按兩下間隔 %1$d 毫秒 @@ -326,6 +329,12 @@ 已是最新 · %1$s 載入器 安裝最新版本 + 更新頻道 + 要安裝哪一種 Decky Loader 版本。Decky 本身的更新程式也會使用相同的頻道。 + 穩定版 + 預發佈版 + 測試版 + 無法儲存 Decky 更新頻道 隨 Steam 啟動。啟用時,此裝置上的其他應用程式可能可以控制 Steam。 已關閉。Steam 的遠端偵錯連接埠保持關閉。 解除安裝 Decky Loader? @@ -335,6 +344,8 @@ 如果在 Deck 模式下遊戲當機或黑畫面,請關閉此項。下次會話生效。 遊戲結束後返回 DroidDeck 從 DroidDeck 或其他啟動器啟動的遊戲關閉後,Steam 會正常退出(先完成雲端存檔同步),並返回啟動位置。關閉:留在 Steam 中。 + 著色器預先快取 + Steam 會為你的遊戲下載預先編譯的著色器並在背景處理,讓遊戲較不卡頓。關閉可節省流動數據用量、儲存空間和電量;遊戲則會改為在執行時編譯著色器。與 Steam 的「下載」>「著色器預先快取」為同一個設定。下次工作階段生效。 修復 Steam 用戶端 下次啟動 Steam 時重新下載 Steam 用戶端。如果 Steam 一直停留在載入畫面,請使用此功能。遊戲、登入資料和設定都會保留。 下次啟動 Steam 時會重新下載 Steam 用戶端。 @@ -591,6 +602,8 @@ proot 快速路徑 需要 proot 的 seccomp 篩選器。 檔案查找更快,卡頓更少。如果程式找不到其檔案,請關閉此項。 + 在 gamescope 內繪製游標 + 由 Gamescope 自行繪製指標,而不交給 Android。如要用控制器操控游標,請開啟此項。在手機上關閉會較快。下次工作階段生效。 會話身分 主機名稱 Steam 看到的此裝置名稱。留空則還原為 %1$s。 @@ -641,6 +654,8 @@ 橫條 鎖定位置 拖曳 HUD 可移動位置;長按可鎖定或解鎖。鎖定時,下方的螢幕控制項仍可使用。 + 貼齊兩側 + 放開 HUD 後,它會移到螢幕較近的一側。輕甩可將它甩向該方向。 外觀 螢幕顯示大小 柔和 @@ -709,7 +724,7 @@ 去色帶強度 亮度 對比度 - Gamma + 伽瑪 飽和度 FXAA(邊緣平滑) 卡通描邊 @@ -1180,6 +1195,9 @@ 隱藏其他 GPU 的驅動程式 顯示其他 GPU 的驅動程式 進階 + UBWC 相容性 + 如果 Steam 或 Android 出現條紋或閃爍,請試試開啟或關閉。重新啟動 DroidDeck 後會套用到兩種驅動程式。 + 重新啟動 DroidDeck 以套用。 與顯示驅動程式一併設定:%1$s 同時包含兩者。 與運行環境驅動程式一併設定:%1$s 同時包含兩者。 供遊戲和桌面使用。 @@ -1288,6 +1306,8 @@ 自動進入畫中畫 展開螢幕時的控制 展開時,遊戲會顯示在 Steam Deck 觸控板和背面握把上方;摺疊時,遊戲會全螢幕顯示。 + 連接控制器時亦保留 + 連接控制器時亦顯示這些觸控按鍵。 已暫停 會話準備就緒後即可使用畫中畫。 請在 Android 設定中允許 DroidDeck 使用畫中畫。 @@ -1354,6 +1374,9 @@ 仍要安裝 移除 Linux 運行環境 此操作會刪除運行環境、其中的 Steam 用戶端,以及安裝在其中的所有遊戲。 + Linux 運行環境 + 「修復」會重新安裝運行環境的系統檔案,並保留 Steam、遊戲和設定。桌面等額外元件會在下次使用時重新安裝。「移除」會刪除所有內容。 + 修復運行環境 已套用:%1$s 無法在主螢幕開啟 %1$s 無法在 %2$s 開啟 %1$s @@ -1480,6 +1503,10 @@ 無法連接運行環境目錄。請檢查網絡連線,然後再按「開啟 Steam」。 Linux 運行環境未能安裝。請在「設定」中檢查運行環境,然後再按「開啟 Steam」。 桌面未能安裝(%1$s)。請檢查網絡連線,然後再按「桌面」。 + 無法完成先前中斷的安裝(%1$s)。請檢查網絡連線和儲存空間,然後再試一次。 + + Linux 運行環境已損壞:%1$d 個程式庫連結指向不存在的檔案(%2$s 等),通常是安裝中斷所致。修復會保留你的遊戲和設定。 + %1$s 意外停止。請在錯誤報告中附上記錄,或再試一次。 程式意外停止。請在錯誤報告中附上記錄,或再試一次。 桌面意外停止。請在錯誤報告中附上記錄,或再試一次。 @@ -1493,6 +1520,12 @@ 安裝插件 ZIP,在下次 Steam 會話使用。 正在下載… 從 ZIP 安裝 + 安裝第三方插件 + Decky Loader 團隊尚未審查此插件。它可能含有惡意軟件,例如竊取你的 Steam 帳戶或損害裝置的軟件。安裝此插件即表示你同意自行承擔裝置的一切風險。 + 繼續 + + 請稍候 %1$d 秒 + 遊戲 音訊及影片 圖形 @@ -1721,6 +1754,93 @@ 鍵盤 + 觸控板 終端機 Deck 握把 + 觸控板 + 儀表板 + 目前沒有可顯示的內容。 + 下載 + 沒有進行中的下載。 + Steam + 正在下載著色器快取 + 已暫停 + 等待開始 + 通知 + DroidDeck 下方螢幕 + 在遊玩時於此處顯示訊息和其他通知。通知只會顯示在這個螢幕上,不會儲存。Android 只會詢問一次:通知存取權頁面會在這個螢幕上開啟,在那裡開啟 DroidDeck 後,就會自動回到此頁面。 + 允許通知存取權 + 沒有通知。 + %1$d 則通知 + 清除 + 螢幕截圖 + 顯示 FPS 需要開啟 HUD + 速度 + 踏板 + G 力 + 計時 + 最佳單圈 + 輪胎 °C + 油門和剎車,最近 10 s + 速度,%1$s + 檔位 + 速度單位 + 自動(%1$s) + Steam 用戶端 ARM + Steam 用戶端 x86 + 油門 + 剎車 + 儀表指示燈 + 換檔 + 手剎 + 維修區限速 + TC + ABS + 渦輪 %1$s bar + 引擎 %1$s °C + 機油 %1$s °C + 能量分配器 + 燃料,t + 主油箱 %1$s t + 備用 %1$s t + 點按頂部橫條或儀表即可變更其樣式。 + 傳送儀表板資料 + 自動 + 為這裡列出的所有遊戲開啟,包括之後找到的遊戲。你關閉的遊戲會維持關閉。 + 這會變更遊戲本身的資料輸出設定。將在遊戲下次啟動時生效;關閉後會還原為原本的設定。 + 請先關閉 %1$s 再變更此項。 + 無法變更 %1$s 的設定。 + %1$s:請在遊戲設定中開啟 %2$s,IP 設為 127.0.0.1,連接埠設為 %3$d。 + 儲存至 Pictures/DroidDeck。Steam 螢幕截圖也會顯示在這裡。 + 截取螢幕畫面 + 已儲存螢幕截圖。 + 無法截取螢幕畫面。 + 尚無螢幕截圖。 + 聊天和語音 + 遊戲在主螢幕上持續執行的同時,在此閱讀和回覆 Steam 聊天。會使用 Steam 目前登入的帳戶。 + Steam 目前還沒有可共用的網頁登入狀態,請在此登入一次。 + DroidDeck 在背景執行時,Android 關閉了聊天頁面。回到 DroidDeck 後會重新開啟。 + 開啟 Steam 聊天 + 關閉聊天 + 麥克風已開啟 + 麥克風已關閉 + 此遊戲的麥克風已關閉。若要說話,請在 DroidDeck 的 Steam 設定(音訊)中開啟麥克風,然後重新啟動遊戲。 + 音量 %1$d%% + 遊戲內數據 + FPS + CPU + GPU + 電池 + 圈數 + 名次 + 本圈 + 上一圈 + 燃料 + 貨物 + 能量分配 SYS · ENG · WEP + 法律狀態 + 餘額 + War Thunder + Elite Dangerous + 開啟賽車遊戲的資料輸出(通常稱為遙測或 UDP),即可在此查看即時儀表。資料只會留在這部裝置上。 + 連接埠 %1$d 正在接收資料(%2$d 個 %3$d 位元組的封包),但此頁面目前還無法讀取這種格式。 + 其他程式已在使用連接埠 %1$s,因此該遊戲無法在此顯示。 正在移除 Linux 運行環境 @@ -2050,6 +2170,7 @@ %1$s 將縮放設為 %2$s。 配合螢幕(%1$d×%2$d) 跟隨螢幕(摺疊或展開時調整大小) + 縮放 %1$s Steam 顯示裝置詳情 隱藏裝置詳情 @@ -2289,6 +2410,9 @@ 下載管理員 同時下載數目 安裝至 + 遊戲語言 + GOG 和 Epic 會安裝此語言。「系統」會跟隨應用程式語言。 + 系統 安裝 %1$s: 內部儲存空間 可用 %1$s @@ -2594,6 +2718,22 @@ 「自動」會令這款 2D 遊戲固定在視野中。如要使用頭部追蹤,請在「顯示」中選擇「虛擬螢幕」。 頭部追蹤適用於虛擬螢幕或正在運行的 SteamVR 遊戲。 設備 + 控制器 + SteamVR 控制器 + VR 中的控制器 + 你的控制器所模擬的兩隻 VR 手。變更會即時生效,SteamVR 運行期間亦然。Joy-Con 和手部追蹤仍會接管各自對應的一側。 + 一般控制器 + Valve Index 控制器 + Meta Quest 3 控制器 + HTC Vive 控制器 + 搖桿初始用途 + 在 VR 中同時按下兩個搖桿即可切換。 + 在遊戲中移動 + 移動雙手 + 按鈕 + 扳機就是扳機,肩鍵為握持,A/B 和 X/Y 為各隻手的正面按鈕,按下搖桿即按下拇指搖桿,Guide 鍵開啟 SteamVR 選單。方向鍵上下可讓雙手拉近或推遠。 + 搖桿作為觸控板使用;按 A/X 或按下搖桿即可按壓觸控板。扳機就是扳機,肩鍵為握持,Start 和 Select 為選單鍵,Guide 鍵開啟 SteamVR 選單。 + 不使用 VR 手。支援控制器的遊戲以及 SteamVR 選單會照常使用你的控制器。 亮度 音量 遮光 @@ -2651,6 +2791,12 @@ 相機位置 開始玩之前,請確認相機能看到你。 檢查 + 手部追蹤下載 + 手部追蹤需要一次性下載 %1$s。 + 下載 + 重試 + 正在下載手部追蹤… %1$d%% + 無法下載手部追蹤。請檢查網絡連線後再試一次。 相機位置 將裝置立在距離 1.5 到 2 米、約胸口高度的地方,讓後置相機對著你。然後張開雙手走進畫面。 將裝置立在距離 1.5 到 2 米、約胸口高度的地方,讓螢幕對著你。然後張開雙手走進畫面。 @@ -3159,6 +3305,113 @@ 可以重新計算已儲存的視圖。開始新的校準以在當前追蹤座標系中捕獲。 手模型校驗與不匹配 OpenCV 無法載入 + Syncthing + Syncthing + 使用 Syncthing 同步 + 這是以 Syncthing-Fork 為基礎的 Syncthing 實作,可將 DroidDeck 內部檔案系統中的檔案與其他 Syncthing 實例同步。只適用於你存放在 DroidDeck 內部檔案系統中的檔案。 + 此版本未包含 Syncthing。 + 正在啟動 Syncthing + 正在啟動 + 已是最新 + 同步中 + 已暫停 + 正在暫停 + 正在繼續 + 需要處理 + Syncthing 已停止 + 再試一次 + 已暫停,等待連接 Wi-Fi + 遊戲期間已暫停 + %1$d 個資料夾 · 已連接 %2$d/%3$d 部裝置 + 剩餘 %1$s + 開啟網頁介面 + 暫停 + 繼續 + 運行中 + 關閉後將完全停止 Syncthing。 + 此裝置 + 裝置 ID + 顯示 QR 碼 + 進階 + 連接埠、運行時機、重設 + 開啟 + 等待你處理 + %1$s 想要連接 + %1$s 想要共用「%2$s」 + 接受後即可選擇在 DroidDeck 中的儲存位置。 + 在網頁介面中檢視 + 資料夾 + 暫時沒有資料夾。 + %1$s · %2$s + 已是最新 + 同步中 %1$d%% + 正在掃描 + 已暫停 + 未同步 + %1$d 個檔案無法同步 + 已封鎖 + 此資料夾位於 DroidDeck 的儲存空間之外,因此 DroidDeck 已將其暫停。若要同步 DroidDeck 以外的資料夾,請使用 Syncthing-Fork 應用程式。 + 從 Syncthing 移除 + 移除後不會刪除任何檔案。 + 裝置 + 暫時沒有裝置。 + 已連接 + 離線 + 上次在線:%1$s + 此裝置的 ID + 在你的另一部裝置上,於 Syncthing 中新增裝置,然後掃描此 QR 碼或貼上 ID。接著在這裡的網頁介面中接受該裝置。 + 複製 + 分享 + 完成 + 關閉 Syncthing? + Syncthing 會立即停止,並保持停止狀態,直至再次開啟為止。已同步的檔案會保留在原處。 + 同時刪除 Syncthing 的設定 + 將移除此裝置的 ID、資料夾和裝置。再次開啟時會從頭開始。 + 關閉 + 連接埠 + 同步 + 本地探索 + 選擇新的連接埠 + 每次啟動時都會重新檢查。 + 重新選擇 + 重設 + API 密鑰 + 重新產生 + 重設 Syncthing + 將產生新的裝置 ID,並移除資料夾和裝置。DroidDeck 儲存空間中的檔案會保留。 + 重設 + 重設 Syncthing? + 運行 + 運行時機 + DroidDeck 開啟時 + 在背景運行 + 只限 Wi-Fi + 遊戲期間暫停 + 記錄 + 此會話的 Syncthing 輸出 + 分享 + Syncthing + 請先設定 Linux 運行環境。 + 接受 + 將「%1$s」儲存到 + 新增資料夾 + 同步 DroidDeck 儲存空間中的資料夾 + 選擇 + 新增 + 無法新增該資料夾。 + 共用對象 + 暫時沒有裝置。你稍後可以從網頁介面共用此資料夾。 + 使用此資料夾 + 新資料夾 + 容器主目錄 + Sync 資料夾 + DroidDeck 共用儲存空間 + %1$s 上的 DroidDeck + DroidDeck 應用程式資料 + 新資料夾會儲存在 DroidDeck 的 Sync 資料夾中。若要同步其他 DroidDeck 資料夾,請在「Syncthing」分頁中使用「新增資料夾」。 + Syncthing + 重新載入 + Syncthing 未在運行。 6DoF 虛擬螢幕 在光線充足的房間內緩慢環顧四周。\n不要遮擋追蹤攝影機,並面向家具等有明顯細節的物體。 DroidDeck ChArUco 30 mm diff --git a/app/src/main/res/values-zh-rTW/gestures.xml b/app/src/main/res/values-zh-rTW/gestures.xml index 7841fe7b4..551fab20d 100644 --- a/app/src/main/res/values-zh-rTW/gestures.xml +++ b/app/src/main/res/values-zh-rTW/gestures.xml @@ -71,7 +71,7 @@ 退格 Enter Tab - Space + 空白鍵 刪除 插入 Home diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d24efd4a2..5314631ea 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -88,6 +88,7 @@ 設為 1 可讓此遊戲不使用 droiddeck-fsync;有對應套件時改用 droiddeck-esync,否則使用 droiddeck-ntsync。 設為 1 讓此遊戲使用 droiddeck-fsync;設為 0 則在有適用其 Proton 的套件時優先使用 droiddeck-esync。 設定為 0,為此遊戲停用 Proton 的手把導覽輔助程式。 + 預設為開啟。當遊戲支援離線遊玩時,以不載入反作弊的方式啟動 Easy Anti-Cheat 遊戲。關閉後會照常透過 Easy Anti-Cheat 啟動。需要 Easy Anti-Cheat 的線上模式仍然無法使用。 D3D9–11 的 DXVK 重疊層。fps 顯示影格率;取消設定則隱藏重疊層。 以分號分隔的 DXVK 設定。近期版本使用 dxvk.maxFrameRate 限制影格率;日常遊玩建議使用工作階段影格率限制器。 此遊戲使用的 CPU 核心,例如 0-4 或 0,2,4,用來取代工作階段的遊戲核心。核心越少,溫度越低。 @@ -298,6 +299,8 @@ Xbox 360 控制器 Deck:提供快速存取按鈕和陀螺儀。Xbox 360:使用 Guide+A 開啟快速存取。 返回 + 長按返回鍵使用 Steam 快捷操作 + 長按返回鍵開啟快速存取,或與其他按鍵組成 Steam 組合鍵。關閉後忽略長按;輕觸和輕觸兩下仍然有效。 按住返回鍵 700 毫秒,或按住返回鍵並按下其他按鈕以使用 Steam 快速鍵。 連按兩下間隔 %1$d 毫秒 @@ -326,6 +329,12 @@ 已是最新 · %1$s 載入器 安裝最新版本 + 更新頻道 + 要安裝哪一種 Decky Loader 版本。Decky 本身的更新程式也會使用相同的頻道。 + 穩定版 + 預先發行版 + 測試版 + 無法儲存 Decky 更新頻道 隨 Steam 啟動。啟用後,此裝置上的其他應用程式可能可以控制 Steam。 關閉。Steam 的遠端偵錯連接埠保持關閉。 解除安裝 Decky Loader? @@ -335,6 +344,8 @@ 如果 Deck 模式下遊戲崩潰或黑畫面,請關閉此項。下次工作階段生效。 遊戲結束後返回 DroidDeck 從 DroidDeck 或其他啟動器啟動的遊戲關閉後,Steam 會正常退出(先完成雲端存檔同步),並返回啟動位置。關閉:留在 Steam 中。 + 著色器預先快取 + Steam 會為你的遊戲下載預先編譯的著色器並在背景處理,讓遊戲較不卡頓。關閉可節省數據用量、儲存空間和電量;遊戲則會改為在執行時編譯著色器。與 Steam 的「下載」>「著色器預先快取」為同一個設定。下次工作階段生效。 修復 Steam 用戶端 下次啟動 Steam 時重新下載 Steam 用戶端。如果 Steam 一直停在載入畫面,請使用此功能。遊戲、登入狀態和設定都會保留。 下次啟動 Steam 時會重新下載 Steam 用戶端。 @@ -591,6 +602,8 @@ proot 快速路徑 需要 proot 的 seccomp 篩選器。 檔案尋找更快、卡頓更少。如果程式找不到檔案,請關閉此項。 + 在 gamescope 內繪製游標 + 由 Gamescope 自行繪製指標,而不交給 Android。若要用手把控制游標,請開啟此項。在手機上關閉會比較快。下次工作階段生效。 工作階段身分 主機名稱 Steam 看到的此裝置名稱。留空可還原為 %1$s。 @@ -641,6 +654,8 @@ 橫條 鎖定位置 拖曳 HUD 可移動位置;長按可鎖定或解鎖。鎖定時,下方的螢幕控制項仍可使用。 + 貼齊兩側 + 放開 HUD 後,它會移到螢幕較近的一側。輕甩可將它甩向該方向。 外觀 螢幕顯示大小 柔和 @@ -1180,6 +1195,9 @@ 隱藏其他 GPU 的驅動程式 顯示其他 GPU 的驅動程式 進階 + UBWC 相容性 + 如果 Steam 或 Android 出現條紋或閃爍,請試試開啟或關閉。重新啟動 DroidDeck 後會套用到兩種驅動程式。 + 重新啟動 DroidDeck 以套用。 與顯示驅動程式一起設定:%1$s 同時包含兩者。 與執行環境驅動程式一起設定:%1$s 同時包含兩者。 供遊戲和桌面使用。 @@ -1288,6 +1306,8 @@ 自動進入子母畫面 展開螢幕時的控制 展開時,遊戲會顯示在 Steam Deck 觸控板和背面握把上方。摺疊時,遊戲以全螢幕顯示。 + 連接手把時也保留 + 連接手把時也顯示這些觸控按鍵。 已暫停 工作階段準備就緒後即可使用子母畫面。 請在 Android 設定中允許 DroidDeck 使用子母畫面。 @@ -1354,6 +1374,9 @@ 仍要安裝 移除 Linux 執行環境 這會刪除執行環境、其中的 Steam 用戶端,以及安裝在其中的所有遊戲。 + Linux 執行環境 + 「修復」會重新安裝執行環境的系統檔案,並保留 Steam、遊戲和設定。桌面等額外元件會在下次使用時重新安裝。「移除」會刪除所有內容。 + 修復執行環境 已套用:%1$s 無法在主螢幕上開啟 %1$s 無法在 %2$s 上開啟 %1$s @@ -1480,6 +1503,10 @@ 無法連線到執行環境目錄。請檢查網路連線,然後再按一次「執行」。 Linux 執行環境未能安裝。請在「設定」中檢查執行環境,然後再按一次「執行」。 桌面未能安裝(%1$s)。請檢查網路連線,然後再按一次「桌面」。 + 無法完成先前中斷的安裝(%1$s)。請檢查網路連線和儲存空間,然後再試一次。 + + Linux 執行環境已損壞:%1$d 個程式庫連結指向不存在的檔案(%2$s 等),通常是安裝中斷所致。修復會保留你的遊戲和設定。 + %1$s 意外停止。請在錯誤報告中附上日誌,或再試一次。 程式意外停止。請在錯誤報告中附上日誌,或再試一次。 桌面意外停止。請在錯誤報告中附上日誌,或再試一次。 @@ -1493,6 +1520,12 @@ 安裝外掛程式 ZIP,供下次 Steam 工作階段使用。 正在下載… 從 ZIP 安裝 + 安裝第三方外掛程式 + Decky Loader 團隊尚未審查此外掛程式。它可能含有惡意軟體,例如竊取你的 Steam 帳戶或損害裝置的軟體。安裝此外掛程式即表示你同意自行承擔裝置的一切風險。 + 繼續 + + 請稍候 %1$d 秒 + 遊戲 影音 圖形 @@ -1548,7 +1581,7 @@ Ctrl Super Alt - Space + 空白鍵 AltGr Tab %1$s ✓ @@ -1721,6 +1754,93 @@ 鍵盤 + 觸控板 終端機 Deck 握把 + 觸控板 + 儀表板 + 目前沒有可顯示的內容。 + 下載 + 沒有進行中的下載。 + Steam + 正在下載著色器快取 + 已暫停 + 等待開始 + 通知 + DroidDeck 底部螢幕 + 在遊玩時於此處顯示訊息和其他通知。通知只會顯示在這個螢幕上,不會儲存。Android 只會詢問一次:通知存取權頁面會在這個螢幕上開啟,在那裡開啟 DroidDeck 後,就會自動回到此頁面。 + 允許通知存取權 + 沒有通知。 + %1$d 則通知 + 清除 + 螢幕擷取畫面 + 顯示 FPS 需要開啟 HUD + 速度 + 踏板 + G 力 + 計時 + 最佳單圈 + 輪胎 °C + 油門和煞車,最近 10 s + 速度,%1$s + 檔位 + 速度單位 + 自動(%1$s) + Steam 用戶端 ARM + Steam 用戶端 x86 + 油門 + 煞車 + 儀表指示燈 + 換檔 + 手煞車 + 維修區限速 + TC + ABS + 渦輪 %1$s bar + 引擎 %1$s °C + 機油 %1$s °C + 能量分配器 + 燃料,t + 主油箱 %1$s t + 備用 %1$s t + 點按頂部橫條或儀表即可變更其樣式。 + 傳送儀表板資料 + 自動 + 為這裡列出的所有遊戲開啟,包括之後找到的遊戲。你關閉的遊戲會維持關閉。 + 這會變更遊戲本身的資料輸出設定。將在遊戲下次啟動時生效;關閉後會還原為原本的設定。 + 請先關閉 %1$s 再變更此項。 + 無法變更 %1$s 的設定。 + %1$s:請在遊戲設定中開啟 %2$s,IP 設為 127.0.0.1,連接埠設為 %3$d。 + 儲存至 Pictures/DroidDeck。Steam 螢幕擷取畫面也會顯示在這裡。 + 擷取螢幕畫面 + 已儲存螢幕擷取畫面。 + 無法擷取螢幕畫面。 + 尚無螢幕擷取畫面。 + 聊天和語音 + 遊戲在主螢幕上持續執行的同時,在此閱讀和回覆 Steam 聊天。會使用 Steam 目前登入的帳戶。 + Steam 目前還沒有可共用的網頁登入狀態,請在此登入一次。 + DroidDeck 在背景執行時,Android 關閉了聊天頁面。回到 DroidDeck 後會重新開啟。 + 開啟 Steam 聊天 + 關閉聊天 + 麥克風已開啟 + 麥克風已關閉 + 此遊戲的麥克風已關閉。若要說話,請在 DroidDeck 的 Steam 設定(音訊)中開啟麥克風,然後重新啟動遊戲。 + 音量 %1$d%% + 遊戲內數據 + FPS + CPU + GPU + 電池 + 圈數 + 名次 + 本圈 + 上一圈 + 燃料 + 貨物 + 能量分配 SYS · ENG · WEP + 法律狀態 + 餘額 + War Thunder + Elite Dangerous + 開啟賽車遊戲的資料輸出(通常稱為遙測或 UDP),即可在此查看即時儀表。資料只會留在這部裝置上。 + 連接埠 %1$d 正在接收資料(%2$d 個 %3$d 位元組的封包),但此頁面目前還無法讀取這種格式。 + 其他程式已在使用連接埠 %1$s,因此該遊戲無法在此顯示。 正在移除 Linux 執行環境 @@ -2050,6 +2170,7 @@ %1$s 將縮放設為 %2$s。 符合螢幕(%1$d×%2$d) 跟隨螢幕(摺疊或展開時調整大小) + 縮放 %1$s Steam 顯示裝置詳細資料 隱藏裝置詳細資料 @@ -2289,6 +2410,9 @@ 下載管理員 同時下載數 安裝到 + 遊戲語言 + GOG 和 Epic 會安裝此語言。「系統」會跟隨應用程式語言。 + 系統 安裝 %1$s: 內部 可用 %1$s @@ -2594,6 +2718,22 @@ 「自動」會讓這款 2D 遊戲固定在視野中。如要使用頭部追蹤,請在「顯示」中選擇「虛擬螢幕」。 頭部追蹤適用於虛擬螢幕或執行中的 SteamVR 遊戲。 設備 + 控制器 + SteamVR 控制器 + VR 中的手把 + 你的手把所模擬的兩隻 VR 手。變更會立即生效,SteamVR 執行時也一樣。Joy-Con 和手部追蹤仍會接管各自對應的一側。 + 一般手把 + Valve Index 控制器 + Meta Quest 3 控制器 + HTC Vive 控制器 + 搖桿初始用途 + 在 VR 中同時按下兩個搖桿即可切換。 + 在遊戲中移動 + 移動雙手 + 按鈕 + 扳機就是扳機,肩鍵為握持,A/B 和 X/Y 為各隻手的正面按鈕,按下搖桿即按下拇指搖桿,Guide 鍵開啟 SteamVR 選單。方向鍵上下可讓雙手拉近或推遠。 + 搖桿作為觸控板使用;按 A/X 或按下搖桿即可按壓觸控板。扳機就是扳機,肩鍵為握持,Start 和 Select 為選單鍵,Guide 鍵開啟 SteamVR 選單。 + 不使用 VR 手。支援手把的遊戲以及 SteamVR 選單會照常使用你的手把。 亮度 音量 遮光 @@ -2651,6 +2791,12 @@ 相機位置 開始遊玩前,請確認相機能看到你。 檢查 + 手部追蹤下載 + 手部追蹤需要一次性下載 %1$s。 + 下載 + 重試 + 正在下載手部追蹤… %1$d%% + 無法下載手部追蹤。請檢查網路連線後再試一次。 相機位置 將裝置立在距離 1.5 到 2 公尺、約胸口高度的地方,讓後置相機對著你。然後張開雙手走進畫面。 將裝置立在距離 1.5 到 2 公尺、約胸口高度的地方,讓螢幕對著你。然後張開雙手走進畫面。 @@ -3159,6 +3305,113 @@ 可以重新計算已儲存的視圖。開始新的校準以在當前追蹤座標系中捕獲。 手模型校驗與不匹配 OpenCV 無法載入 + Syncthing + Syncthing + 使用 Syncthing 同步 + 這是以 Syncthing-Fork 為基礎的 Syncthing 實作,可將 DroidDeck 內部檔案系統中的檔案與其他 Syncthing 執行個體同步。僅適用於你存放在 DroidDeck 內部檔案系統中的檔案。 + 此版本未包含 Syncthing。 + 正在啟動 Syncthing + 正在啟動 + 已是最新 + 同步中 + 已暫停 + 正在暫停 + 正在繼續 + 需要處理 + Syncthing 已停止 + 再試一次 + 已暫停,等待連上 Wi-Fi + 遊戲期間已暫停 + %1$d 個資料夾 · 已連線 %2$d/%3$d 部裝置 + 剩餘 %1$s + 開啟網頁介面 + 暫停 + 繼續 + 執行中 + 關閉後將完全停止 Syncthing。 + 此裝置 + 裝置 ID + 顯示 QR 碼 + 進階 + 連接埠、執行時機、重設 + 開啟 + 等待你處理 + %1$s 想要連線 + %1$s 想要共用「%2$s」 + 接受後即可選擇在 DroidDeck 中的儲存位置。 + 在網頁介面中檢視 + 資料夾 + 尚無資料夾。 + %1$s · %2$s + 已是最新 + 同步中 %1$d%% + 正在掃描 + 已暫停 + 未同步 + %1$d 個檔案無法同步 + 已封鎖 + 此資料夾位於 DroidDeck 的儲存空間之外,因此 DroidDeck 已將其暫停。若要同步 DroidDeck 以外的資料夾,請使用 Syncthing-Fork 應用程式。 + 從 Syncthing 移除 + 移除後不會刪除任何檔案。 + 裝置 + 尚無裝置。 + 已連線 + 離線 + 上次上線:%1$s + 此裝置的 ID + 在你的另一部裝置上,於 Syncthing 中新增裝置,然後掃描此 QR 碼或貼上 ID。接著在這裡的網頁介面中接受該裝置。 + 複製 + 分享 + 完成 + 要關閉 Syncthing 嗎? + Syncthing 會立即停止,並維持停止狀態,直到再次開啟為止。已同步的檔案會保留在原處。 + 同時刪除 Syncthing 的設定 + 將移除此裝置的 ID、資料夾和裝置。再次開啟時會從頭開始。 + 關閉 + 連接埠 + 同步 + 本機探索 + 選擇新的連接埠 + 每次啟動時都會重新檢查。 + 重新選擇 + 重設 + API 金鑰 + 重新產生 + 重設 Syncthing + 將產生新的裝置 ID,並移除資料夾和裝置。DroidDeck 儲存空間中的檔案會保留。 + 重設 + 要重設 Syncthing 嗎? + 執行 + 執行時機 + DroidDeck 開啟時 + 在背景執行 + 僅限 Wi-Fi + 遊戲期間暫停 + 日誌 + 此工作階段的 Syncthing 輸出 + 分享 + Syncthing + 請先設定 Linux 執行環境。 + 接受 + 將「%1$s」儲存到 + 新增資料夾 + 同步 DroidDeck 儲存空間中的資料夾 + 選擇 + 新增 + 無法新增該資料夾。 + 共用對象 + 尚無裝置。你之後可以從網頁介面共用此資料夾。 + 使用此資料夾 + 新資料夾 + 容器家目錄 + Sync 資料夾 + DroidDeck 共用儲存空間 + %1$s 上的 DroidDeck + DroidDeck 應用程式資料 + 新資料夾會儲存在 DroidDeck 的 Sync 資料夾中。若要同步其他 DroidDeck 資料夾,請在「Syncthing」分頁中使用「新增資料夾」。 + Syncthing + 重新載入 + Syncthing 未在執行。 6DoF 虛擬螢幕 在光線充足的房間內緩慢環顧四周。\n不要遮擋追蹤攝影機,並面向家具等有明顯細節的物體。 DroidDeck ChArUco 30 mm diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e74c7fcb..5635c0172 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -323,6 +323,8 @@ Xbox 360 controller Deck: Quick Access button and gyro. Steam Controller: the same, with grip sense on the back buttons. DualShock 4 and DualSense: gyro, and the trackpads as their touchpad. Xbox 360: open Quick Access with Guide+A. Back + Hold Back for Steam shortcuts + Holding Back opens Quick Access or makes Steam chords with other buttons. Turn off to ignore held Back; tap and double-tap still work. Hold Back for 700 ms, or hold Back and press another button for Steam shortcuts. Double-tap window %1$d ms @@ -452,6 +454,16 @@ Internal only Folder: %1$s Second library + More Steam libraries + Folders Steam can install to and play from, each under its own name. Removing one keeps its games on disk. + Add library + Rename + Library name + Choose a folder for the library + At most %1$d added libraries + That folder is already a library or inside one + %1$s · not available + Applies from the next Steam session Games that stream assets from SD or shared storage may stutter. Storage diagnostics Record allocation results and sampled storage timings in Share logs. May slow installs while enabled. Applies next session. @@ -678,6 +690,8 @@ Strip Lock in place Drag the HUD to move it; a long press locks or unlocks it. While it is locked, on-screen controls under it still work. + Snap to sides + Let go of the HUD and it moves to the nearer side of the screen. A flick throws it that way. Look Size on screen Soft @@ -1878,6 +1892,17 @@ Terminal Deck grips + trackpads Dashboard + Desktop + Start the desktop + Desktop · %1$s + Trackpad + Direct touch + Trackpad. Tap for direct touch + Direct touch. Tap for trackpad + Starting the KDE desktop… + The desktop closed. + The desktop couldn\'t start. Its logs are in this session\'s bottom-desktop folder. + The desktop is closed. Nothing to show here yet. Downloads No downloads running. @@ -1922,7 +1947,21 @@ Fuel, t Main %1$s t Reserve %1$s t - Tap the top strip or the dash to change its style. + Tap the HUD, the top strip or the dash to change its style. + Frame time + last %1$d s + avg frame + 1 percent low + frames over 33 ms + %1$d / min + Playing for %1$s + %1$d cores, up to %2$s GHz + %1$d cores + game + FPS of %1$d Hz + The red mark is where the HUD shows the temperature in red. + whole device + battery Send dashboard data Automatic Turns it on for every game listed here, including games found later. Games you switch off stay off. @@ -1946,6 +1985,13 @@ Mic is off for this game. To talk, turn on Microphone in DroidDeck\'s Steam settings (Audio) and start the game again. Volume %1$d%% In-game stats + Bottom screen HUD + Works with the top HUD off and does not change it. The number switches below are for the Line strip: under the tabs, and the Line style above a game\'s dashboard. + The big HUD, the Line strip and the strip under the tabs. A game\'s dashboard stays. + Strip on every tab + Shows the line under the tabs while you use the bottom screen. + 1% and 0.1% lows + CPU temperature FPS CPU GPU @@ -1961,7 +2007,7 @@ Balance War Thunder Elite Dangerous - Turn on a racing game\'s data output (often called telemetry or UDP) to see a live dash here. It stays on this device. + Turn on a game\'s data output (often called telemetry or UDP) to see a live dash here. It stays on this device. Port %1$d receives data (%2$d packets of %3$d bytes), but in a format this page cannot read yet. Another program already uses port %1$s, so that game cannot show here. @@ -2297,6 +2343,7 @@ %1$s Sets scaling to %2$s. Match screen (%1$d×%2$d) Follow screen (resizes when folded or unfolded) + Scaled %1$s Steam Show device details Hide device details @@ -3421,6 +3468,113 @@ Saved views can be recalculated. Start a new calibration to capture in the current tracking frame. Hand model checksum mismatch OpenCV could not load + Syncthing + Syncthing + Sync with Syncthing + Based on Syncthing-Fork, this is a Syncthing implementation that lets you sync files in DroidDeck\'s internal file system with other Syncthing instances. This is only for files you are storing within DroidDeck\'s internal file system. + This build doesn\'t include Syncthing. + Starting Syncthing + Starting + Up to date + Syncing + Paused + Pausing + Resuming + Needs attention + Syncthing stopped + Try again + Paused until Wi-Fi + Paused during the game + %1$d folders · %2$d of %3$d devices connected + %1$s left + Open web UI + Pause + Resume + Running + Turn off to stop Syncthing completely. + This device + Device ID + Show QR + Advanced + Ports, when to run, reset + Open + Waiting for you + %1$s wants to connect + %1$s wants to share “%2$s” + Accept to choose where it\'s saved in DroidDeck. + Review in web UI + Folders + No folders yet. + %1$s · %2$s + Up to date + Syncing %1$d%% + Scanning + Paused + Out of sync + %1$d files couldn\'t sync + Blocked + This folder is outside DroidDeck\'s storage, so DroidDeck paused it. To sync folders outside DroidDeck, use the Syncthing-Fork app. + Remove from Syncthing + Removing it doesn\'t delete any files. + Devices + No devices yet. + Connected + Offline + Last seen %1$s + This device\'s ID + On your other device, add a device in Syncthing and scan this code or paste the ID. Then accept it here in the web UI. + Copy + Share + Done + Turn off Syncthing? + Syncthing stops now and stays stopped until it\'s turned back on. Synced files stay where they are. + Also delete Syncthing\'s config + Removes this device\'s ID, folders and devices. Turning it on again starts fresh. + Turn off + Ports + Sync + Local discovery + Pick new ports + Checked again on every start. + Re-pick + Reset + API key + Regenerate + Reset Syncthing + New device ID, folders and devices removed. Files in DroidDeck\'s storage are kept. + Reset + Reset Syncthing? + Running + Run + While DroidDeck is open + In the background + Wi-Fi only + Pause during games + Logs + Syncthing output for this session + Share + Syncthing + Set up the Linux runtime first. + Accept + Save “%1$s” to + Add folder + Sync a folder in DroidDeck\'s storage + Choose + Add + The folder couldn\'t be added. + Share with + No devices yet. You can share this folder from the web UI later. + Use this folder + New folder + Container home + Sync folder + DroidDeck shared storage + DroidDeck on %1$s + DroidDeck app data + New folders are saved in DroidDeck\'s Sync folder. To sync a different DroidDeck folder, use Add folder on the Syncthing tab. + Syncthing + Reload + Syncthing isn\'t running. Steam stopped with an illegal instruction (SIGILL). This can be a CPU instruction compatibility problem or a client crash. Share the logs with your device details. You can try Steam settings → Steam client → x86-64 (experimental). It needs an additional download and may run more slowly. The Linux graphics driver could not find a usable Vulkan device. Check that the selected Linux GPU driver supports this GPU. diff --git a/app/src/main/rust/stores/src/fetch_core.rs b/app/src/main/rust/stores/src/fetch_core.rs index f01973440..a0a4ac91a 100644 --- a/app/src/main/rust/stores/src/fetch_core.rs +++ b/app/src/main/rust/stores/src/fetch_core.rs @@ -609,8 +609,6 @@ fn classify_reqwest_error(err: &reqwest::Error) -> FetchFailKind { } else if err.is_connect() { FetchFailKind::Connect } else if err.is_body() || err.is_decode() { - // A truncated or undecodable body comes from the CDN, not the request. Rest that host so - // the retry goes to another mirror instead of reading the same bad object again. FetchFailKind::ServerFault } else { FetchFailKind::Other @@ -1424,9 +1422,6 @@ impl FetchScheduler { }; h.samples = h.samples.saturating_add(1); h.consecutive_errors = 0; - // A success can belong to a request that was already in flight when another response from - // this host failed. Keep a server-fault cooldown until it expires instead of clearing it - // early and sending the retry straight back to the same mirror. if let Some(fault) = h.fault_until.filter(|until| *until > now) { h.cooldown_until = Some(h.cooldown_until.map_or(fault, |current| current.max(fault))); } else { diff --git a/app/src/main/rust/stores/src/fetch_tuning.rs b/app/src/main/rust/stores/src/fetch_tuning.rs index 5c380d537..8a11c3dc0 100644 --- a/app/src/main/rust/stores/src/fetch_tuning.rs +++ b/app/src/main/rust/stores/src/fetch_tuning.rs @@ -68,8 +68,6 @@ pub const SERVER_EXPLORE_EVERY: u64 = 8; pub const NOMINAL_CHUNK_RESERVE_BYTES: u64 = 1024 * 1024; pub const RATE_LIMIT_COOLDOWN_MS: u64 = 5_000; -// Long enough that every retry of the item (5 attempts, at most ~4.5 s of backoff) goes to another -// mirror after this one served a truncated or undecodable body. pub const SERVER_FAULT_COOLDOWN_MS: u64 = 10_000; pub fn retry_backoff_millis(attempt: u32) -> u64 { diff --git a/app/src/test/cpp/viture_display_modes_test.cpp b/app/src/test/cpp/viture_display_modes_test.cpp index ea6ea5462..956e05fe7 100644 --- a/app/src/test/cpp/viture_display_modes_test.cpp +++ b/app/src/test/cpp/viture_display_modes_test.cpp @@ -1,4 +1,3 @@ -// Compile with the installed SDK's headers to verify the two mode vocabularies. #include "viture_protocol_public.h" #include "../../main/cpp/viture/display_modes.h" using namespace droiddeck::viture; diff --git a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt index f0c1002eb..f2ed52f7e 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt @@ -17,11 +17,6 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -/** - * A download that was cut off keeps its bytes, and a second call with `resume` asks for the rest - * with a Range header instead of starting again. The server is a plain socket so the first answer - * can promise the whole file and then hang up halfway. - */ class DownloaderResumeTest { private val data = ByteArray(300_000) { (it % 251).toByte() } private val cutAt = 100_000 @@ -43,11 +38,9 @@ class DownloaderResumeTest { try { while (!server.isClosed) { val socket = server.accept() - // the client may hang up on a body it refuses; that must not stop the server runCatching { handle(socket) } } } catch (_: Exception) { - // closed by tearDown } }.apply { isDaemon = true; start() } } @@ -72,7 +65,6 @@ class DownloaderResumeTest { val content = if (corrupt && "/corrupt" in path) ByteArray(data.size) else data val from = range?.removePrefix("bytes=")?.substringBefore('-')?.toIntOrNull() if (misbehave) { - // answers a Range request with a 206 that starts at 0, and serves plain requests whole val start = if (from == null) "200 OK" else "206 Partial Content" val extra = if (from == null) "" else "Content-Range: bytes 0-${data.size - 1}/${data.size}\r\n" out.write("HTTP/1.1 $start\r\n${extra}${validator}Content-Length: ${data.size}\r\nConnection: close\r\n\r\n".toByteArray()) @@ -89,7 +81,6 @@ class DownloaderResumeTest { out.write("HTTP/1.1 200 OK\r\n${validator}Content-Length: ${data.size}\r\nConnection: close\r\n\r\n".toByteArray()) out.write(content, 0, cutAt) out.flush() - // hang up with most of the promised body still unsent } else { val rest = data.size - from out.write(("HTTP/1.1 206 Partial Content\r\nContent-Range: bytes $from-${data.size - 1}/${data.size}\r\n" + @@ -187,7 +178,6 @@ class DownloaderResumeTest { val target = File(dir, "runtime.tar.zst") misbehave = true val polls = java.util.concurrent.atomic.AtomicInteger() - // candidate selection and attempt each check once before the request; later checks follow chunks val cancelled = java.util.function.BooleanSupplier { polls.incrementAndGet() > 2 } assertFalse(Downloader.downloadCandidates(first, listOf(first, second), target, true, null, cancelled, null)) assertEquals("a cancel must not fall back to the next source", listOf(null), requests.toList()) diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesDisplayTest.kt index 923f98848..38ccf3649 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesDisplayTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesDisplayTest.kt @@ -93,7 +93,6 @@ class GlassesDisplayTest { @Test fun gtMaxSmartGlassesEdidUsesTheSameOutputSelectionAsViture() { val panel = display("SmartGlasses", productName = "SmartGlasses", productId = "980") assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = true)?.displayId) - // The display remains usable while USB control reconnects during a mode switch. assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = false)?.displayId) } @@ -126,7 +125,6 @@ class GlassesDisplayTest { } } - // Robolectric exposes display names and flags, but has no product-metadata setter. @Implements(Display::class) class ProductMetadataDisplay : ShadowDisplay() { var productMetadata: DeviceProductInfo? = null diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMenuProjectionTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMenuProjectionTest.kt index d1cd22769..0192d9840 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMenuProjectionTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMenuProjectionTest.kt @@ -63,7 +63,6 @@ class GlassesMenuProjectionTest { private object GlassesMenuProjection { data class Uv(val x: Float, val y: Float) - // Row-major head-to-anchor rotation; +Y is up, -Z is forward, output UV starts at the top left. fun intersect(frame: GlassesMenuPose.Frame, rayX: Float, rayY: Float, eye: Float, halfWidth: Float, distance: Float, aspect: Float): Uv? { if (frame.rotation.size != 9 || frame.position.size != 3 || frame.rotation.any { !it.isFinite() } || diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesOutputHandoffTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesOutputHandoffTest.kt index e1258f489..1669d236f 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesOutputHandoffTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesOutputHandoffTest.kt @@ -30,7 +30,6 @@ class GlassesOutputHandoffTest { private lateinit var screen: GlassesScreen @Before fun setup() { - // Exercise the output lifecycle without starting the native Linux runtime. activity = Robolectric.buildActivity(SessionActivity::class.java).get() phone = SurfaceView(activity) ReflectionHelpers.setField(activity, "surfaceView", phone) @@ -46,7 +45,7 @@ class GlassesOutputHandoffTest { @Test fun selectingGlassesDoesNotHidePhoneOrClaimOutputBeforeAttachment() { assertFalse(active()) assertEquals(View.VISIBLE, phone.visibility) - attach(phone.holder) // A late phone callback must not complete the handoff. + attach(phone.holder) assertFalse(active()) assertEquals(View.VISIBLE, phone.visibility) } diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt index 4b5972ede..5c4b38d23 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt @@ -183,11 +183,11 @@ class GlassesRenderingTest { picture3d = GlassesSettings.PICTURE_SCREEN) for (state in listOf(ultra.copy(displayId = -1), GlassesState())) { val preview = GlassesRendering.cardboard(base, settings, state, false, true) - assertTrue(preview.enabled) // Published as headset proximity, preventing standby. + assertTrue(preview.enabled) assertTrue(preview.mono) assertEquals(Cardboard.SOURCE_SBS, preview.source) assertEquals(Cardboard.Viewer.NONE.id, preview.viewer) - assertTrue(preview.headTracking) // Phone preference, not dormant glasses settings. + assertTrue(preview.headTracking) assertFalse(base.enabled) assertSame(base, GlassesRendering.cardboard(base, settings, state, false, false)) } diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesSteamVrTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesSteamVrTest.kt index 02ab5aca8..8d4175374 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesSteamVrTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesSteamVrTest.kt @@ -251,8 +251,6 @@ class GlassesSteamVrTest { .set(activity, LoadingState(activity).apply { visible = false }) SessionState.steamVr = true SessionState.mode = SessionService.MODE_STEAM - // Hold the external worker so status transitions can be exercised - // without launching a Linux runtime from the host test process. val worker = SessionActivity::class.java.getDeclaredField("steamVrWorker") .apply { isAccessible = true }.get(null) as ThreadPoolExecutor val blocked = CountDownLatch(1) diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoCalibrationTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoCalibrationTest.kt index 996b8fa8c..70ef3a221 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoCalibrationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoCalibrationTest.kt @@ -115,8 +115,6 @@ class RayNeoCalibrationTest { val upright = RayNeoOrientationFilter(profile.rotationXDeg, profile.gtMaxAxes, saved) .update(sample(floatArrayOf(-0.02f, 8.819f, 4.106f), reading.gyroDps)) assertArrayEquals(floatArrayOf(1f, 0f, 0f, 0f), upright, 0.001f) - // This was the reported failure: the same physical pose cannot calibrate - // through the Max's permutation and 35-degree mounting correction. assertNull(finish(RayNeoCalibrationCollector(RayNeoSensorAxes(35f, true), 0), sample = reading)) } diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoLinkTest.kt index 832d220c7..a880bf96f 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoLinkTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoLinkTest.kt @@ -102,7 +102,6 @@ class RayNeoLinkTest { val cmd = bytes[1].toInt() and 0xff commands += cmd if (gtTraffic && cmd in listOf(0, 1)) { - // Notifications and unrelated ACKs can precede the requested response. for (other in listOf(0x08, 6, 7, 0x50)) { responses += ByteArray(64).apply { this[0] = 0x99.toByte(); this[1] = 0xc8.toByte(); this[2] = 64 diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilterTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilterTest.kt index c05b7eb19..0dc767629 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilterTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilterTest.kt @@ -79,8 +79,6 @@ class RayNeoOrientationFilterTest { val first = screen(filter.update(sample(0, level))) val ref = HeadPose.recenter(first) var q = FloatArray(4) - // Physical 30-degree turn over one second, through the complete shared - // VITURE conversion and recenter pipeline. GT signs match device captures. repeat(100) { i -> val a = Math.toRadians((i + 1) * 0.3) val accel = when (axis) { diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoProtocolTest.kt index 0cf666308..03fcd5bb5 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoProtocolTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoProtocolTest.kt @@ -116,7 +116,7 @@ class RayNeoProtocolTest { @Test fun tickWrapDoesNotIntroduceOrientationJump() { val filter = RayNeoOrientationFilter(0f) filter.update(sample(0xfffffff0L, 90f)) - val q = filter.update(sample(84, 90f)) // 100 ticks = 10ms across uint32 rollover. + val q = filter.update(sample(84, 90f)) assertEquals(kotlin.math.sin(Math.PI * 0.005).toFloat(), q[2], 1e-4f) } } diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureSdkTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureSdkTest.kt index 0bc140777..3be90f93c 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/VitureSdkTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureSdkTest.kt @@ -71,8 +71,8 @@ class VitureSdkTest { val s = kotlin.math.sin(Math.PI / 12).toFloat() val c = kotlin.math.cos(Math.PI / 12).toFloat() for ((axis, expected) in listOf( - 2 to floatArrayOf(-0.5f, 0f, -0.8660254f), // Observed left turn: positive Y. - 1 to floatArrayOf(0f, 0.5f, -0.8660254f), // Observed upward look: positive X. + 2 to floatArrayOf(-0.5f, 0f, -0.8660254f), + 1 to floatArrayOf(0f, 0.5f, -0.8660254f), 3 to floatArrayOf(-0.5f, 0.8660254f, 0f), )) { val q = floatArrayOf(c, 0f, 0f, 0f).apply { this[axis] = s } @@ -84,7 +84,6 @@ class VitureSdkTest { @Test fun ultraOpenGlAxesMatchExistingGlassesHeadTracker() { val a = sqrt(0.5f) - // OpenGL Y yaw maps to NWU Z; OpenGL X pitch maps to negative NWU Y. assertArrayEquals(floatArrayOf(0f, 0f, a, a), VitureSdkPose.toGlasses(floatArrayOf(a, 0f, a, 0f), true), 0.00001f) assertArrayEquals(floatArrayOf(0f, -a, 0f, a), diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2ProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2ProtocolTest.kt index 95f0cfb55..993d33d82 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2ProtocolTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2ProtocolTest.kt @@ -1,5 +1,4 @@ -// Fixtures adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. -// Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/. +// Fixtures adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/. package com.droiddeck.launcher.glasses import org.junit.Assert.assertArrayEquals diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/XrealOrientationFilterTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/XrealOrientationFilterTest.kt index 578d334a6..4aa94d69b 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/XrealOrientationFilterTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/XrealOrientationFilterTest.kt @@ -63,10 +63,8 @@ class XrealOrientationFilterTest { @Test fun capturedNeutralGravityStartsUprightBeforeRecenter() { val pose = XrealOrientationFilter().update(sample(1000000, accel = Vec3(-0.0909f, -9.6529f, -1.0731f)))!! - // An upright start must not contain the previous approximately 180-degree - // pitch correction. Check before recenter can hide a mounting error. assertTrue(kotlin.math.abs(pose[3]) > 0.99f) val screen = matrix(pose) - assertTrue(screen[7] > 0.99f) // Screen up points toward shared world +Z. + assertTrue(screen[7] > 0.99f) } } diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/XrealProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/XrealProtocolTest.kt index 0deee75dd..c84cd7cdb 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/XrealProtocolTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/XrealProtocolTest.kt @@ -93,7 +93,6 @@ class XrealProtocolTest { assertEquals(0x2822, XrealProtocol.read(requests).command) repeat(3) { assertEquals(0x275e, XrealProtocol.read(requests).command) } assertEquals(0, requests.available()) - // Actual 1S busy response remains an error, never a successful ack. assertFalse(XrealProtocol.acknowledged(byteArrayOf(0x22, 3, 8, 0x89.toByte(), 0x27))) } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt index ba2a53f4e..6ff43cb12 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt @@ -111,6 +111,9 @@ class DriverPairsTest { assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(840), pairs)) assertEquals("other 8xx keep WinNative", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs)) assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), pairs)) - assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs)) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(740), pairs)) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(722), pairs)) + assertEquals(DriverPairs.BANNER_ONEUI, DriverPairs.recommendedKey(gpu(740, oneUi = true), pairs)) + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), pairs)) } } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt index 9a22eb1fd..7348210c9 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt @@ -28,7 +28,6 @@ class GpuDetectionTest { } @Test fun nonAdrenoRenderersAreNeverAdrenoWhateverNumberTheyCarry() { - // Xclipse (Exynos), Mali and Immortalis names; familyOf ignores the number off Adreno. assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, 0)) assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, GpuInfo.threeDigits("Samsung Xclipse 920"))) assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, GpuInfo.threeDigits("Xclipse 940"))) diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLogTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLogTest.kt index e54564dba..7a6daf420 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLogTest.kt @@ -17,7 +17,7 @@ class SteamVrDeviceLossLogTest { assertFalse(log.poll()) file.appendText("-4\n") assertTrue(log.poll()) - assertTrue(log.poll()) // Keep pending until the activity is foreground. + assertTrue(log.poll()) } @Test fun onlyExplicitDeviceLossTriggersRecovery() { diff --git a/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt b/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt index ac9348f52..724275fd3 100644 --- a/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt @@ -8,7 +8,6 @@ import org.junit.Test import kotlin.random.Random class FrameStatsTest { - /** MangoHud's update_hud_info_with_frametime and fps()/frame_timing(), line by line (overlay.cpp). */ private class Mango { val frametimeData = ArrayList(List(200) { 0f }) var lastPresentTime = 0L @@ -69,6 +68,30 @@ class FrameStatsTest { assertFalse(feed.poll(now + 1)) } + @Test fun slowFramesCountOnlyFramesOver33MsInTheWindow() { + val stats = FrameStats() + var now = 1_000_000_000L + for (ft in listOf(16_666_667L, 40_000_000L, 16_666_667L, 50_000_000L, 2_000_000_000L, 16_666_667L)) { + now += ft + stats.add(ft, now) + } + assertEquals(2, stats.slowFrames(now, 60_000_000_000L)) + assertEquals(0, stats.slowFrames(now + 120_000_000_000L, 60_000_000_000L)) + stats.reset() + assertEquals(0, stats.slowFrames(now, 60_000_000_000L)) + } + + @Test fun compositorFramesDoNotCountAsLiveGamescopeFrames() { + val feed = GameFrameFeed(java.io.File("/nonexistent/droiddeck-hud/frames")) + var now = 1_000_000_000L + assertFalse(feed.live) + feed.onCompositorFrame(now) + now += 16_666_667L + feed.onCompositorFrame(now) + feed.poll(now) + assertFalse(feed.live) + } + @Test fun aCompletedPeriodIsCountedWhenMangoHudWouldRedraw() { val stats = FrameStats() var now = 1_000_000_000L diff --git a/app/src/test/java/com/droiddeck/launcher/hud/MangoReadingsTest.kt b/app/src/test/java/com/droiddeck/launcher/hud/MangoReadingsTest.kt index 246d97296..4de40ea2a 100644 --- a/app/src/test/java/com/droiddeck/launcher/hud/MangoReadingsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/hud/MangoReadingsTest.kt @@ -18,12 +18,10 @@ class MangoReadingsTest { @Test fun cpuLoadFollowsMangoHudsFormulaAndTruncates() { val stat = File(tmp.root, "stat") val m = readings() - // user nice system idle iowait irq softirq steal guest guest_nice stat.writeText("cpu 1000 100 500 8000 400 50 50 0 200 0\ncpu0 500 50 250 4000 200 25 25 0 100 0\ncpu1 500 50 250 4000 200 25 25 0 100 0\nintr 0\n") m.cpu(stat) stat.writeText("cpu 1500 100 700 8600 500 60 70 10 300 0\ncpu0 900 50 400 4100 250 30 35 10 200 0\ncpu1 600 50 300 4500 250 30 35 0 100 0\nintr 0\n") val cpu = m.cpu(stat)!! - // busy = user-guest 400 + nice 0 + system+irq+softirq 230 + steal 10 + guest 100 = 740 of 1440 (idle+iowait 700) assertEquals((740 * 100f / 1440).toInt(), cpu.percent) assertEquals(51, cpu.percent) assertArrayEquals(intArrayOf(((300 + 165 + 10 + 100) * 100f / (300 + 165 + 10 + 100 + 150)).toInt(), ((100 + 65) * 100f / (100 + 65 + 550)).toInt()), cpu.perCore) diff --git a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt index 5759556ae..db813c62d 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt @@ -240,6 +240,30 @@ class BackQamInputTest { assertTrue(taps.isEmpty()) } + @Test fun withHoldsOffTapsStillWorkButHoldsAndChordsDoNothing() { + val noHold = BackQamInput(changes::add, taps::add, holds = { false }) + fun press(action: Int) = noHold.onKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_BACK), true) + press(KeyEvent.ACTION_DOWN) + press(KeyEvent.ACTION_UP) + advance(500) + assertEquals(listOf(false), taps) + press(KeyEvent.ACTION_DOWN) + press(KeyEvent.ACTION_UP) + press(KeyEvent.ACTION_DOWN) + press(KeyEvent.ACTION_UP) + assertEquals(listOf(false, true), taps) + taps.clear() + press(KeyEvent.ACTION_DOWN) + advance(2000) + assertTrue(press(KeyEvent.ACTION_UP)) + press(KeyEvent.ACTION_DOWN) + noHold.onChordInput() + press(KeyEvent.ACTION_UP) + advance(1000) + assertTrue(changes.isEmpty()) + assertTrue(taps.isEmpty()) + } + private fun advance(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis)) private fun send(action: Int, repeat: Int = 0, device: Int = 4, enabled: Boolean = true) = diff --git a/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt index 8a66101f1..f1ed57778 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt @@ -38,4 +38,15 @@ class SecondScreenModeTest { assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening("dashboard", deckPad = true, dashboard = false)) assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("dashboard", deckPad = false)) } + + @Test fun theDesktopOpensAgainOnlyWhileItCanRun() { + assertEquals(SecondScreenMode.DESKTOP, SecondScreenMode.opening("desktop", deckPad = true, dashboard = true, desktop = true)) + assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening("desktop", deckPad = true, dashboard = true)) + assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("desktop", deckPad = false)) + } + + @Test fun withNothingRememberedTheDesktopIsNeverPicked() { + assertEquals(SecondScreenMode.DASHBOARD, SecondScreenMode.opening(null, deckPad = false, dashboard = true, desktop = true)) + assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening(null, deckPad = false, desktop = true)) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt index 5c49c5265..a78cd7f0c 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt @@ -738,7 +738,7 @@ class TouchscreenGesturesTest { @Test fun pinchReversalDoesNotConsumeOldDirectionRemainder() { startPinch() events.clear() - movePinch(4.5f) // Nine pixels toward zooming in, short of a step. + movePinch(4.5f) assertTrue(events.none { it.startsWith("wheel") }) for (i in 1..10) movePinch(4.5f - i * .5f) assertEquals(listOf("wheel 1"), events.filter { it.startsWith("wheel") }) @@ -794,16 +794,13 @@ class TouchscreenGesturesTest { override fun moveTo(x: Float, y: Float) { cachedX = x; clientX = x } override fun prepareRelativePan() { cachedX = 960f; clientX = 960f } override fun moveBy(dx: Float, dy: Float, relative: Boolean) { - // Before pointer lock, the compositor delivers an absolute - // position even when we inject a relative delta. The game - // only requests lock after seeing this first pan movement. cachedX += dx clientX = cachedX } override fun button(code: Int, down: Boolean) { if (code == 274) { middleDown = down - if (down) clientX = 960f // The client warps to the middle when pan starts. + if (down) clientX = 960f } } override fun key(code: Int, down: Boolean) {} diff --git a/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt index 2c1d90ca0..e40328041 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt @@ -29,7 +29,6 @@ class BootMovieLibraryTest { private fun movie(name: String) = File(movies, name).also { it.parentFile!!.mkdirs(); it.writeText("movie") } - /** A movie DroidDeck itself imported: kept in the library, copied into Steam's folder and noted in the list. */ private fun imported(title: String, source: String = BootMovieLibrary.SOURCE_IMPORT, repo: String? = null): String { val staged = File(BootMovieLibrary.libraryDir(app), ".t-${System.nanoTime()}-${Thread.currentThread().id}.staged").also { it.parentFile!!.mkdirs(); it.writeText("movie") } return BootMovieLibrary.commit(app, staged, title, source, repo)!! @@ -258,11 +257,9 @@ class BootMovieLibraryTest { steamPicks("b.webm") assertTrue(BootMovie.syncFromSteam(app)) assertEquals("file:b.webm", BootMovie.choice(app)) - // Picking another one here is not undone by the same Steam setting. BootMovie.select(app, "file:a.webm") assertFalse(BootMovie.syncFromSteam(app)) assertEquals("file:a.webm", BootMovie.choice(app)) - // A new pick inside Steam is adopted again. movie("c.webm") steamPicks("c.webm") assertTrue(BootMovie.syncFromSteam(app)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentsNightlyCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentsNightlyCatalogTest.kt index 8fbb0bacd..5dbd3163f 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ComponentsNightlyCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentsNightlyCatalogTest.kt @@ -55,11 +55,9 @@ class ComponentsNightlyCatalogTest { val nVkEc = item("DD-vkd3d-proton-arm64ec-3.0.1+455-nightly.wcp", "vkd3d", n) val pairs = ComponentsManager.nightlyPairs(listOf(dxvk, ec, gpl, fex, vk) + others + listOf(nDxvk, nEc, nGpl, nFex, nVk, nVkEc)) assertEquals(mapOf(dxvk to nDxvk, ec to nEc, gpl to nGpl, fex to nFex, vk to nVk), pairs) - // Two nightlies for one stable build: the newer one wins. val nDxvkNewer = item("DD-dxvk-3.1.1+154-nightly.wcp", "dxvk", n) assertEquals(mapOf(dxvk to nDxvkNewer), ComponentsManager.nightlyPairs(listOf(dxvk, nDxvkNewer, nDxvk))) assertEquals(mapOf(dxvk to nDxvkNewer), ComponentsManager.nightlyPairs(listOf(dxvk, nDxvk, nDxvkNewer))) - // A nightly of a version with no stable build pairs with nothing. assertEquals(emptyMap(), ComponentsManager.nightlyPairs(listOf(item("dxvk-3.1-linux.wcp", "dxvk", "Dxvk-Linux"), nDxvk))) } diff --git a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt index ba026b474..241d81545 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt @@ -36,7 +36,6 @@ class ForcedPackageTest { ComponentsManager.unpackedCacheKey(filename) fail("Expected cache traversal name to be rejected: $filename") } catch (_: IllegalArgumentException) { - // Expected: the derived path would be a dot segment. } } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameTelemetryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameTelemetryTest.kt index fc5cbfca9..ebbd21b66 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/GameTelemetryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/GameTelemetryTest.kt @@ -16,12 +16,12 @@ class GameTelemetryTest { @Test fun codemastersFloatsBecomeADash() { val data = packet(264) { - putFloat(7 * 4, 25f) // m/s + putFloat(7 * 4, 25f) putFloat(29 * 4, 0.8f) putFloat(31 * 4, 0.1f) putFloat(33 * 4, 3f) putFloat(36 * 4, 1f) - putFloat(37 * 4, 650f) // rpm / 10 + putFloat(37 * 4, 650f) putFloat(63 * 4, 800f) putFloat(45 * 4, 30f) putFloat(46 * 4, 60f) @@ -35,7 +35,6 @@ class GameTelemetryTest { assertEquals(0.5f, t.fuel!!, 0.001f) } - // Values from a DiRT Showdown packet recorded on a Retroid Pocket Duo (extradata 3 still sends 152 bytes there). @Test fun codemastersShortPacketFromDirtShowdown() { val data = packet(152) { putFloat(1 * 4, 80.085f) @@ -124,7 +123,6 @@ class GameTelemetryTest { assertNull(GameTelemetry.parse(1234, ByteArray(400), 400)) } - // DiRT Showdown's 38 floats already carry steering and both G-forces. @Test fun codemastersGForceAndSteering() { val data = packet(152) { putFloat(30 * 4, -0.5f) @@ -181,7 +179,6 @@ class GameTelemetryTest { assertEquals(GameTelemetry.DL_SHIFT or GameTelemetry.DL_ABS, t.lights) } - // BeamNG leaves some OutGauge fields at zero; those stay hidden. @Test fun outGaugeZeroFieldsStayHidden() { val t = GameTelemetry.parse(GameTelemetry.OUTGAUGE_PORT, packet(92) { put(10, 2) }, 92)!! assertNull(t.turboBar) diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameThreadHintsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameThreadHintsTest.kt index 16f5d7d2c..8233957e2 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/GameThreadHintsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/GameThreadHintsTest.kt @@ -9,9 +9,7 @@ class GameThreadHintsTest { @Test fun `a thread joins above 60 percent and leaves under 15`() { assertEquals(emptySet(), GameThreadHints.pick(emptySet(), mapOf(1 to 0.55))) assertEquals(setOf(1), GameThreadHints.pick(emptySet(), mapOf(1 to 0.68))) - // Faster where it was moved, or capped by the display, it stays boosted at 25%... assertEquals(setOf(1), GameThreadHints.pick(setOf(1), mapOf(1 to 0.25))) - // ...and is let go once it idles. assertEquals(emptySet(), GameThreadHints.pick(setOf(1), mapOf(1 to 0.1))) } @@ -28,8 +26,8 @@ class GameThreadHintsTest { assertTrue(GameThreadHints.holdsPace(119.0, 120.0)) assertTrue(GameThreadHints.holdsPace(60.5, 120.0)) assertTrue(GameThreadHints.holdsPace(30.0, 120.0)) - assertFalse(GameThreadHints.holdsPace(92.0, 120.0)) // falling behind: boost + assertFalse(GameThreadHints.holdsPace(92.0, 120.0)) assertFalse(GameThreadHints.holdsPace(105.0, 120.0)) - assertFalse(GameThreadHints.holdsPace(3.0, 120.0)) // headless or loading: boost + assertFalse(GameThreadHints.holdsPace(3.0, 120.0)) } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt new file mode 100644 index 000000000..996035e31 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt @@ -0,0 +1,114 @@ +package com.droiddeck.launcher.session + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class GpuLayerLoadTest { + @get:Rule val tmp = TemporaryFolder() + private var now = 0L + private fun load(dir: File) = GpuStatsComponent.LayerLoad(dir, clock = { now }) + private fun write(dir: File, pid: Int, busyNs: Long) = File(dir, "$pid").writeText("%20d %-32s\n".format(busyNs, "game")) + + @Test fun busyTimeOfAllProcessesOverWallTime() { + val dir = tmp.newFolder("gpu-load") + val load = load(dir) + write(dir, 100, 1_000_000_000L) + write(dir, 200, 5_000_000_000L) + now = 10_000_000_000L + assertNull(load.percent()) + write(dir, 100, 1_300_000_000L) + write(dir, 200, 5_100_000_000L) + now += 1_000_000_000L + assertEquals(40L, load.percent()) + } + + @Test fun aNewProcessStartsFromItsOwnTotalAndAGoneOneStopsCounting() { + val dir = tmp.newFolder("gpu-load") + val load = load(dir) + write(dir, 100, 2_000_000_000L) + now = 1L + load.percent() + File(dir, "100").delete() + write(dir, 300, 9_000_000_000L) + now += 500_000_000L + assertEquals(0L, load.percent()) + write(dir, 300, 9_250_000_000L) + now += 500_000_000L + assertEquals(50L, load.percent()) + } + + @Test fun capsAtFullAndIgnoresOtherFiles() { + val dir = tmp.newFolder("gpu-load") + val load = load(dir) + File(dir, "on").createNewFile() + write(dir, 1, 0L) + write(dir, 2, 0L) + now = 1L + load.percent() + write(dir, 1, 900_000_000L) + write(dir, 2, 900_000_000L) + now += 1_000_000_000L + assertEquals(100L, load.percent()) + } + + @Test fun theOnFlagFollowsTheHudAndHidingDropsTheBaseline() { + val dir = tmp.newFolder("gpu-load") + val load = load(dir) + load.wanted(true) + assertTrue(File(dir, "on").exists()) + write(dir, 1, 100L) + now = 1L + load.percent() + load.wanted(false) + assertFalse(File(dir, "on").exists()) + load.wanted(true) + now += 1_000L + assertNull(load.percent()) + } + + @Test fun theCompositorsOwnGpuTimeAddsToTheGames() { + val dir = tmp.newFolder("gpu-load") + var compositor: Long? = 7_000_000_000L + val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, extra = { compositor }) + write(dir, 100, 1_000_000_000L) + now = 1L + assertNull(load.percent()) + write(dir, 100, 1_600_000_000L) + compositor = 7_150_000_000L + now += 1_000_000_000L + assertEquals(75L, load.percent()) + compositor = null + write(dir, 100, 1_900_000_000L) + now += 1_000_000_000L + assertEquals(30L, load.percent()) + } + + @Test fun onlyTheCompositorCountsWhenNoGameReportsYet() { + val dir = File(tmp.root, "never-created") + var compositor = 0L + val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, extra = { compositor }) + now = 1L + load.percent() + compositor = 200_000_000L + now += 1_000_000_000L + assertEquals(20L, load.percent()) + } + + @Test fun compositorTimingFollowsTheHudOnlyOnChanges() { + val dir = tmp.newFolder("gpu-load") + val calls = mutableListOf() + val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, onWanted = { calls += it }) + load.wanted(true) + load.wanted(true) + load.wanted(false) + load.wanted(true) + load.clear() + assertEquals(listOf(true, false, true, false), calls) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt new file mode 100644 index 000000000..f0d7478d8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt @@ -0,0 +1,74 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GpuMemstoreLoadTest { + private var busy = 0L + private var samples = 0L + private var moves = 0L + private var available = true + private val load = GpuStatsComponent.MemstoreLoad { if (available) longArrayOf(busy, samples, moves) else null } + + private fun tick(newSamples: Long, newBusy: Long, newMoves: Long = 1) { + samples += newSamples + busy += newBusy + moves += newMoves + } + + @Test fun notTrustedUntilNewWorkIsSeen() { + assertNull(load.percent()) + tick(500, 400, newMoves = 0) + assertNull(load.percent()) + assertFalse(load.working) + tick(500, 300) + assertEquals(70L, load.percent()) + assertTrue(load.working) + } + + @Test fun givesUpWhenTheTimestampsNeverMove() { + load.percent() + repeat(10) { + tick(500, 250, newMoves = 0) + assertNull(load.percent()) + } + assertTrue(load.failed) + tick(500, 250) + assertNull(load.percent()) + assertFalse(load.working) + } + + @Test fun averagesOverTheLastTwoReads() { + load.percent() + tick(500, 500) + assertEquals(100L, load.percent()) + tick(500, 0) + assertEquals(50L, load.percent()) + tick(500, 0, newMoves = 0) + assertEquals(0L, load.percent()) + } + + @Test fun pausingStartsANewWindow() { + load.percent() + tick(500, 500) + load.percent() + load.pause() + tick(0, 0, newMoves = 0) + assertNull(load.percent()) + tick(500, 100) + assertEquals(20L, load.percent()) + } + + @Test fun noReadingWithoutNewSamplesOrCounts() { + load.percent() + tick(500, 100) + load.percent() + assertNull(load.percent()) + available = false + tick(500, 100) + assertNull(load.percent()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt index 5d51028b0..82512c0b9 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt @@ -4,6 +4,8 @@ import org.junit.Assert.* import org.junit.Test class SessionDisplayTest { + private val scaledFollow = listOf("follow/1.5", "follow/2", "follow/2.5", "follow/3", "follow/4") + @Test fun portraitPresetsSwapTheOutputAndRetainTheResolutionCap() { assertEquals(720 to 1280, SessionDisplay.resolveChoice(1920 to 1080, "720p", portrait = true)) assertEquals(900 to 1600, SessionDisplay.resolveChoice(1080 to 1920, "900p", portrait = true)) @@ -40,13 +42,13 @@ class SessionDisplayTest { @Test fun optionsListTheHeightsThePanelCanShowThenThePanel() { val thor = SessionDisplay.resolutionOptions(1920 to 1080) - assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN), thor) - assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN), + assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN) + scaledFollow, thor) + assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN) + scaledFollow, SessionDisplay.resolutionOptions(2400 to 1080)) - assertEquals(listOf("720p", "900p", "1080p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN), + assertEquals(listOf("720p", "900p", "1080p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN) + scaledFollow, SessionDisplay.resolutionOptions(3120 to 1440)) val small = SessionDisplay.resolutionOptions(1280 to 720) - assertEquals(listOf(SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN), small) + assertEquals(listOf(SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN) + scaledFollow, small) assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN)) assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN)) } @@ -105,6 +107,31 @@ class SessionDisplayTest { assertNull(SessionDisplay.followSize(640, 360)) } + @Test fun scaledFollowDividesTheWindowAndStaysEven() { + assertEquals(1260 to 540, SessionDisplay.resolveChoice(1080 to 2520, "follow/2")) + assertEquals(540 to 1260, SessionDisplay.resolveChoice(2520 to 1080, "follow/2", portrait = true)) + assertEquals(1092 to 984, SessionDisplay.followSize(2185, 1969, scale = 2f)) + assertEquals(656 to 728, SessionDisplay.followSize(1968, 2184, scale = 3f)) + assertNull(SessionDisplay.followSize(640, 360, scale = 2f)) + assertEquals(720 to 1600, SessionDisplay.resolveChoice(2400 to 1080, "follow/1.5", portrait = true)) + assertEquals(1600 to 720, SessionDisplay.resolveChoice(1080 to 2400, "follow/1.5")) + assertNull(SessionDisplay.followScale("follow/1.0")) + } + + @Test fun onlyKnownFollowChoicesCountAsFollow() { + assertEquals(1f, SessionDisplay.followScale(SessionDisplay.FOLLOW_SCREEN)) + assertEquals(3f, SessionDisplay.followScale("follow/3")) + assertEquals(1.3f, SessionDisplay.followScale("follow/1.3")) + assertEquals(1.3f, SessionDisplay.followScale("follow/1.26")) + assertEquals("follow/1.3", SessionDisplay.followScaled(1.3f)) + assertEquals("follow/2", SessionDisplay.followScaled(2f)) + assertNull(SessionDisplay.followScale("follow/1")) + assertNull(SessionDisplay.followScale("follow/x")) + assertNull(SessionDisplay.followScale("follow/9")) + assertNull(SessionDisplay.followScale("720p")) + assertFalse(SessionDisplay.isFollow(SessionDisplay.MATCH_SCREEN)) + } + @Test fun aPhoneCanFollowTheShortGameAreaAboveItsControls() { assertNull(SessionDisplay.followSize(720, 404)) assertEquals(720 to 404, SessionDisplay.followSize(720, 404, controlsBelow = true)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt index 674f6211e..ff7fd9510 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -27,7 +27,6 @@ class SessionLogShareZipTest { val earlySecret = "early-sessionid-abcdef123456" val trailingSecret = "late-refresh-token-abcdef123456" try { - // One long line exercises the streaming limit; the final ordinary line must still be redacted. raw.bufferedWriter().use { out -> out.write("sessionid=$earlySecret") val chunk = "x".repeat(8192) @@ -40,7 +39,6 @@ class SessionLogShareZipTest { ZipFile(zip).use { archive -> val entry = archive.getEntry("${session.name}/steam-42.log")!! archive.getInputStream(entry).use { input -> - // Only inspect the bounded scrubbed prefix: the pre-fix raw archive is over 64 MiB. val expected = "${com.droiddeck.launcher.core.LogRedactor.OVERSIZED_LINE}\n" val prefix = ByteArray(expected.toByteArray(StandardCharsets.UTF_8).size) var offset = 0 @@ -74,7 +72,6 @@ class SessionLogShareZipTest { "Authorization: Bearer abc.def.ghi\n" + "kept: EResult 5, app 1086940, steamapps/common/Game\n" ) - // StoreLogFiles.prune, started by App.onCreate, deletes a stores log named for a day over a week old. val storesLog = "stores-" + SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + ".log" File(logs, "stores").apply { mkdirs() }.resolve(storesLog).writeText("epic: f_token=zzzzzzzz12 window 32\n") File(logs, SessionPaths.TOOLS_DIR).apply { mkdirs() }.resolve("flatpak-install.log").writeText("== install\nCookie: steamLoginSecure=76561198000000000%7C%7Ceyabc\n") diff --git a/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt b/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt index 636e7e439..8d2f97ae3 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt @@ -5,16 +5,30 @@ import org.junit.Test class StatsLayoutTest { @Test fun unknownIdsFallBackToTheDefaults() { - assertEquals(StatsStrip.PANEL, StatsStrip.from(null)) + assertEquals(StatsStrip.LINE, StatsStrip.from(null)) + assertEquals(StatsStrip.LINE, StatsStrip.from("gone")) + assertEquals(StatsStrip.PANEL, StatsStrip.from("panel")) assertEquals(StatsStrip.RINGS, StatsStrip.from("rings")) assertEquals(StatsDash.AUTO, StatsDash.from("gone")) assertEquals(StatsDash.TRACE, StatsDash.from("trace")) } @Test fun tappingTheStripCyclesThroughEveryStyle() { + assertEquals(StatsStrip.PANEL, StatsStrip.LINE.next()) assertEquals(StatsStrip.TILES, StatsStrip.PANEL.next()) assertEquals(StatsStrip.RINGS, StatsStrip.TILES.next()) - assertEquals(StatsStrip.PANEL, StatsStrip.RINGS.next()) + assertEquals(StatsStrip.LINE, StatsStrip.RINGS.next()) + } + + @Test fun stripNumbersStartWithEverythingButTheGpuTemperature() { + assertEquals(StripMetric.entries.toSet() - StripMetric.GPU_TEMP, StripMetric.parse(null)) + } + + @Test fun stripNumbersSurviveSavingAndSkipUnknownIds() { + val picked = setOf(StripMetric.GPU, StripMetric.RAM, StripMetric.CLOCK) + assertEquals(picked, StripMetric.parse(StripMetric.encode(picked))) + assertEquals(setOf(StripMetric.CPU), StripMetric.parse("cpu,gone, ")) + assertEquals(emptySet(), StripMetric.parse("")) } @Test fun autoPicksTheDashByTheGamesFormat() { diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamLibrariesTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamLibrariesTest.kt new file mode 100644 index 000000000..183618ab8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamLibrariesTest.kt @@ -0,0 +1,73 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.frontend.Library +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SteamLibrariesTest { + private val app get() = RuntimeEnvironment.getApplication() + @get:Rule val tmp = TemporaryFolder() + + private fun added(path: String, label: String = File(path).name): SteamLibraries.Entry = + (SteamLibraries.add(app, path, label) as SteamLibraries.AddResult.Added).entry + + @Test fun addedLibrariesGetStableMountsThatSurviveRenameAndRemoval() { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + val docs = added(tmp.newFolder("Documents").path, "Docs") + val usb = added(tmp.newFolder("USB").path) + assertEquals(listOf("/mnt/droiddeck-lib/1", "/mnt/droiddeck-lib/2"), SteamLibraries.list(app).map { it.guest }) + assertTrue(File(docs.path, "steamapps").isDirectory) + + SteamLibraries.rename(app, docs.id, "My \"Docs\"") + assertEquals("My Docs", SteamLibraries.list(app).first().label) + assertEquals("/mnt/droiddeck-lib/1", SteamLibraries.list(app).first().guest) + + SteamLibraries.remove(app, docs.id) + assertEquals(listOf(usb.id), SteamLibraries.list(app).map { it.id }) + assertTrue(File(docs.path, "steamapps").isDirectory) + assertEquals("3", added(tmp.newFolder("Again").path).id) + } + + @Test fun overlappingFoldersAndAFifthLibraryAreRefused() { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + val games = tmp.newFolder("Games") + added(games.path) + assertTrue(SteamLibraries.add(app, games.path) is SteamLibraries.AddResult.Refused) + assertTrue(SteamLibraries.add(app, File(games, "inner").apply { mkdirs() }.path) is SteamLibraries.AddResult.Refused) + repeat(SteamLibraries.MAX - 1) { added(tmp.newFolder("L$it").path) } + assertTrue(SteamLibraries.add(app, tmp.newFolder("Full").path) is SteamLibraries.AddResult.Refused) + } + + @Test fun addedLibrariesAreListedMappedAndKeepGamesFoldersFromDoubling() { + val pc = tmp.newFolder("PC") + File(pc, "steamapps").mkdirs() + File(pc, "steamapps/appmanifest_220.acf").writeText("x") + SessionPrefs.setAddedGamesDirs(app, listOf(pc.path)) + val lib = added(pc.path, "PC") + assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty()) + assertEquals(1, Library.steamLibraries(app, "arm64").count { it.first == pc }) + assertTrue(Library.steamLibraries(app, "x86_64").any { it.first == File(pc, GameStorage.X64_CARD_DIR) }) + assertEquals(File(pc, "steamapps/common"), AddedGames.hostPath(app, "${lib.guest}/steamapps/common")) + val docs = added(tmp.newFolder("Documents").path) + assertEquals("${docs.guest}/steamapps", AddedGames.guestPath(app, File(docs.path, "steamapps"))) + val nested = File(pc, "Other").apply { mkdirs() } + SteamLibraries.remove(app, lib.id) + assertTrue(SteamLibraries.add(app, nested.path) is SteamLibraries.AddResult.Refused) + } + + @Test fun unreadableOrInvalidEntriesAreDropped() { + assertTrue(SteamLibraries.parse("{oops").isEmpty()) + assertEquals(listOf("2"), SteamLibraries.parse("""[{"id":"../x","path":"/a"},{"id":"2","path":"/b","label":""}]""").map { it.id }) + assertEquals("b", SteamLibraries.parse("""[{"id":"2","path":"/b","label":""}]""").single().label) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamWebLoginTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamWebLoginTest.kt index 01988bf84..0b8fadada 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SteamWebLoginTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamWebLoginTest.kt @@ -7,7 +7,6 @@ import org.junit.Test class SteamWebLoginTest { private fun hex(s: String) = ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } - // Made with: openssl enc -aes-128-cbc -K -iv <16 spaces>, prefixed "v10". @Test fun chromiumV10ValuesDecrypt() { val encrypted = hex("7631300b9cf0a6eee9cf1aaac601b85ca37cf9fe9e67078564f0e05835ae2aa3d9b0ab") assertEquals("0123456789abcdef01234567", SteamWebLogin.decrypt(encrypted, hostHashed = false)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/TelemetryOutputTest.kt b/app/src/test/java/com/droiddeck/launcher/session/TelemetryOutputTest.kt index cd65b2a57..248b4a179 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/TelemetryOutputTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/TelemetryOutputTest.kt @@ -12,7 +12,6 @@ import java.io.File class TelemetryOutputTest { @get:Rule val tmp = TemporaryFolder() - // DiRT Showdown's own line, as found in its prefix on the Retroid Pocket Duo. private val showdown = "\n\n" + "\t\n" diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreCatalogPatchTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreCatalogPatchTest.kt index e545cc77d..e3b91ff6f 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreCatalogPatchTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreCatalogPatchTest.kt @@ -4,7 +4,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Test -/** Filling a grid one card at a time: what is on screen stays put, what is new lands where its title belongs. */ class StoreCatalogPatchTest { private fun item(id: String, title: String, image: String? = null) = CatalogItem(Store.GOG, id, title, imageUrl = image, owned = true) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/download/DownloadQueueRetryTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/download/DownloadQueueRetryTest.kt index 879e8de91..072999178 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/download/DownloadQueueRetryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/download/DownloadQueueRetryTest.kt @@ -75,7 +75,6 @@ class DownloadQueueRetryTest { DownloadQueue.clear(app, item.key) assertNull(DownloadQueue.entry(item.key)) - // A card resume (including the restored paused path) must retain its real target. val card = File(app.filesDir, "retry-card").apply { mkdirs() } SessionPrefs.setGameStorage(app, card.path, "Card") val target = File(card, "Games") diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt index 0c6502e93..5aace971f 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt @@ -7,7 +7,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** The account listing: everything the tab needs to draw a library before a product is asked about. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) class GogListingTest { diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncSnapshotTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncSnapshotTest.kt new file mode 100644 index 000000000..5416bc304 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncSnapshotTest.kt @@ -0,0 +1,52 @@ +package com.droiddeck.launcher.sync + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SyncSnapshotTest { + private fun folder(state: String = "idle", global: Long = 100, need: Long = 0, blocked: Boolean = false, paused: Boolean = false, errors: Int = 0) = + SyncFolder("id", "Saves", "/p", paused, blocked, state, global, need, errors, emptyList()) + + private fun snapshot(vararg folders: SyncFolder) = SyncSnapshot("me", folders.toList(), emptyList(), emptyList(), emptyList(), 0, 0) + + @Test + fun syncingReportsPercentOfActiveFolders() { + val s = snapshot(folder("syncing", global = 1000, need = 360), folder()) + assertTrue(s.syncing) + assertEquals(64, s.percent) + assertEquals(360, s.needBytes) + } + + @Test + fun blockedOrFailingFoldersNeedAttention() { + assertTrue(snapshot(folder(blocked = true, paused = true)).needsAttention) + assertTrue(snapshot(folder(errors = 2)).needsAttention) + assertTrue(snapshot(folder("idle", need = 5)).needsAttention) + assertFalse(snapshot(folder(paused = true, errors = 2)).needsAttention) + } + + @Test + fun unreadableFolderStatusShowsAsError() { + val config = JSONObject("""{"id":"saves","label":"Saves","path":"/p","paused":false,"devices":[{"deviceID":"DEV1"}]}""") + val f = SyncthingParse.folder(config, SyncthingParse.unreadableStatus(), blocked = false, names = mapOf("DEV1" to "Desktop PC")) + assertEquals("error", f.state) + assertTrue(f.errors > 0) + assertTrue(f.outOfSync) + assertFalse(f.syncing) + assertEquals(listOf("Desktop PC"), f.sharedWith) + assertTrue(snapshot(f).needsAttention) + } + + @Test + fun parsesPendingFoldersWithDeviceNames() { + val json = JSONObject("""{"emu":{"offeredBy":{"DEV1":{"label":"Emulation saves","time":"2026-01-01T00:00:00Z"}}}}""") + val pending = SyncthingParse.pendingFolders(json, mapOf("DEV1" to "Desktop PC")) + assertEquals(listOf(SyncPendingFolder("emu", "Emulation saves", "Desktop PC", "DEV1")), pending) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncthingConfigTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingConfigTest.kt new file mode 100644 index 000000000..4d51bde19 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingConfigTest.kt @@ -0,0 +1,83 @@ +package com.droiddeck.launcher.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class SyncthingConfigTest { + @get:Rule val tmp = TemporaryFolder() + + private val generated = """ + + + false + + + false + + +

dynamic
+ false + + +
127.0.0.1:8384
+ droiddeck + key123 +
+ + default + 21027 + 0 + true + + + + + + """.trimIndent() + + private val ports = SyncthingPorts(22100, 21127) + + @Test + fun pausesDisallowedFoldersBeforeLaunch() { + val out = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync") { !it.startsWith("/storage/emulated/0/DCIM") } + val camera = out.substringAfter("id=\"abc\"").substringBefore("") + val saves = out.substringAfter("id=\"def\"").substringBefore("") + assertTrue(camera.contains("true")) + assertTrue(saves.contains("false")) + } + + @Test + fun pausesDevicesBeforeLaunchWhenPolicyRequires() { + val paused = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync", pauseDevices = true) { true } + assertTrue(paused.substringAfter("id=\"PEER\"").substringBefore("").contains("true")) + val open = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync", pauseDevices = false) { true } + assertTrue(open.substringAfter("id=\"PEER\"").substringBefore("").contains("false")) + } + + @Test + fun replacesConfigFileWithoutLeavingStagedCopy() { + val dir = tmp.newFolder() + val file = File(dir, "config.xml").apply { writeText(generated) } + SyncthingConfig.apply(file, ports, File("/data/st/gui.sock"), File("/ext/Sync"), false) { true } + assertEquals(SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync") { true }, file.readText()) + assertEquals(listOf("config.xml"), dir.list()!!.toList()) + } + + @Test + fun applyIsStable() { + val once = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync") { true } + assertEquals(once, SyncthingConfig.apply(once, ports, "/data/st/gui.sock", "/ext/Sync") { true }) + } + + @Test + fun guiListensOnlyOnPrivateSocket() { + val gui = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync") { true }.substringAfter("") + assertTrue(gui.contains("
/data/st/gui.sock
")) + assertTrue(gui.contains("600")) + assertTrue(gui.contains("tls=\"false\"")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncthingGuardTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingGuardTest.kt new file mode 100644 index 000000000..94d7f1584 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingGuardTest.kt @@ -0,0 +1,69 @@ +package com.droiddeck.launcher.sync + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +class SyncthingGuardTest { + @get:Rule val tmp = TemporaryFolder() + + private fun setup(): Triple { + val data = tmp.newFolder("data") + val external = tmp.newFolder("external") + val home = File(external, "Sync").apply { mkdirs() } + val config = File(data, "files/syncthing").apply { mkdirs() } + return Triple(SyncthingGuard(home, listOf(data, external), listOf(config)), data, external) + } + + @Test + fun allowsFoldersInsideAppStorage() { + val (guard, data, external) = setup() + assertTrue(guard.allows(File(external, "Sync/Game saves").path)) + assertTrue(guard.allows(File(data, "files/linux/home").path)) + assertTrue(guard.allows("~/Shared")) + assertTrue(guard.allows(external.path)) + } + + @Test + fun blocksFoldersOutsideAppStorage() { + val (guard, _, _) = setup() + assertFalse(guard.allows(tmp.newFolder("DCIM").path)) + assertFalse(guard.allows("/storage/emulated/0/Download")) + assertFalse(guard.allows("")) + assertFalse(guard.allows("relative/path")) + } + + @Test + fun blocksSyncthingsOwnConfig() { + val (guard, data, _) = setup() + assertFalse(guard.allows(File(data, "files/syncthing").path)) + assertFalse(guard.allows(File(data, "files/syncthing/keys").path)) + } + + @Test + fun blocksFoldersHoldingSyncthingsConfig() { + val (guard, data, _) = setup() + assertFalse(guard.allows(data.path)) + assertFalse(guard.allows(File(data, "files").path)) + } + + @Test + fun blocksSiblingWithSharedPrefix() { + val (guard, _, external) = setup() + assertFalse(guard.allows(external.path + "-other")) + } + + @Test + fun blocksSymlinkPointingOutside() { + val (guard, _, external) = setup() + val outside = tmp.newFolder("outside") + val link = File(external, "Sync/link") + Files.createSymbolicLink(link.toPath(), outside.toPath()) + assertFalse(guard.allows(link.path)) + assertFalse(guard.allows(File(link, "child").path)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncthingHttpTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingHttpTest.kt new file mode 100644 index 000000000..1efb0d919 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingHttpTest.kt @@ -0,0 +1,30 @@ +package com.droiddeck.launcher.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream + +class SyncthingHttpTest { + private fun response(raw: String) = SyncthingHttp.readResponse(raw.byteInputStream(Charsets.ISO_8859_1)) + + @Test + fun readsContentLengthBody() { + assertEquals(200 to """{"ping":"pong"}""", response("HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"ping\":\"pong\"}")) + } + + @Test + fun readsChunkedBody() { + assertEquals(403 to "CSRF Error", response("HTTP/1.1 403 Forbidden\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nCSRF\r\n6\r\n Error\r\n0\r\n\r\n")) + } + + @Test + fun writesOneShotRequest() { + val out = ByteArrayOutputStream() + SyncthingHttp.writeRequest(out, "POST", "/rest/system/pause", listOf("X-API-Key" to "k"), null) + val text = out.toString("ISO-8859-1") + assertTrue(text.startsWith("POST /rest/system/pause HTTP/1.1\r\n")) + assertTrue(text.contains("Connection: close\r\n")) + assertTrue(text.endsWith("X-API-Key: k\r\nContent-Length: 0\r\n\r\n")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncthingPortsTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingPortsTest.kt new file mode 100644 index 000000000..3bb8017bb --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingPortsTest.kt @@ -0,0 +1,18 @@ +package com.droiddeck.launcher.sync + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SyncthingPortsTest { + @Test + fun skipsBusyPorts() { + val busy = setOf(22100, 21127) + val picked = SyncthingPortPicker.pick({ it !in busy }, { it !in busy }) + assertEquals(SyncthingPorts(22101, 21128), picked) + } + + @Test(expected = IllegalStateException::class) + fun failsWhenNoPortIsFree() { + SyncthingPortPicker.pick({ false }, { true }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt index 5a5141889..5d167e14e 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt @@ -31,7 +31,6 @@ class BottomDashboardTest { } @Test fun tabNamesShowOnlyWhileEveryTabFits() { - // Retroid Pocket Duo bottom panel: 1280x960 at 320 dpi = 640 dp wide. assertTrue(bottomTabsWithLabels(640f, 4)) assertFalse(bottomTabsWithLabels(640f, 5)) assertFalse(bottomTabsWithLabels(360f, 3)) diff --git a/app/src/test/java/com/droiddeck/launcher/ui/DeckControlsPanelTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/DeckControlsPanelTest.kt index 4aa3a0327..c6ebe838d 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/DeckControlsPanelTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/DeckControlsPanelTest.kt @@ -22,14 +22,11 @@ class DeckControlsPanelTest { val directory = Files.createTempDirectory("deck-panel").toFile() try { SessionState.deckPad = false - // No pad: the Deck layouts (trackpads, grips). A pad without Deck controls: the gamepad layout - // (sticks, d-pad, face buttons). for (pad in listOf(null, PadBridge(directory))) for (portraitDock in listOf(false, true)) { val name = "pad=${pad != null} portraitDock=$portraitDock" val panel = DeckControlsPanel(RuntimeEnvironment.getApplication(), onClose = {}, onSteamMenu = {}, onQam = {}, pad = pad, portraitDock = portraitDock) assertEquals(name, pad == null, panel.deckControls) - // The portrait dock sits under a portrait game; its gamepad layout needs a tall panel. val (w, h) = if (portraitDock) 720 to 1000 else 1200 to 480 panel.measure(View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY)) @@ -41,7 +38,6 @@ class DeckControlsPanelTest { val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, x.toFloat(), y.toFloat(), 0) assertEquals("$name ($x, $y)", expected, panel.dispatchTouchEvent(down)) down.recycle() - // Cancel rather than lift, so no button fires its action. MotionEvent.obtain(0L, 1L, MotionEvent.ACTION_CANCEL, x.toFloat(), y.toFloat(), 0).also { panel.dispatchTouchEvent(it); it.recycle() } if (expected) taken++ else missed++ } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/HistoryTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/HistoryTest.kt new file mode 100644 index 000000000..0e4fef58d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/HistoryTest.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HistoryTest { + @Test fun theSpanIsTheTimeTheKeptReadingsCover() { + val h = History(3) + h.add(1f, 1_000L) + h.add(2f, 1_500L) + assertEquals(1, h.spanSeconds()) + h.add(3f, 2_000L) + h.add(4f, 4_000L) + assertEquals(listOf(2f, 3f, 4f), h.snapshot()) + assertEquals(3, h.spanSeconds()) + } + + @Test fun aHistoryThatStopsBeingFedKeepsItsSpan() { + val h = History(4) + h.add(1f, 10_000L) + h.add(2f, 20_000L) + assertEquals(10, h.spanSeconds()) + assertEquals(10, h.spanSeconds()) + } + + @Test fun readingsWithoutATimeHaveNoSpan() { + val h = History(2) + h.add(1f) + assertEquals(0, h.spanSeconds()) + assertEquals(0, History(2).spanSeconds()) + assertEquals(0, History(2).apply { add(1f, 5_000L) }.spanSeconds()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StripFitTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StripFitTest.kt new file mode 100644 index 000000000..6d9aed0a1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/StripFitTest.kt @@ -0,0 +1,26 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class StripFitTest { + @Test fun everythingFitsOnAWideLine() { + assertArrayEquals(booleanArrayOf(true, true, true), fitByPriority(intArrayOf(10, 20, 30), intArrayOf(0, 1, 2), 5, 100)) + } + + @Test fun theLeastImportantItemsAreLeftOutFirst() { + assertArrayEquals(booleanArrayOf(true, false, true), fitByPriority(intArrayOf(10, 20, 30), intArrayOf(0, 2, 1), 5, 60)) + } + + @Test fun anItemThatDoesNotFitEndsTheLine() { + assertArrayEquals(booleanArrayOf(true, false, false), fitByPriority(intArrayOf(10, 50, 5), intArrayOf(0, 1, 2), 2, 40)) + } + + @Test fun equalPrioritiesKeepTheirOrder() { + assertArrayEquals(booleanArrayOf(true, true, false), fitByPriority(intArrayOf(10, 10, 10), intArrayOf(0, 0, 0), 0, 25)) + } + + @Test fun nothingFitsInNoRoom() { + assertArrayEquals(booleanArrayOf(false, false), fitByPriority(intArrayOf(10, 10), intArrayOf(0, 1), 0, 5)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/TrackingRecoveryViewTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/TrackingRecoveryViewTest.kt index 3275e622f..c921d7370 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/TrackingRecoveryViewTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/TrackingRecoveryViewTest.kt @@ -78,7 +78,6 @@ class TrackingRecoveryViewTest { difference+=abs(((actual ushr 16) and 255)-((reference ushr 16) and 255)) } } - // Native text rasterization can round a translated glyph edge differently. assertTrue("Eye at x=$x changed $changed pixels",changedreal_volume), PA_VOLUME_NORM); level = ((uint64_t)requested * maximum + PA_VOLUME_NORM / 2) / PA_VOLUME_NORM; pa_cvolume_set(&sink->real_volume, sink->sample_spec.channels, (uint64_t)level * PA_VOLUME_NORM / maximum); - /* Android applies media gain to both PulseAudio and DirectAudio streams. */ pa_cvolume_reset(&sink->soft_volume, sink->sample_spec.channels); android_volume_request(sink, "volume", level); } @@ -88,7 +87,6 @@ static void android_volume_refresh(pa_mainloop_api *api, pa_time_event *event, c pa_xfree(mute_request); pa_xfree(processing); pa_xfree(mute_processing); - /* A held volume key steps Android every ~50 ms; sampling at 100 ms hands Steam uneven multi-step jumps. */ unsigned delay_ms = ANDROID_VOLUME_IDLE_MS; if (bridge->active_ticks) { bridge->active_ticks--; diff --git a/tools/build_local.sh b/tools/build_local.sh index 2d3f08a1b..216b1e0df 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -49,7 +49,7 @@ if ! command -v docker >/dev/null 2>&1; then echo "Docker is required to cross-compile the glibc ARM64 preload libraries." >&2 exit 1 fi -for tool in curl tar zstd shasum unzip python3; do +for tool in curl tar zstd shasum unzip python3 go; do if ! command -v "${tool}" >/dev/null 2>&1; then echo "${tool} is required to build the CI-equivalent APK." >&2 exit 1 @@ -90,8 +90,6 @@ trap 'exit 143' TERM cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"} mkdir -p "${cache_dir}" -# Steps whose output depends only on tracked sources are kept per input hash in the -# shared cache, so a rerun or a fresh worktree copies them instead of rebuilding. inputs_hash() { (cd "${repo_root}" && { printf '%s\n' "$1"; shift; find "$@" -type f ! -name '*.pyc' | LC_ALL=C sort | xargs shasum -a 256; }) \ | shasum -a 256 | cut -d' ' -f1 @@ -109,7 +107,6 @@ save_step() { part=$(mktemp -d "${stored}.part.XXXXXX") (cd "$3" && tar -cf - .) | tar -xf - -C "${part}" touch "${part}/.complete" - # Another checkout may have published the same key meanwhile; its copy is equivalent. if [[ -e "${stored}" ]]; then rm -rf "${part}"; else mv "${part}" "${stored}" || rm -rf "${part}"; fi } @@ -186,6 +183,13 @@ if ! restore_step vr "${vr_key}" "${vr_out}"; then save_step vr "${vr_key}" "${vr_out}" fi +gpuload_out="${linuxfs_dir}/usr/local/lib/droiddeck-gpuload" +gpuload_key=$(inputs_hash "${image_id} ${ndk_version} ${VK_INCLUDE:-}" tools/gpuload) +if ! restore_step gpuload "${gpuload_key}" "${gpuload_out}"; then + DROIDDECK_BUILD_IMAGE="${image_name}" "${repo_root}/tools/gpuload/build.sh" "${gpuload_out}" + save_step gpuload "${gpuload_key}" "${gpuload_out}" +fi + github_repo=${DROIDDECK_GITHUB_REPOSITORY:-} if [[ -z "${github_repo}" ]]; then origin_url=$(git -C "${repo_root}" remote get-url origin) @@ -342,6 +346,27 @@ else (cd "${proot_out}" && shasum -a 256 libproot.so libproot-loader.so > .proot-outputs) fi +syncthing_src="${repo_root}/tools/syncthing/syncthing-android/syncthing/src/github.com/syncthing/syncthing" +git -C "${repo_root}" submodule update --init --recursive tools/syncthing/syncthing-android +syncthing_inputs=$(printf '%s\n' "$(cd "${repo_root}" && shasum -a 256 tools/syncthing/build.sh \ + tools/syncthing/syncthing-android/gradle/libs.versions.toml)" \ + "$(git -C "${syncthing_src}" rev-parse HEAD)" \ + "$(git -C "${syncthing_src}" diff --binary HEAD | shasum -a 256)" \ + "$(cd "${syncthing_src}" && git ls-files -z --others --exclude-standard | xargs -0 shasum -a 256)" \ + "${ndk_version}" | shasum -a 256 | cut -d' ' -f1) +if [[ -f "${proot_out}/libsyncthingnative.so" \ + && "$(cat "${proot_out}/.syncthing-inputs" 2>/dev/null)" = "${syncthing_inputs}" ]]; then + echo "syncthing: sources unchanged, keeping ${proot_out}/libsyncthingnative.so" +else + syncthing_stage="${staging_dir}/syncthing" + if ! restore_step syncthing "${syncthing_inputs}" "${syncthing_stage}"; then + "${repo_root}/tools/syncthing/build.sh" "${syncthing_stage}" + save_step syncthing "${syncthing_inputs}" "${syncthing_stage}" + fi + install -m755 "${syncthing_stage}/libsyncthingnative.so" "${proot_out}/" + echo "${syncthing_inputs}" > "${proot_out}/.syncthing-inputs" +fi + bundle_dir="${staging_dir}/pulseaudio-bundle" mkdir -p "${bundle_dir}" zstd -dc "${bundle_asset}" | tar -xf - -C "${bundle_dir}" diff --git a/tools/cardboard/test_cardboard_params.c b/tools/cardboard/test_cardboard_params.c index 8b284cf6a..e1f761c1e 100644 --- a/tools/cardboard/test_cardboard_params.c +++ b/tools/cardboard/test_cardboard_params.c @@ -45,8 +45,6 @@ int main(void) { cardboard_push_constants(&q, pose, 0, 0, 0, 0, pc); CHECK(isfinite(pc[17]) && isfinite(pc[19])); - // Narrow optical profiles and the lower SteamVR slider values must reach - // the projection unchanged, rather than all becoming a 30-degree view. const float glasses_fovs[] = {22.0f, 23.3f, 24.0f, 25.8f, 29.0f}; for (unsigned i = 0; i < sizeof(glasses_fovs) / sizeof(glasses_fovs[0]); i++) { q.fov_deg = glasses_fovs[i]; diff --git a/tools/check_glasses_menu_shader.py b/tools/check_glasses_menu_shader.py index 1b775b3e3..45ed20677 100644 --- a/tools/check_glasses_menu_shader.py +++ b/tools/check_glasses_menu_shader.py @@ -156,7 +156,6 @@ def main(): read(0,0,width,height,0x1908,0x1401,actual) samples = [] for x, y in coords: - # Readback is GL bottom-up; coordinates below are image top-left. offset = ((height-1-y)*width+x)*4 rgb = list(actual[offset:offset+3]) eye = 0 if mono or x < width/2 else 1 diff --git a/tools/components/preloads.py b/tools/components/preloads.py index 5f16610c3..808d51046 100644 --- a/tools/components/preloads.py +++ b/tools/components/preloads.py @@ -1,4 +1,3 @@ -"""Reuse reviewed preload snapshots; changed snapshots must prove their main-build origin.""" import argparse import gzip import http.client @@ -147,7 +146,6 @@ def producer_sources(group, commit, root=ROOT): name = 'arm64-preloads' if group == 'arm64' else 'x86-preloads' patterns = definitions[name]['source_inputs'] else: - # Older approved builds kept ARM64 commands inline, before the Vulkan/VR build. if group == 'arm64': text = run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() text = text[text.index(' aarch64-linux-gnu-g++ -shared'):] diff --git a/tools/components/prepare.py b/tools/components/prepare.py index fbcd27394..122ba98c9 100644 --- a/tools/components/prepare.py +++ b/tools/components/prepare.py @@ -1,4 +1,3 @@ -"""One entry point for runtime recipes, cache identities and verified job handoff.""" import argparse import hashlib import json diff --git a/tools/components/recipes.json b/tools/components/recipes.json index 656e7798c..88ae5b79d 100644 --- a/tools/components/recipes.json +++ b/tools/components/recipes.json @@ -14,6 +14,7 @@ "tools/msitools", "tools/pipewire", "tools/vr", + "tools/gpuload", "app/src/main/cpp/waylandcomp/generated", "app/src/main/cpp/waylandcomp/src/headpose_shm.h", "app/src/main/cpp/waylandcomp/src/spatialpose_shm.h", @@ -21,7 +22,8 @@ ], "outputs": [ "app/src/main/assets/linuxfs/usr/local/bin/gamescope", - "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so" + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so", + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so" ], "ndk": true, "packages": [ diff --git a/tools/components/recipes/runtime.sh b/tools/components/recipes/runtime.sh index 92bcaad6c..aa5273d27 100644 --- a/tools/components/recipes/runtime.sh +++ b/tools/components/recipes/runtime.sh @@ -4,6 +4,8 @@ D=app/src/main/assets/linuxfs mkdir -p "$D" VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \ tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr" +VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \ + tools/gpuload/build.sh --native "$D/usr/local/lib/droiddeck-gpuload" for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")" done diff --git a/tools/deploy_fast.py b/tools/deploy_fast.py index 259dc89cb..958ac3051 100644 --- a/tools/deploy_fast.py +++ b/tools/deploy_fast.py @@ -63,7 +63,6 @@ def graft_targets(): def changed_paths(donor_commit): - """Return {path: status} for the checkout, uncommitted and untracked files included.""" changes = {} fields = git('diff', '--name-status', '-z', '--no-renames', donor_commit).decode().split('\0') for status, path in zip(fields[0::2], fields[1::2]): @@ -74,7 +73,6 @@ def changed_paths(donor_commit): def classify(changes, targets, donor_entries): - """Split changes into grafts {path: asset}, ignored paths and blockers {path: reason}.""" grafts, ignored, blocked = {}, [], {} for path, status in sorted(changes.items()): if is_ignored(path): @@ -96,7 +94,6 @@ def helper_bytes(path): def graft(donor, replacements, output): - """Copy the donor with entries replaced, dropping its signature so it can be re-signed.""" with ZipFile(donor) as source, ZipFile(output, 'w') as target: for info in source.infolist(): name = info.filename @@ -200,7 +197,6 @@ def pwsh(): def signing_env(): - """The .signing.env build_local.sh uses: the override, else the checkout's own, else the main checkout's.""" common = Path(git('rev-parse', '--path-format=absolute', '--git-common-dir').decode().strip()) for candidate in (os.environ.get('DROIDDECK_SIGNING_ENV'), ROOT / '.signing.env', common.parent / '.signing.env'): if candidate and Path(candidate).is_file(): @@ -218,7 +214,6 @@ def sign_with_env(env_file, unsigned, signed): build_tools = sorted(complete, key=version_key, reverse=True) if not build_tools: raise Refused('no build-tools with aapt, zipalign and apksigner under ' + str(sdk_root() / 'build-tools')) - # BUILD_TOOLS is assigned after the env file is sourced so the file cannot replace the chosen SDK. script = 'set -a; cd "$(dirname "$1")"; . "$1"; set +a; BUILD_TOOLS="$5" exec "$2" "$3" standard "$4"' result = subprocess.run(['bash', '-c', script, 'sign', str(env_file), str(ROOT / 'tools/release/sign-apk.sh'), str(unsigned), str(signed), str(build_tools[0].parent.resolve())], @@ -230,7 +225,6 @@ def sign_with_env(env_file, unsigned, signed): def sign(unsigned, signed): if not SIGNER.is_file() and (env_file := signing_env()): return sign_with_env(env_file, unsigned, signed) - # Microsoft Store Python virtualizes AppData for child processes too and hides the signer; use a python.org install. def quoted(value): return "'" + str(value).replace("'", "''") + "'" command = (f"if (-not (Test-Path -LiteralPath {quoted(SIGNER)})) {{ Write-Error 'Local signer not found'; exit 1 }}; " diff --git a/tools/desktop-kde/closure.py b/tools/desktop-kde/closure.py index 2c74e299f..73d3781a4 100755 --- a/tools/desktop-kde/closure.py +++ b/tools/desktop-kde/closure.py @@ -1,11 +1,4 @@ #!/usr/bin/env python3 -"""The packages the KDE desktop package ships: the dependency closure of the seeds over Arch Linux -ARM's extracted repository databases, minus what the runtime already has at a version that serves. - - closure.py - -Prints one "repo/filename sha256" per package to ship (the sha256 the repository database gives). -""" import collections import os import re @@ -107,7 +100,6 @@ def main(): providers[pname].append((name, pver or pkgs[name]["version"])) def resolve(dep): - """The package to take for a dependency string, and whether the runtime's copy serves.""" name, op, want = parse_dep(dep) if name in runtime and satisfies(runtime[name], op, want): return name, True diff --git a/tools/desktop-kde/patches/0002-select-explicit-nested-render-node.patch b/tools/desktop-kde/patches/0002-select-explicit-nested-render-node.patch index 369ce1a56..8c0b8cccb 100644 --- a/tools/desktop-kde/patches/0002-select-explicit-nested-render-node.patch +++ b/tools/desktop-kde/patches/0002-select-explicit-nested-render-node.patch @@ -1,13 +1,12 @@ --- a/src/backends/wayland/wayland_backend.cpp +++ b/src/backends/wayland/wayland_backend.cpp -@@ -455,6 +455,20 @@ +@@ -455,6 +455,19 @@ if (WaylandClient::LinuxDmabufV1 *dmabuf = m_display->linuxDmabuf()) { m_renderDevice = GpuManager::self()->compatibleRenderDevice(dmabuf->mainDevice()); + if (!m_renderDevice && qEnvironmentVariableIsSet("DROIDDECK_NESTED_SCALE")) { + const auto nodes = GpuManager::splitPathList(qEnvironmentVariable("KWIN_RENDER_NODES")); + const auto &devices = GpuManager::self()->renderDevices(); -+ // Android's KGSL host cannot name a DRM device in its dmabuf feedback. + if (nodes.size() == 1) { + for (const auto &device : devices) { + if (device->path() == nodes.constFirst()) { diff --git a/tools/desktop-kde/removal-manifests.py b/tools/desktop-kde/removal-manifests.py index 1089f9421..1e9079697 100644 --- a/tools/desktop-kde/removal-manifests.py +++ b/tools/desktop-kde/removal-manifests.py @@ -1,15 +1,4 @@ #!/usr/bin/env python3 -"""Generate removal inventories from checksum-pinned published archives. - -Usage: removal-manifests.py - - - removal-manifests.py --add -Removal includes desktop applications/themes and our overlay, not their shared dependencies. -The KDE cache is verified against the published desktop-kde.packages.txt before use. -The LXQt cache contains the signed application packages listed by desktop.packages.txt; -verify their Arch Linux ARM Build System signatures before generating the inventory. -""" import gzip import hashlib import json diff --git a/tools/device-smoke.sh b/tools/device-smoke.sh index 80a883805..479088937 100755 --- a/tools/device-smoke.sh +++ b/tools/device-smoke.sh @@ -1,26 +1,31 @@ #!/usr/bin/env bash -# Smoke test of a DroidDeck build on a real device, over plain adb (no python, no root). -# -# What it does, in order, and restores afterwards: -# 1. prints the device (model, Android, kernel, page size, SoC); -# 2. installs --apk when given and launches the app; -# 3. checks that the app starts and that nothing in the crash buffer belongs to it; -# 4. changes the display density and the window size and checks the app keeps its process -# (and does not recreate the activity that is on top); -# 5. on a foldable cycles the device state (half-opened, and with --closed also closed) through -# `cmd device_state`, then puts the original state back; -# 6. reads the newest session folder of a debug build: first frame, last frame rate, the -# memory page size, the proot that session ran, and the last process exits. -# -# Usage: tools/device-smoke.sh [--serial SERIAL] [--apk FILE] [--pkg PACKAGE] -# [--no-density] [--no-size] [--no-fold] [--closed] -# Exit status: 0 when no check failed, 1 when one did, 2 for a usage or device problem. -# -# Not a replacement for tools/droiddeck-scenario, which drives a game through the agent bridge; -# this covers what that does not: display changes and foldable states. set -u export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' +usage() { + cat <<'EOF' +Smoke test of a DroidDeck build on a real device, over plain adb (no python, no root). + +What it does, in order, and restores afterwards: + 1. prints the device (model, Android, kernel, page size, SoC); + 2. installs --apk when given and launches the app; + 3. checks that the app starts and that nothing in the crash buffer belongs to it; + 4. changes the display density and the window size and checks the app keeps its process + (and does not recreate the activity that is on top); + 5. on a foldable cycles the device state (half-opened, and with --closed also closed) through + `cmd device_state`, then puts the original state back; + 6. reads the newest session folder of a debug build: first frame, last frame rate, the + memory page size, the proot that session ran, and the last process exits. + +Usage: tools/device-smoke.sh [--serial SERIAL] [--apk FILE] [--pkg PACKAGE] + [--no-density] [--no-size] [--no-fold] [--closed] +Exit status: 0 when no check failed, 1 when one did, 2 for a usage or device problem. + +Not a replacement for tools/droiddeck-scenario, which drives a game through the agent bridge; +this covers what that does not: display changes and foldable states. +EOF +} + pkg=com.droiddeck.launcher serial=${ANDROID_SERIAL:-} apk= @@ -39,7 +44,7 @@ while [ $# -gt 0 ]; do --no-size) do_size=0 ;; --no-fold) do_fold=0 ;; --closed) do_closed=1 ;; - -h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) usage; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac shift @@ -96,7 +101,6 @@ restore_size() { echo "$current" | grep -q 'Physical size:' || return 1 [ "$(echo "$current" | wm_override size)" = "$override_size" ] } -# An override that was already in place (a test lab pins one) is put back; otherwise the request is cancelled. restore_fold() { if [ -n "$pre_override" ]; then sh_ cmd device_state state "$pre_override" >/dev/null; else sh_ cmd device_state state reset >/dev/null; fi; } restore() { @@ -133,7 +137,7 @@ app_crashes() { pat=$(printf '%s' "$pkg" | sed 's/[.[\*^$]/\\&/g') printf '%s\n' "$out" | grep -c -E "(Process: |Cmdline: |>>> )$pat([ ,<:]|$)" || true } -relaunches_of() { # $1 = activity class fragment +relaunches_of() { sh_ logcat -b events -d 2>/dev/null | grep -E 'wm_(relaunch|destroy|create)_activity' | grep -c "$1" } @@ -214,7 +218,7 @@ if [ "$do_size" = 1 ]; then phys=$(echo "$size_snapshot" | sed -n 's/.*Physical size: \([0-9]*\)x\([0-9]*\).*/\1 \2/p' | head -1) set -- $phys if [ -n "${1:-}" ]; then - w=$(( ($2 * 2 / 3) / 2 * 2 )); h=$1 # a narrower, taller window, like a cover screen + w=$(( ($2 * 2 / 3) / 2 * 2 )); h=$1 pid0=$(app_pid) size_changed=1 wm_status=0 @@ -250,7 +254,7 @@ if [ "$do_fold" = 1 ]; then else id_of() { echo "$states" | sed -n "s/.*identifier=\([0-9]*\), name='$1'.*/\1/p" | head -1; } half=$(id_of HALF_OPENED); closed=$(id_of CLOSED) - cycle() { # $1 name $2 id + cycle() { pid0=$(app_pid) fold_changed=1 sh_ cmd device_state state "$2" >/dev/null; sleep 8 diff --git a/tools/directaudio/sink/da_relay_proto.h b/tools/directaudio/sink/da_relay_proto.h index 351cdfb60..2207ae929 100644 --- a/tools/directaudio/sink/da_relay_proto.h +++ b/tools/directaudio/sink/da_relay_proto.h @@ -1,49 +1,10 @@ -/* DirectAudio relay - the contract between the driver and the relay helper. - * +/* * Copyright 2026 The412Banner * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. - * - * WHY THIS EXISTS. The driver's whole premise is an in-process AAudio client: - * the guest's WASAPI buffers are mixed and handed to Android's audio server - * from inside the game, no daemon in the path. That is only possible when the - * game process is a bionic process. Under a glibc runtime (the Linux Steam - * client, where the game runs inside a proot'd Arch rootfs on Valve's own - * ARM64 Proton) libaaudio cannot be loaded at all: it is a bionic library and - * the rootfs's dynamic linker does not know how to bring it into a glibc - * process. The game side therefore has to stop at a boundary it CAN cross, and - * the only thing both worlds agree on is the kernel: a unix socket, a memfd and - * a futex word. - * - * SHAPE. One helper process (directaudio-relay, bionic, links libaaudio) runs - * on the Android side and listens on a unix socket. Each game process that - * needs audio connects once, sends its launch config (the same knobs the - * in-process build reads from BANNER_AUDIO_DIRECT_*), and gets back one or two - * shared rings (memfds passed with SCM_RIGHTS): - * - * render ring game (producer) -> helper (consumer) -> AAudio OUTPUT - * capture ring helper (producer, AAudio INPUT) -> game (consumer) - * - * The driver keeps its mixer and its per-voice WASAPI rings exactly as in the - * in-process build; what used to be the AAudio data callback is now a pump - * thread that keeps the render ring topped up, and the capture callback is a - * pump that drains the capture ring into the capture voices. Everything that - * needs an AAudioStream (open, adaptive buffer growth and decay, route-change - * reopen, the stalled-callback watchdog, the live-config mailbox) moves to the - * helper unchanged in spirit. - * - * COST. One extra hand-off: the ring. The helper's callback pulls a burst from - * the ring, so the ring must hold at least one burst at every callback; the - * driver keeps DA_RING_TARGET_BURSTS bursts queued and the helper raises that - * target when a callback finds the ring short. Everything else - the AAudio - * buffer, AudioFlinger's own latency - is what the in-process build pays too. - * - * WIRE FORMAT. Fixed-width, native-endian, same-machine only. The two sides are - * different libcs but the same CPU, so no marshalling beyond fixed-size structs. - * Bump DA_RELAY_VERSION on any layout change; the helper refuses a mismatch. */ #ifndef DA_RELAY_PROTO_H #define DA_RELAY_PROTO_H @@ -52,34 +13,20 @@ #include #define DA_RELAY_VERSION 1 -#define DA_RING_MAGIC 0x44415247u /* 'DARG' */ -#define DA_HELLO_MAGIC 0x44414831u /* 'DAH1' */ -#define DA_ACK_MAGIC 0x44414131u /* 'DAA1' */ +#define DA_RING_MAGIC 0x44415247u +#define DA_HELLO_MAGIC 0x44414831u +#define DA_ACK_MAGIC 0x44414131u -/* Socket path. The driver reads BANNER_AUDIO_DIRECT_RELAY; when unset it falls - * back to $XDG_RUNTIME_DIR/DA_RELAY_DEFAULT_NAME, which a host that already - * hands its runtime dir into the session gets for free. */ #define DA_RELAY_ENV "BANNER_AUDIO_DIRECT_RELAY" #define DA_RELAY_DEFAULT_NAME "directaudio-relay" -/* Both rings carry float32 interleaved stereo. The render ring is always - * 48 kHz (the mixer's output rate); the capture ring runs at whatever rate the - * input stream was granted, published in da_ring.rate, and the driver resamples - * per voice as the in-process build already does. */ #define DA_RING_RATE 48000 #define DA_RING_CHANNELS 2 -/* Render ring sizing. Capacity is the ceiling adaptive growth can reach; the - * initial target is what the driver keeps queued. Two bursts: the helper's - * callback consumes one burst and wakes the pump, which then has a full burst - * period to refill before the next callback. */ #define DA_RING_CAP_MS 250 #define DA_RING_TARGET_BURSTS 2 #define DA_RING_MAX_TARGET_MS 100 -/* Capture ring: 200 ms is far more than a game ever leaves queued; on overrun - * the helper drops the newest block, matching the in-process "guest is not - * draining" behaviour of dropping data rather than blocking the mic thread. */ #define DA_CAPTURE_RING_MS 200 enum da_ring_state @@ -93,35 +40,27 @@ struct da_ring { uint32_t magic; uint32_t version; - uint32_t cap_frames; /* ring capacity in frames */ - uint32_t rate; /* frames per second of the data in this ring */ - uint32_t channels; /* DA_RING_CHANNELS */ + uint32_t cap_frames; + uint32_t rate; + uint32_t channels; uint32_t _pad0; - /* Single producer, single consumer; monotonic frame totals, wrap-safe with - * unsigned arithmetic. Producer writes widx with RELEASE after the data; - * consumer writes ridx with RELEASE after it has copied the data out. */ volatile uint32_t widx; volatile uint32_t ridx; - /* Futex word the CONSUMER of a render ring / PRODUCER of a capture ring - * bumps after each block, so the other side can sleep on it instead of - * polling. Waiters always use a timeout, so a missed wake only costs one - * period. */ volatile uint32_t wake; - volatile uint32_t quit; /* either side: tear down */ - volatile uint32_t state; /* enum da_ring_state, helper-owned */ + volatile uint32_t quit; + volatile uint32_t state; - /* Render ring only, helper-owned, read by the driver: */ - volatile int32_t target_frames; /* keep at least this many queued */ - volatile int32_t hw_buf_frames; /* AAudio buffer size right now (get_latency) */ - volatile int32_t hw_burst; /* frames per burst of the output stream */ - volatile uint32_t underruns; /* callbacks that found fewer than they needed */ - volatile uint32_t cb_count; /* data callbacks so far (liveness) */ + volatile int32_t target_frames; + volatile int32_t hw_buf_frames; + volatile int32_t hw_burst; + volatile uint32_t underruns; + volatile uint32_t cb_count; uint32_t _pad1[3]; - float data[]; /* cap_frames * channels */ + float data[]; }; static inline size_t da_ring_bytes(uint32_t cap_frames) @@ -141,62 +80,52 @@ static inline uint32_t da_ring_space(const struct da_ring *r) return r->cap_frames - da_ring_avail(r); } -/* ---- wire messages ------------------------------------------------------- */ - -#define DA_HELLO_MIC 0x01u /* also open an INPUT stream + capture ring */ -#define DA_HELLO_EXCLUSIVE 0x02u /* AAUDIO_SHARING_MODE_EXCLUSIVE requested */ +#define DA_HELLO_MIC 0x01u +#define DA_HELLO_EXCLUSIVE 0x02u #define DA_HELLO_ADAPTIVE 0x04u #define DA_HELLO_DECAY 0x08u #define DA_HELLO_WATCHDOG 0x10u -/* First message on a fresh connection, driver -> helper. Mirrors the launch - * config the in-process build derives from BANNER_AUDIO_DIRECT_* so a preset - * means the same thing under both runtimes. */ struct da_hello { - uint32_t magic; /* DA_HELLO_MAGIC */ - uint32_t version; /* DA_RELAY_VERSION */ - uint32_t flags; /* DA_HELLO_* */ - int32_t perf; /* 0 NONE, 1 LOW_LATENCY, 2 POWER_SAVING (host convention) */ - int32_t target_ms; /* AAudio buffer, ms; 0 = use target_frames or the default */ - int32_t max_ms; /* adaptive ceiling, ms; 0 = max_frames or the default */ - int32_t target_frames; /* frame forms, used only when the ms forms are 0 */ + uint32_t magic; + uint32_t version; + uint32_t flags; + int32_t perf; + int32_t target_ms; + int32_t max_ms; + int32_t target_frames; int32_t max_frames; - uint32_t stall_ms; /* watchdog: callback silent this long => rebuild */ - uint32_t quiet_ms; /* decay pacing, see the driver */ + uint32_t stall_ms; + uint32_t quiet_ms; uint32_t punish_ms; uint32_t max_backoff; - int32_t pid; /* the game process, for the log */ - uint32_t log; /* BANNER_AUDIO_DIRECT_LOG */ - char runtime_path[256]; /* BANNER_AUDIO_DIRECT_RUNTIME mailbox, "" = none */ - char name[64]; /* process name, for the log */ + int32_t pid; + uint32_t log; + char runtime_path[256]; + char name[64]; }; -#define DA_ACK_MIC_FAILED 0x01u /* mic asked for, input stream would not open */ +#define DA_ACK_MIC_FAILED 0x01u -/* Reply, helper -> driver, followed by SCM_RIGHTS carrying the ring fds: - * fd[0] = render ring, fd[1] = capture ring (present iff in_cap_frames > 0). */ struct da_ack { - uint32_t magic; /* DA_ACK_MAGIC */ - int32_t status; /* 0 = ok, else an AAudio error code (negative) */ - int32_t rate; /* output stream rate (48000) */ - int32_t burst; /* output frames per burst */ - int32_t buf_frames; /* output buffer size as opened */ - int32_t out_cap_frames; /* render ring capacity */ - int32_t in_cap_frames; /* capture ring capacity, 0 = no capture ring */ - int32_t in_rate; /* rate of the capture ring's data */ - uint32_t flags; /* DA_ACK_* */ + uint32_t magic; + int32_t status; + int32_t rate; + int32_t burst; + int32_t buf_frames; + int32_t out_cap_frames; + int32_t in_cap_frames; + int32_t in_rate; + uint32_t flags; uint32_t _pad; }; -/* Subsequent messages, driver -> helper, on the same socket. The helper never - * writes after the ack; everything it has to say is in the ring header. EOF on - * the socket is how the helper learns the game is gone. */ enum da_msg_type { - DA_MSG_MIC_START = 1, /* first capture voice started: make the mic hot */ - DA_MSG_MIC_STOP = 2, /* last capture voice gone: stop (keep open) */ + DA_MSG_MIC_START = 1, + DA_MSG_MIC_STOP = 2, DA_MSG_PING = 3, }; @@ -206,4 +135,4 @@ struct da_msg int32_t arg; }; -#endif /* DA_RELAY_PROTO_H */ +#endif diff --git a/tools/directaudio/sink/module-directaudio-native-sink.c b/tools/directaudio/sink/module-directaudio-native-sink.c index 3b6e5f6c9..3311fe0ed 100644 --- a/tools/directaudio/sink/module-directaudio-native-sink.c +++ b/tools/directaudio/sink/module-directaudio-native-sink.c @@ -1,24 +1,5 @@ /* - * module-directaudio-native-sink: a PulseAudio 13 sink that is DirectAudio's AAudio engine, - * in-process. For the native Linux Steam client on Android (DroidDeck, Bannerlator's Linux - * session), whose sound can only ever reach a PulseAudio daemon: this module is where that - * daemon's mixed output meets Android, one step away from AudioFlinger, with the same engine the - * game driver has - adaptive device buffer that grows a burst on every xrun, optional decay with a - * remembered floor, a primed ring with fades instead of clicks, route-change / error reopen on a - * fresh stream, and a stall watchdog. - * - * It is the relay helper's per-client OUTPUT engine (directaudio-relay.c) with the socket and the - * shared-memory ring replaced by a ring in this process, fed by the sink's IO thread exactly as - * module-directaudio-sink feeds the relay. Compared with that module there is no second process - * and no 40 ms proot safety margin: the ring here only has to cover the IO thread's own - * scheduling jitter, so it starts at two bursts and grows only if a callback finds it short. - * - * The daemon must run on the Android side (the host app's process), not inside proot, for AAudio - * to be reachable at all - which is where both host apps already run it. - * - * Field fixes carried from the relay (MaxsTechReview, Droid-Deck/DroidDeck#338): prime before - * playing, fade on an underrun, one consumer per ring across a reopen, a device buffer of at - * least two bursts. + * Derived from the AAudio engine of DirectAudio's relay helper (directaudio-relay.c) by The412Banner. * * SPDX-License-Identifier: LGPL-2.1-or-later */ @@ -52,7 +33,7 @@ #include #include -#include "da_relay_proto.h" /* struct da_ring + the ring helpers; nothing of the socket protocol */ +#include "da_relay_proto.h" PA_MODULE_AUTHOR("The412Banner"); PA_MODULE_DESCRIPTION("Android audio output: DirectAudio's AAudio engine, in-process"); @@ -77,7 +58,6 @@ PA_MODULE_USAGE( #define WATCHDOG_TICK_USEC (1 * PA_USEC_PER_SEC) #define AUDIO_NICE (-16) -/* ---- tunables: the in-process driver's, same defaults ----------------------- */ #define DA_KNEE 0.75f #define DA_DEFAULT_MS 12 #define DA_DEFAULT_MAX_MS 100 @@ -112,17 +92,14 @@ struct userdata { pa_sample_spec ss; size_t frame_size; - /* configuration */ aaudio_performance_mode_t perf; int32_t target_ms, max_ms, ring_ms; bool adaptive, decay, limiter, watchdog_on; - /* the ring: this process's, same layout as the relay's so the engine is shared */ struct da_ring *ring; int32_t burst; int32_t ring_max_target; - /* the one OUTPUT stream and its adaptive state (the relay's struct client, minus the socket) */ AAudioStream *aq; pthread_mutex_t lock; int32_t last_xrun; @@ -134,7 +111,6 @@ struct userdata { int suspended; volatile int dead; - /* what the IO thread last logged, so engine changes made on the AAudio thread get a line */ int32_t seen_target, seen_hw_buf; uint32_t seen_underruns; pa_usec_t next_watchdog, settled_at, stats_at; @@ -152,8 +128,6 @@ static uint64_t now_ns(void) { static void request_reopen(struct userdata *u, const char *why); -/* ---- the engine: the relay's out_cb / open_output / reopen / watchdog, on a local ring ---- */ - static void fade(float *p, uint32_t frames, int in) { uint32_t i, ch; @@ -165,8 +139,6 @@ static void fade(float *p, uint32_t frames, int in) { } } -/* The driver's soft-knee limiter: below DA_KNEE the signal is untouched, so ordinary material is - * bit-identical; above it a smooth knee folds the overshoot back under full scale. */ static void limit(float *p, uint32_t samples) { uint32_t i; @@ -193,7 +165,6 @@ static aaudio_data_callback_result_t out_cb(AAudioStream *aq, void *user, void * int fade_in = 0, short_read = 0, starving = 0; uint64_t now; - /* Only the promoted stream reads the ring; an outgoing one during a reopen plays silence. */ if ((live && aq != live) || __atomic_exchange_n(&u->consuming, 1, __ATOMIC_ACQUIRE)) { memset(out, 0, (size_t) frames * DA_RING_CHANNELS * sizeof(float)); return AAUDIO_CALLBACK_RESULT_CONTINUE; @@ -248,8 +219,6 @@ static aaudio_data_callback_result_t out_cb(AAudioStream *aq, void *user, void * __atomic_add_fetch(&u->cb_count, 1, __ATOMIC_SEQ_CST); __atomic_store_n(&r->cb_count, u->cb_count, __ATOMIC_RELAXED); - /* The ring's own adaptive step: a short ring means the IO thread did not get scheduled in - * time - an xrun one stage earlier - so keep one more burst queued. Only grows. */ if (short_read) __atomic_add_fetch(&r->underruns, 1, __ATOMIC_RELAXED); if ((short_read || starving) && u->adaptive) { @@ -262,8 +231,6 @@ static aaudio_data_callback_result_t out_cb(AAudioStream *aq, void *user, void * __atomic_store_n(&r->target_frames, want, __ATOMIC_RELEASE); } - /* The device buffer: grow a burst per xrun; decay a burst after a quiet spell, never below - * the floor a punished decay taught us. */ if (u->adaptive) { int32_t xruns = AAudioStream_getXRunCount(aq); int32_t burst = AAudioStream_getFramesPerBurst(aq); @@ -312,8 +279,6 @@ static void out_error_cb(AAudioStream *aq, void *user, aaudio_result_t error) { if (aq != __atomic_load_n(&u->aq, __ATOMIC_SEQ_CST)) return; if (__atomic_load_n(&u->suspended, __ATOMIC_SEQ_CST)) return; - /* A disconnected stream (headphones plugged or unplugged, a Bluetooth route coming or going) - * can never be restarted: it has to be closed and a new one opened on the current route. */ if (error != AAUDIO_ERROR_DISCONNECTED && error != AAUDIO_ERROR_INVALID_STATE && error != AAUDIO_ERROR_INVALID_HANDLE && @@ -322,8 +287,6 @@ static void out_error_cb(AAudioStream *aq, void *user, aaudio_result_t error) { request_reopen(u, "stream error"); } -/* 48 kHz / float / stereo, buffer from the ms form rounded up to a burst and never below two, - * capacity from the ceiling. The ring target floor is two bursts or ring_ms, whichever is more. */ static aaudio_result_t open_output(struct userdata *u, AAudioStream **out) { AAudioStreamBuilder *builder = NULL; AAudioStream *aq = NULL; @@ -446,7 +409,6 @@ static void request_reopen(struct userdata *u, const char *why) { } } -/* Ticked once a second from the IO thread: a callback silent for a second is a dead stream. */ static void watchdog(struct userdata *u) { uint64_t now = now_ns(), last_cb, prev_tick; unsigned int cb; @@ -484,13 +446,10 @@ static void close_output(struct userdata *u) { AAudioStream_requestStop(aq); AAudioStream_close(aq); } - /* A reopen still in flight sees u->dead and stops; give it a moment to let go of u. */ while (__atomic_load_n(&u->reopen_running, __ATOMIC_SEQ_CST)) usleep(10000); } -/* ---- the PulseAudio side: module-directaudio-sink's producer, on the local ring ---- */ - static pa_usec_t frames_to_usec(const struct userdata *u, int64_t frames) { return (pa_usec_t) (frames * (int64_t) PA_USEC_PER_SEC / (int64_t) u->ss.rate); } @@ -528,7 +487,6 @@ static void produce(struct userdata *u, uint32_t frames) { __atomic_store_n(&r->widx, w + frames, __ATOMIC_RELEASE); } -/* Tops the ring up to the engine's target and narrates what the engine changed. */ static void service_ring(struct userdata *u) { struct da_ring *r = u->ring; uint32_t avail, space; @@ -587,8 +545,6 @@ static int sink_process_msg(pa_msgobject *o, int code, void *data, int64_t offse return pa_sink_process_msg(o, code, data, offset, chunk); } -/* A started AAudio stream keeps audioserver's AudioMix wake lock held, so the device never - * suspends while the session sleeps: stop it, and the IO thread's timer, until the sink reopens. */ static int sink_set_state_in_io_thread_cb(pa_sink *s, pa_sink_state_t new_state, pa_suspend_cause_t new_suspend_cause) { struct userdata *u = s->userdata; aaudio_result_t res = AAUDIO_OK; @@ -629,9 +585,6 @@ static void thread_func(void *userdata) { for (;;) { int ret; - /* Half a burst between visits, never blocking on the stream: clients' audio reaches this - * thread as messages pa_rtpoll_run handles, and a thread stuck in a write or a wait lets - * them run dry (the crackle #338 fixed). */ if (!u->suspended) { service_ring(u); pa_rtpoll_set_timer_relative(u->rtpoll, PA_MAX(frames_to_usec(u, u->burst) / 2, PA_USEC_PER_MSEC)); @@ -719,7 +672,6 @@ int pa__init(pa_module *m) { pthread_mutex_init(&u->lock, NULL); pa_channel_map_init_stereo(&map); - /* The ring: the relay's layout in this process's memory. */ cap_frames = DA_RING_CAP_MS_LOCAL * DA_RING_RATE / 1000; u->ring = pa_xmalloc0(da_ring_bytes(cap_frames)); u->ring->magic = DA_RING_MAGIC; @@ -774,8 +726,6 @@ int pa__init(pa_module *m) { pa_sink_set_asyncmsgq(u->sink, u->thread_mq.inq); pa_sink_set_rtpoll(u->sink, u->rtpoll); pa_sink_set_max_request(u->sink, (size_t) u->ring->target_frames * u->frame_size); - /* What clients should keep queued on their side: the ring's starting target plus the device - * buffer. Grows are reported live through GET_LATENCY. */ pa_sink_set_fixed_latency(u->sink, frames_to_usec(u, (int64_t) u->ring->target_frames + u->base_buf_frames)); if (!(u->thread = pa_thread_new("directaudio-native-sink", thread_func, u))) { diff --git a/tools/dirt-ws2/build.sh b/tools/dirt-ws2/build.sh index 3abe6d16c..67a1b3be0 100755 --- a/tools/dirt-ws2/build.sh +++ b/tools/dirt-ws2/build.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash -# Builds the 32-bit ws2_32.dll stand-in for DiRT 3 and DiRT Showdown into the given folder. set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) mkdir -p "$1" diff --git a/tools/dirt-ws2/gen.py b/tools/dirt-ws2/gen.py index 3d2532b0e..02743506f 100644 --- a/tools/dirt-ws2/gen.py +++ b/tools/dirt-ws2/gen.py @@ -1,8 +1,3 @@ -"""ws2_32 stand-in helpers. - -gen.py generate SRC OUT writes OUT/thunks.c (one jump per export) and OUT/ws2_32.def from SRC/ws2_32-exports.txt. -gen.py verify SRC DLL checks that DLL exports exactly the ordinals and names in SRC/ws2_32-exports.txt. -""" import struct import sys from pathlib import Path diff --git a/tools/dirt-ws2/ws2_32.c b/tools/dirt-ws2/ws2_32.c index de18fe912..fcad74b85 100644 --- a/tools/dirt-ws2/ws2_32.c +++ b/tools/dirt-ws2/ws2_32.c @@ -1,9 +1,4 @@ -/* ws2_32.dll stand-in for DiRT 3 and DiRT Showdown (32-bit). - * - * Both games close their motion telemetry UDP socket at the end of a race (and when leaving Joyride) and keep - * calling sendto() on it; the WSAENOTSOCK (10038) failure stops the game. Every export here jumps to the - * same-ordinal export of Wine's own ws2_32.dll, except that a sendto() failing with 10038 reports 0 bytes sent. - * Same behaviour as DiRTTelemetryErrorFix by RoccoC (MIT), based on the fix by Carsten Gottschling. */ +/* Same behaviour as DiRTTelemetryErrorFix by RoccoC (MIT), based on the fix by Carsten Gottschling. */ typedef unsigned short WCHAR; typedef unsigned long DWORD; @@ -21,7 +16,6 @@ __attribute__((dllimport)) UINT WINAPI GetSystemDirectoryW(WCHAR *buffer, UINT s __attribute__((dllimport)) DWORD WINAPI GetEnvironmentVariableW(const WCHAR *name, WCHAR *buffer, DWORD size); __attribute__((dllimport)) DWORD WINAPI GetFileAttributesW(const WCHAR *name); -/* droiddeck-game-env recognises a swapped-in copy by this text. */ __attribute__((used)) const char dirtfix_marker[] = "droiddeck-dirt-ws2_32"; extern const unsigned short dirtfix_ordinals[]; @@ -59,8 +53,6 @@ static void append_number(WCHAR *dst, UINT n) append(dst, digits + i); } -/* When this file replaces the prefix's syswow64\ws2_32.dll, Wine's own copy is the i386-windows\ws2_32.dll in one - * of the builtin DLL folders Wine lists in WINEDLLDIR0, WINEDLLDIR1, ... as NT paths (\??\Z:\...). */ static HMODULE load_wine_ws2_32(HMODULE self) { WCHAR name[24], path[600]; diff --git a/tools/docker_platform.sh b/tools/docker_platform.sh index e7f34e561..4d4744875 100644 --- a/tools/docker_platform.sh +++ b/tools/docker_platform.sh @@ -1,4 +1,3 @@ -# Sourced by the build scripts: picks the Docker platform and the matching cross-build image. docker_platform=${DROIDDECK_DOCKER_PLATFORM:-} if [[ -z "${docker_platform}" ]]; then case "$(uname -m)" in arm64|aarch64) docker_platform=linux/arm64 ;; *) docker_platform=linux/amd64 ;; esac diff --git a/tools/docker_preflight.py b/tools/docker_preflight.py index eddd6206a..65322fd61 100644 --- a/tools/docker_preflight.py +++ b/tools/docker_preflight.py @@ -1,4 +1,3 @@ -"""Check the engine before modifying build assets; never rebuild for a daemon error.""" import os from pathlib import Path import subprocess @@ -18,7 +17,7 @@ if __name__ == '__main__': sys.exit('Docker is unavailable: ' + engine.stderr.strip()) image = run('image', 'inspect', sys.argv[1]) if image.returncode: - if 'No such image' not in image.stderr and 'image not known' not in image.stderr: # Docker, Podman + if 'No such image' not in image.stderr and 'image not known' not in image.stderr: sys.exit('Cannot inspect the build image: ' + image.stderr.strip()) repo = Path(__file__).resolve().parents[1] subprocess.run(['docker', 'build', '--platform', os.environ.get('DROIDDECK_DOCKER_PLATFORM', 'linux/amd64'), '-t', sys.argv[1], diff --git a/tools/droiddeck-scenario b/tools/droiddeck-scenario index b4590b02f..8b238ef80 100755 --- a/tools/droiddeck-scenario +++ b/tools/droiddeck-scenario @@ -146,7 +146,6 @@ class Runner: log.append(entry) def evidence(self, folder: Path) -> dict: - """What a failed run leaves behind: the screen, gamescope's focus, the session's logs.""" found = {} for name, action in ( ("screenshot", lambda: ctl.screenshot(self.adb, self.serial, str(folder / "failure.png"), None)), diff --git a/tools/droiddeckctl b/tools/droiddeckctl index b3a6c2d5b..95ebc5dbe 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -24,7 +24,6 @@ from adb_device import DeviceResolutionError, resolve_serial from proot_profile import report as proot_profile_report -# tools/release/variants.txt: the same app also ships as other packages (e.g. com.antutu.benchmark.full). PACKAGE = os.environ.get("DROIDDECK_PACKAGE", "com.droiddeck.launcher") AUTHORITY = f"content://{PACKAGE}.agent" PACKAGE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+") @@ -115,7 +114,6 @@ def provider_call(adb: str, serial: str, method: str, arg: str | None = None, re def checked(response: dict, default_code: str) -> dict: - """A provider or guest answer, raised as a ControlError when it carries ok: false.""" if response.get("ok") is False: error = response.get("error", {}) raise ControlError(error.get("message", default_code), error.get("code", default_code)) @@ -166,7 +164,6 @@ def get_state(adb: str, serial: str) -> dict: def wait_until(adb: str, serial: str, describe: str, done, timeout: float, poll: float = 1.0) -> dict: - """Poll state until done(state) is true. A failed session ends the wait at once.""" deadline = time.monotonic() + timeout while True: state = get_state(adb, serial) @@ -203,8 +200,6 @@ def wait_for_phase(adb: str, serial: str, wanted: str, timeout: float) -> dict: def focused(state: dict, app_id: int | None) -> bool: - """The game has focus the way a player sees it: gamescope focuses it and it is in the list the - Steam client orders the base layer from. app_id None means any game (not the client).""" focus = state.get("session", {}).get("focus") or {} current = focus.get("focusedApp") if app_id is None: @@ -232,7 +227,6 @@ def add_serial(response: dict, serial: str) -> dict: def displays(adb: str, serial: str) -> list[dict]: - """Logical displays with the SurfaceFlinger (physical) id screencap and screenrecord want.""" result = run_adb(adb, serial, "shell", "dumpsys", "display", timeout=30) text = result.stdout.decode(errors="replace") devices = dict(re.findall(r'DisplayDeviceInfo\{"([^"]+)": uniqueId="local:(\d+)"', text)) @@ -300,14 +294,6 @@ def record(adb: str, serial: str, path: str, seconds: float, display: int | None def pacing_summary(text: str, package: str = PACKAGE) -> dict: - """What reached the panel, from a `dumpsys SurfaceFlinger --timestats -dump`. - - The game is on the compositor's own layer (droiddeck_wayland_game) when it has one, otherwise on - the session's SurfaceView. SurfaceFlinger keeps one record per layer AND refresh rate, so a panel - that switches between 60 and 120 Hz mid-capture splits the same layer into several records: they - are summed. A game's own fps counter does not show any of this - it counts frames handed over, not - frames shown, and 60 handed over can be 48 on screen with one in four held for two refreshes. - """ records = {"game": [], "surface": []} for part in re.split(r"^displayRefreshRate = ", text, flags=re.M)[1:]: rate = re.match(r"(\d+)", part) @@ -329,7 +315,6 @@ def pacing_summary(text: str, package: str = PACKAGE) -> dict: for ms, count in bins.items(): histogram[ms] = histogram.get(ms, 0) + count if bins: - # SurfaceFlinger bins by whole milliseconds, so one cadence spans two buckets (16.7 ms: 16 and 17). mode = max(bins, key=bins.get) off += sum(count for ms, count in bins.items() if abs(ms - mode) > 1) intervals = sum(histogram.values()) @@ -346,7 +331,6 @@ def pacing_summary(text: str, package: str = PACKAGE) -> dict: def pacing(adb: str, serial: str, seconds: float) -> dict: - """Frames on screen and their intervals over `seconds`, from SurfaceFlinger's timestats.""" seconds = max(1.0, min(600.0, seconds)) timestats = ("shell", "dumpsys", "SurfaceFlinger", "--timestats") try: @@ -487,7 +471,6 @@ def xr_capture(adb: str, serial: str, destination: Path, timeout: float) -> dict except (OSError, EOFError, zlib.error) as error: raise ControlError('Incomplete compressed XR image', 'XR_CAPTURE_FAILED') from error images[eye] = xr_ppm_to_png(pixels, linear=eye == 'source' and metadata.get('sourceColorSpace') == 'linear') - # Detect overlapping captures before publishing images with another request's metadata. check = run_adb(adb, serial, 'shell', 'cat', remote + '.json') try: unchanged = json.loads(check.stdout).get('id') == token @@ -961,7 +944,7 @@ def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) serial = None try: - if not PACKAGE_NAME.fullmatch(PACKAGE): # it reaches the device shell through `am start` and `content call` + if not PACKAGE_NAME.fullmatch(PACKAGE): raise ControlError(f"DROIDDECK_PACKAGE is not an Android package name: {PACKAGE!r}", "INVALID_REQUEST") if args.command == "proot-profile" and args.profile_op == "report": if args.limit < 1: diff --git a/tools/fex/build-in-arch.sh b/tools/fex/build-in-arch.sh index ce5f68ceb..66d2c2a4e 100755 --- a/tools/fex/build-in-arch.sh +++ b/tools/fex/build-in-arch.sh @@ -1,6 +1,4 @@ #!/usr/bin/bash -# Runs inside an Arch Linux ARM container (menci/archlinuxarm:base-devel) on an arm64 runner. Arch Linux ARM ships -# no FEX package and FEX publishes no Linux binaries. set -euxo pipefail VERSION=${FEX_VERSION:-FEX-2610} WORK=/work @@ -10,7 +8,6 @@ grep -q '^DisableSandbox' /etc/pacman.conf || sed -i 's/^\[options\]/[options]\n mv /etc/pacman.d/mirrorlist.new /etc/pacman.d/mirrorlist pacman -Syu --noconfirm --needed git zstd binutils cmake ninja clang lld llvm python squashfs-tools file patchelf xxhash -# The x86 sysroot is Arch x86_64 and multilib i686 packages unpacked by pacman, never run: no scriptlets or signature checks. THUNKS=${FEX_THUNKS:-0} X86ROOT=$WORK/x86root if [ "$THUNKS" = 1 ]; then @@ -40,15 +37,12 @@ fi rm -rf fex-src out && git clone -q --depth 1 --branch "$VERSION" --recurse-submodules --shallow-submodules https://github.com/FEX-Emu/FEX.git fex-src for p in tools/fex/patches/*.patch; do patch -d fex-src -p1 --no-backup-if-mismatch < "$p"; done -# FEX's x86 toolchain files assume an x86 build machine; here the guest thunks need the x86 sysroot too. -# Arch's clang adds stack protection; its canary load in a guest thunk's prologue clobbered r11 (the host function). if [ "$THUNKS" = 1 ]; then for t in fex-src/Data/CMake/toolchain_x86_64.cmake fex-src/Data/CMake/toolchain_x86_32.cmake; do printf '\nif (X86_DEV_ROOTFS AND NOT X86_DEV_ROOTFS STREQUAL "/")\n set(CMAKE_SYSROOT "${X86_DEV_ROOTFS}")\nendif()\n' >> "$t" printf '\nset(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fno-stack-protector")\nset(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-stack-protector")\n' >> "$t" done fi -# TUNE_CPU none: the default (native) would tune for the runner's Neoverse cores, not the device. cmake -S fex-src -B fex-build -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local \ -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DUSE_LINKER=lld \ @@ -60,8 +54,6 @@ cmake -S fex-src -B fex-build -G Ninja \ ninja -C fex-build DESTDIR="$WORK/out" ninja -C fex-build install install -Dm755 /usr/bin/unsquashfs out/usr/local/bin/unsquashfs -# The Linux runtime has no xxhash and no ld.so.cache: the library goes beside libFEXCore and the programs -# that need it find it through RUNPATH. install -Dm644 "$(readlink -f /usr/lib/libxxhash.so.0)" out/usr/local/lib/libxxhash.so.0 bundled=$(cd out/usr/local/lib && ls -- *.so* | sort -u) for f in out/usr/local/bin/*; do @@ -70,7 +62,6 @@ for f in out/usr/local/bin/*; do readelf -d "$f" | awk '/NEEDED/ { gsub(/[\[\]]/, "", $5); print $5 }' | grep -qxF "$bundled" \ && patchelf --set-rpath '$ORIGIN/../lib' "$f" done -# Every NEEDED soname must resolve in a stock guest: bundled here or provided by the runtime (runtime-libs.txt). for f in out/usr/local/bin/* out/usr/local/lib/*.so* out/usr/local/lib/fex-emu/HostThunks*/*.so; do [ -f "$f" ] && file "$f" | grep -q ELF || continue readelf -d "$f" | awk '/NEEDED/ { gsub(/[\[\]]/, "", $5); print $5 }' diff --git a/tools/fex/patches/0001-openat2-enosys-fallback.patch b/tools/fex/patches/0001-openat2-enosys-fallback.patch index d7914133e..af0af4fa1 100644 --- a/tools/fex/patches/0001-openat2-enosys-fallback.patch +++ b/tools/fex/patches/0001-openat2-enosys-fallback.patch @@ -10,7 +10,7 @@ index 7172460..2c80dca 100644 #include #include #include -@@ -632,6 +633,163 @@ static bool ShouldSkipOpenInEmu(int flags) { +@@ -632,6 +633,157 @@ static bool ShouldSkipOpenInEmu(int flags) { return false; } @@ -26,19 +26,15 @@ index 7172460..2c80dca 100644 + return Result; +} + -+// openat2() with RESOLVE_IN_ROOT, walked one component at a time: PRoot has no openat2 and fails it with ENOSYS. -+// DirFD is AT_FDCWD only for thunk overlays, which never resolve in the root. +static int OpenatWithoutOpenat2(int DirFD, const char* Pathname, int Flags, uint32_t Mode) { + if (DirFD == AT_FDCWD) { + return ::syscall(SYSCALL_DEF(openat), DirFD, Pathname, Flags, Mode); + } + -+ // The kernel's MAXSYMLINKS. + constexpr uint32_t MaxSymlinks = 40; + uint32_t Symlinks {}; + std::optional RootDev {}; + -+ // O_NOFOLLOW and O_CREAT | O_EXCL leave a final symlink alone, unless a trailing slash follows it. + const bool NoFollow = (Flags & O_NOFOLLOW) || (Flags & (O_CREAT | O_EXCL)) == (O_CREAT | O_EXCL); + + char Resolved[PATH_MAX]; @@ -116,7 +112,6 @@ index 7172460..2c80dca 100644 + } + + if (Buffer.st_dev != *RootDev && IsOnProcFS(DirFD, Resolved)) { -+ // Only procfs has magic links. RESOLVE_IN_ROOT fails them with EXDEV and the callers then open without it. + return ::syscall(SYSCALL_DEF(openat), DirFD, Pathname, Flags, Mode); + } + @@ -145,7 +140,6 @@ index 7172460..2c80dca 100644 + } + + if (Last) { -+ // openat reports on the final component, which O_CREAT may create. + ResolvedLen = CandidateLen; + if (TrailingSlash) { + Resolved[ResolvedLen++] = '/'; @@ -174,7 +168,7 @@ index 7172460..2c80dca 100644 bool FileManager::ReplaceEmuFd(int fd, int flags, uint32_t mode) { char Tmp[PATH_MAX + 1]; -@@ -678,6 +836,8 @@ uint64_t FileManager::Open(const char* pathname, int flags, uint32_t mode) { +@@ -678,6 +830,8 @@ uint64_t FileManager::Open(const char* pathname, int flags, uint32_t mode) { // This means a magic symlink (/proc/foo) was involved. In this case we // just punt and do the access without RESOLVE_IN_ROOT. fd = ::syscall(SYSCALL_DEF(openat), Path.FD, Path.Path, flags, mode); @@ -183,7 +177,7 @@ index 7172460..2c80dca 100644 } } -@@ -931,6 +1091,8 @@ uint64_t FileManager::Openat([[maybe_unused]] int dirfs, const char* pathname, i +@@ -931,6 +1085,8 @@ uint64_t FileManager::Openat([[maybe_unused]] int dirfs, const char* pathname, i // This means a magic symlink (/proc/foo) was involved. In this case we // just punt and do the access without RESOLVE_IN_ROOT. fd = ::syscall(SYSCALL_DEF(openat), Path.FD, Path.Path, flags, mode); @@ -192,7 +186,7 @@ index 7172460..2c80dca 100644 } } -@@ -967,6 +1129,8 @@ uint64_t FileManager::Openat2(int dirfs, const char* pathname, FEX::HLE::open_ho +@@ -967,6 +1123,8 @@ uint64_t FileManager::Openat2(int dirfs, const char* pathname, FEX::HLE::open_ho // This means a magic symlink (/proc/foo) was involved. In this case we // just punt and do the access without RESOLVE_IN_ROOT. fd = ::syscall(SYSCALL_DEF(openat2), Path.FD, Path.Path, how, usize); diff --git a/tools/fex/patches/0003-allocator32-map32bit-exhaustion.patch b/tools/fex/patches/0003-allocator32-map32bit-exhaustion.patch index 923d68789..2d9f0a172 100644 --- a/tools/fex/patches/0003-allocator32-map32bit-exhaustion.patch +++ b/tools/fex/patches/0003-allocator32-map32bit-exhaustion.patch @@ -26,11 +26,10 @@ index 60b0ef1..4a94009 100644 using FindHandler = uint64_t (MemAllocator32Bit::*)(uint64_t Start, size_t Pages) const; FindHandler FindPageRangePtr {}; }; -@@ -132,6 +135,37 @@ uint64_t MemAllocator32Bit::FindPageRange_TopDown(uint64_t Start, size_t Pages) +@@ -132,6 +135,36 @@ uint64_t MemAllocator32Bit::FindPageRange_TopDown(uint64_t Start, size_t Pages) return 0; } -+// Marks each run of host mappings that overlaps the range as used, in full. Returns false if none overlaps. +bool MemAllocator32Bit::SetHostMappedPages(uint64_t PageAddr, size_t PagesLength) { + const int MapsFD = ::open("/proc/self/maps", O_RDONLY | O_CLOEXEC); + if (MapsFD == -1) { @@ -64,7 +63,7 @@ index 60b0ef1..4a94009 100644 void* MemAllocator32Bit::Mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset) { std::scoped_lock lk {AllocMutex}; size_t PagesLength = FEXCore::AlignUp(length, FEXCore::Utils::FEX_PAGE_SIZE) >> FEXCore::Utils::FEX_PAGE_SHIFT; -@@ -175,11 +209,17 @@ void* MemAllocator32Bit::Mmap(void* addr, size_t length, int prot, int flags, in +@@ -175,11 +208,16 @@ void* MemAllocator32Bit::Mmap(void* addr, size_t length, int prot, int flags, in auto AllocateNoHint = [&]() -> void* { bool Wrapped = false; @@ -74,7 +73,6 @@ index 60b0ef1..4a94009 100644 // Linear range scan uint64_t LowerPage = (this->*FindPageRangePtr)(BottomPage, PagesLength); if (LowerPage == 0) { -+ // A second scan from the start found nothing either: the range is full, as the kernel would say. + if (Restarted) { + return reinterpret_cast(-ENOMEM); + } @@ -82,15 +80,12 @@ index 60b0ef1..4a94009 100644 // Try again but this time from the start BottomPage = Map32Bit ? LastKeyLocation32Bit : LastKeyLocation; LowerPage = (this->*FindPageRangePtr)(BottomPage, PagesLength); -@@ -206,7 +246,17 @@ restart: { +@@ -206,7 +244,14 @@ restart: { return (void*)(uintptr_t)-errno; } else { // Try again - if (SearchDown) { + if (Map32Bit) { -+ // MAP_32BIT from a 64-bit guest, whose other mappings bypass this allocator: once Counter-Strike 2 had reserved the -+ // low 2GB, stepping one page at a time scanned for ever. Record what the host holds there and rescan the range, or -+ // move past it if that mapping is gone. + const bool Marked = SetHostMappedPages(LowerPage, PagesLength); + if (SearchDown) { + BottomPage = Marked ? UpperPage - 1 : LowerPage - 1; diff --git a/tools/gamescope/build-in-arch.sh b/tools/gamescope/build-in-arch.sh index ba3b1605d..dbf323136 100755 --- a/tools/gamescope/build-in-arch.sh +++ b/tools/gamescope/build-in-arch.sh @@ -49,7 +49,6 @@ tar --use-compress-program=unzstd -xf "$PKG" -C out --strip-components=2 usr/bin mv out/gamescope out/usr/local/bin/gamescope chmod 755 out/usr/local/bin/gamescope strip --strip-unneeded out/usr/local/bin/gamescope || true -# The WSI layer carries patches too (0117); the runtime's stock copy is replaced. mkdir -p out/usr/lib tar --use-compress-program=unzstd -xf "$PKG" -C out usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so strip --strip-unneeded out/usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so || true diff --git a/tools/gamescope/patches/0116-release-cursor-shm-buffers.patch b/tools/gamescope/patches/0116-release-cursor-shm-buffers.patch index 03b4d7fd0..a68ac9133 100644 --- a/tools/gamescope/patches/0116-release-cursor-shm-buffers.patch +++ b/tools/gamescope/patches/0116-release-cursor-shm-buffers.patch @@ -1,12 +1,9 @@ --- a/src/wlserver.cpp +++ b/src/wlserver.cpp -@@ -203,6 +203,12 @@ +@@ -203,6 +203,9 @@ } void xwayland_surface_commit(struct wlr_surface *wlr_surface) { -+ // Cursor surfaces never acquire an X11/xdg window association. Keeping their -+ // commits in g_PendingCommits pins every old SHM buffer until the cursor -+ // surface is destroyed. X11 cursor images are handled by MouseCursor. + if ( wlr_surface->role && strcmp( wlr_surface->role->name, "wl_pointer-cursor" ) == 0 ) + return; + diff --git a/tools/gamescope/patches/0117-wsi-cache-present-bypass-check.patch b/tools/gamescope/patches/0117-wsi-cache-present-bypass-check.patch index 7ce0ec48d..9ee156d8a 100644 --- a/tools/gamescope/patches/0117-wsi-cache-present-bypass-check.patch +++ b/tools/gamescope/patches/0117-wsi-cache-present-bypass-check.patch @@ -9,12 +9,10 @@ #include // For limiter file. #include -@@ -476,6 +478,34 @@ +@@ -476,6 +478,32 @@ // Cached for comparison. std::optional cachedWindowRect; -+ // canBypassXWayland costs several X round trips; at present time it is refreshed at most -+ // every DROIDDECK_WSI_BYPASS_CHECK_MS (default 0 = every present; DroidDeck's Performance mode sets 100). + std::optional presentBypass; + bool presentBypassHdr = false; + uint64_t presentBypassNs = 0; @@ -44,7 +42,7 @@ bool isWayland() const { // Is native Wayland? return connection == nullptr; -@@ -1597,7 +1627,7 @@ +@@ -1597,7 +1625,7 @@ continue; } diff --git a/tools/gamescope/patches/0119-wsi-default-images-under-limiter.patch b/tools/gamescope/patches/0119-wsi-default-images-under-limiter.patch index 78f2837ca..c55ae8643 100644 --- a/tools/gamescope/patches/0119-wsi-default-images-under-limiter.patch +++ b/tools/gamescope/patches/0119-wsi-default-images-under-limiter.patch @@ -2,13 +2,10 @@ diff --git a/layer/VkLayer_FROG_gamescope_wsi.cpp b/layer/VkLayer_FROG_gamescope index ea4ba84..46d086c 100644 --- a/layer/VkLayer_FROG_gamescope_wsi.cpp +++ b/layer/VkLayer_FROG_gamescope_wsi.cpp -@@ -201,6 +201,13 @@ namespace GamescopeWSILayer { +@@ -201,6 +201,10 @@ namespace GamescopeWSILayer { return s_minImageCount; } -+ // The raised count keeps an uncapped game drawing while gamescope turns its buffers around. -+ // Under the frame limiter's FIFO the extra images only queue frames ahead of the screen, -+ // so a limited swapchain gets the default count. + static uint32_t getMinImageCount(bool forcingFifo) { + return forcingFifo ? std::min(getMinImageCount(), 3u) : getMinImageCount(); + } @@ -16,7 +13,7 @@ index ea4ba84..46d086c 100644 static bool getEnsureMinImageCount() { static bool s_ensureMinImageCount = []() -> bool { if (auto ensure = parseEnv("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT")) { -@@ -984,7 +991,7 @@ namespace GamescopeWSILayer { +@@ -984,7 +988,7 @@ namespace GamescopeWSILayer { pSurfaceCapabilities->currentExtent = rect->extent; } @@ -25,7 +22,7 @@ index ea4ba84..46d086c 100644 return VK_SUCCESS; } -@@ -1027,7 +1034,7 @@ namespace GamescopeWSILayer { +@@ -1027,7 +1031,7 @@ namespace GamescopeWSILayer { pSurfaceCapabilities->surfaceCapabilities.currentExtent = rect->extent; } @@ -34,7 +31,7 @@ index ea4ba84..46d086c 100644 return VK_SUCCESS; } -@@ -1305,7 +1312,7 @@ namespace GamescopeWSILayer { +@@ -1305,7 +1309,7 @@ namespace GamescopeWSILayer { uint32_t minImageCount = swapchainInfo.minImageCount; if (getEnsureMinImageCount()) @@ -43,12 +40,10 @@ index ea4ba84..46d086c 100644 swapchainInfo.minImageCount = minImageCount; fprintf(stderr, "[Gamescope WSI] Creating swapchain for xid: 0x%0x - oldSwapchain: %p - provided minImageCount: %u - minImageCount: %u - format: %s - colorspace: %s - flip: %s\n", -@@ -1627,6 +1634,13 @@ namespace GamescopeWSILayer { +@@ -1627,6 +1631,11 @@ namespace GamescopeWSILayer { continue; } -+ // Other apps keep their swapchain when the limiter changes; ask them to recreate it so -+ // the image count follows the limiter as well. + if (!frameLimiterAware && gamescopeSwapchain->forceFifo != forceFifo && + getMinImageCount(true) != getMinImageCount(false) && + !(gamescopeSurface->flags & GamescopeLayerClient::Flag::NoSuboptimal)) diff --git a/tools/gamescope/patches/0120-nested-refresh-follows-host.patch b/tools/gamescope/patches/0120-nested-refresh-follows-host.patch index 83ed73b74..a2b846440 100644 --- a/tools/gamescope/patches/0120-nested-refresh-follows-host.patch +++ b/tools/gamescope/patches/0120-nested-refresh-follows-host.patch @@ -2,13 +2,10 @@ diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp index 40164ba..1b85e63 100644 --- a/src/Backends/WaylandBackend.cpp +++ b/src/Backends/WaylandBackend.cpp -@@ -1698,6 +1698,52 @@ namespace gamescope +@@ -1698,6 +1698,46 @@ namespace gamescope } } -+ // -r sets the refresh gamescope starts with. With GAMESCOPE_NESTED_REFRESH_FOLLOWS_HOST=1 a refresh -+ // the host compositor reports later replaces it, so pacing and the frame limiter follow a panel -+ // switched to another mode during the session. + static bool NestedRefreshFollowsHost() + { + static const bool s_bFollow = [] @@ -19,9 +16,6 @@ index 40164ba..1b85e63 100644 + return s_bFollow; + } + -+ // A mode switch on the host can report a passing rate for a few frames (72 Hz between 144 and -+ // 120 on an AYANEO Pocket FIT); a new rate is taken once it has been reported for 300 ms. -+ // Until then gamescope keeps presenting, so a still screen also gets the feedback that takes it. + static bool HostRefreshChanged( int32_t nRefreshmHz, uint64_t ulPresentedNs ) + { + static int32_t s_nPendingmHz = 0; @@ -55,7 +49,7 @@ index 40164ba..1b85e63 100644 void CWaylandPlane::UpdateVRRRefreshRate() { if ( m_pParent ) -@@ -1724,6 +1770,7 @@ namespace gamescope +@@ -1724,6 +1764,7 @@ namespace gamescope // TODO(strategy): We should pick the largest refresh rate. xdg_log.infof( "Changed refresh to: %.3fhz", ConvertmHzToHz( (float) nLargestRefreshRateMhz ) ); g_nOutputRefresh = nLargestRefreshRateMhz; @@ -63,7 +57,7 @@ index 40164ba..1b85e63 100644 } } -@@ -1794,10 +1841,11 @@ namespace gamescope +@@ -1794,10 +1835,11 @@ namespace gamescope if ( uRefreshCycle ) { int32_t nRefresh = RefreshCycleTomHz( uRefreshCycle ); @@ -76,11 +70,10 @@ index 40164ba..1b85e63 100644 } m_pConnector->SetHostCompositorIsCurrentlyVRR( false ); -@@ -2636,6 +2684,9 @@ namespace gamescope +@@ -2636,6 +2678,8 @@ namespace gamescope } void CWaylandBackend::Wayland_Output_Mode( wl_output *pOutput, uint32_t uFlags, int32_t nWidth, int32_t nHeight, int32_t nRefresh ) { -+ // The followed rate comes from presentation feedback, so a still screen presents once for it. + if ( NestedRefreshFollowsHost() && m_pOutputs[ pOutput ].nRefresh != nRefresh ) + force_repaint(); m_pOutputs[ pOutput ].nRefresh = nRefresh; @@ -90,12 +83,11 @@ diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp index 6c9299d..5fb9086 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -9254,6 +9254,15 @@ void update_vrr_atoms(xwayland_ctx_t *root_ctx, bool force, bool* needs_flush = +@@ -9254,6 +9254,14 @@ void update_vrr_atoms(xwayland_ctx_t *root_ctx, bool force, bool* needs_flush = g_nCurrentRefreshRate_CachedValue = g_nOutputRefresh; if (needs_flush) *needs_flush = true; + -+ // Steam reads the display's refresh rates from gamescope_control when it binds; send them again. + if ( !force ) + { + wlserver_lock(); diff --git a/tools/gamescope/patches/0121-override-content-skip-toplevel-walk.patch b/tools/gamescope/patches/0121-override-content-skip-toplevel-walk.patch index 016ae5af3..a7624b402 100644 --- a/tools/gamescope/patches/0121-override-content-skip-toplevel-walk.patch +++ b/tools/gamescope/patches/0121-override-content-skip-toplevel-walk.patch @@ -11,12 +11,10 @@ index 9a027a2..e6d8e31 100644 }; std::mutex g_wlserver_xdg_shell_windows_lock; -@@ -800,6 +802,20 @@ static void gamescope_swapchain_destroy_co( struct wl_resource *resource ); +@@ -800,6 +802,18 @@ static void gamescope_swapchain_destroy_co( struct wl_resource *resource ); void gamescope_xwayland_server_t::handle_override_window_content( struct wl_client *client, struct wl_resource *gamescope_swapchain_resource, struct wlr_surface *surface, uint32_t x11_window ) { -+ // The WSI layer sends this on every present. Finding the top level walks the X tree with a -+ // round trip per level, so a swapchain repeating the same window skips it for 100 ms. + const uint64_t ulNow = get_time_in_nanos(); + if ( gamescope_swapchain_resource ) + { @@ -32,7 +30,7 @@ index 9a027a2..e6d8e31 100644 x11_window = x11_find_toplevel_for_xid( this->ctx->dpy, x11_window ); wlserver_x11_surface_info *x11_surface = lookup_x11_surface_info_from_xid( this, x11_window ); -@@ -813,7 +829,11 @@ void gamescope_xwayland_server_t::handle_override_window_content( struct wl_clie +@@ -813,7 +827,11 @@ void gamescope_xwayland_server_t::handle_override_window_content( struct wl_clie if ( content_overrides.count( x11_window ) ) { if ( content_overrides[x11_window]->gamescope_swapchain == gamescope_swapchain_resource ) @@ -44,7 +42,7 @@ index 9a027a2..e6d8e31 100644 #ifdef GAMESCOPE_SWAPCHAIN_DEBUG wl_log.infof( "handle_override_window_content: (2) DESTROYING x11_window: 0x%x old_swapchain: %p new_swapchain: %p", x11_window, content_overrides[x11_window]->gamescope_swapchain, gamescope_swapchain_resource ); #endif -@@ -830,6 +850,8 @@ void gamescope_xwayland_server_t::handle_override_window_content( struct wl_clie +@@ -830,6 +848,8 @@ void gamescope_xwayland_server_t::handle_override_window_content( struct wl_clie co->surface = surface; co->x11_window = x11_window; co->gamescope_swapchain = gamescope_swapchain_resource; diff --git a/tools/gamescope/patches/0122-wayland-wake-after-host-events.patch b/tools/gamescope/patches/0122-wayland-wake-after-host-events.patch index 0be39bf16..b33cef1b6 100644 --- a/tools/gamescope/patches/0122-wayland-wake-after-host-events.patch +++ b/tools/gamescope/patches/0122-wayland-wake-after-host-events.patch @@ -2,12 +2,11 @@ diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp index 1b85e63..370661d 100644 --- a/src/Backends/WaylandBackend.cpp +++ b/src/Backends/WaylandBackend.cpp -@@ -2977,6 +2977,9 @@ namespace gamescope +@@ -2977,6 +2977,8 @@ namespace gamescope LogDisplayError( "Failed to read events on input thread", m_pBackend->GetDisplay() ); abort(); } + -+ // Reading also queues releases and presentation feedback for steamcompmgr. + nudge_steamcompmgr(); } } diff --git a/tools/gamescope/patches/0124-commit-done-at-import.patch b/tools/gamescope/patches/0124-commit-done-at-import.patch index 9ac818e4e..9adfdf911 100644 --- a/tools/gamescope/patches/0124-commit-done-at-import.patch +++ b/tools/gamescope/patches/0124-commit-done-at-import.patch @@ -36,12 +36,11 @@ index 22aa907..0aef598 100644 void update_wayland_res(const std::shared_ptr &doneCommits, steamcompmgr_win_t *w, ResListEntry_t& reslistentry) { struct wlr_buffer *buf = reslistentry.buf; -@@ -8602,7 +8629,11 @@ void update_wayland_res(const std::shared_ptr &doneCommits, st +@@ -8602,7 +8629,10 @@ void update_wayland_res(const std::shared_ptr &doneCommits, st struct wlr_dmabuf_attributes dmabuf = {0}; if ( wlr_buffer_get_dmabuf( buf, &dmabuf ) ) { - fence = dup( dmabuf.fd[0] ); -+ // A finished buffer needs no fence of its own. + if ( cv_commit_ready_check && commit_fence_ready_now( dmabuf.fd[0] ) ) + bKnownReady = true; + else @@ -49,7 +48,7 @@ index 22aa907..0aef598 100644 } else { -@@ -8610,11 +8641,22 @@ void update_wayland_res(const std::shared_ptr &doneCommits, st +@@ -8610,11 +8640,22 @@ void update_wayland_res(const std::shared_ptr &doneCommits, st } } @@ -72,12 +71,11 @@ index 22aa907..0aef598 100644 else g_ImageWaiter.AddWaitable( newCommit.get() ); } -@@ -9400,6 +9442,13 @@ void steamcompmgr_check_xdg(bool vblank, uint64_t vblank_idx) +@@ -9400,6 +9441,12 @@ void steamcompmgr_check_xdg(bool vblank, uint64_t vblank_idx) } check_new_xdg_res(); + -+ // Commits marked done at import: handle them now rather than on the next wake. FIFO commits still wait for a vblank. + if ( s_bXdgCommitsDoneAtImport ) + { + s_bXdgCommitsDoneAtImport = false; @@ -86,12 +84,11 @@ index 22aa907..0aef598 100644 } void update_edid_prop() -@@ -10512,6 +10561,14 @@ steamcompmgr_main(int argc, char **argv) +@@ -10512,6 +10559,13 @@ steamcompmgr_main(int argc, char **argv) gamescope_xwayland_server_t *server = NULL; for (size_t i = 0; (server = wlserver_get_xwayland_server(i)); i++) check_new_xwayland_res(server->ctx.get()); + -+ // Commits marked done at import: handle them now rather than on the next wake. FIFO commits still wait for a vblank. + if ( s_bXwaylandCommitsDoneAtImport ) + { + s_bXwaylandCommitsDoneAtImport = false; diff --git a/tools/gamescope/patches/0125-droiddeck-hud-frame-times.patch b/tools/gamescope/patches/0125-droiddeck-hud-frame-times.patch index 8fdbbf824..2fec1e7d2 100644 --- a/tools/gamescope/patches/0125-droiddeck-hud-frame-times.patch +++ b/tools/gamescope/patches/0125-droiddeck-hud-frame-times.patch @@ -69,11 +69,10 @@ index 0aef598..1473148 100644 gamescope::ConVar cv_commit_ready_check( "commit_ready_check", true, "Check a new commit's fence at import; a finished frame is marked done in the same pass instead of through the image waiter." ); // Set when update_wayland_res marks a commit done itself, so the main loop handles it in the same pass. -@@ -8519,6 +8522,16 @@ void update_wayland_res(const std::shared_ptr &doneCommits, st +@@ -8519,6 +8522,15 @@ void update_wayland_res(const std::shared_ptr &doneCommits, st uMangoMsgType = pMangoFocus->externalOverlayWindow->uMangoappMsgType; } -+ // The commits the typed stream of connector 0 counts, whether or not a mangoapp runs. + bool bDroidDeckHudFrame = false; + if ( pUpscaleFocus && !w->isSteamStreamingClient ) + bDroidDeckHudFrame = pUpscaleFocus->ulVirtualFocusKey == 0; @@ -86,7 +85,7 @@ index 0aef598..1473148 100644 bool bValidPreemptiveScale = reslistentry.pAcquirePoint && pUpscaleFocus && cv_upscale_preemptive; bool bPreemptiveUpscale = bValidPreemptiveScale && newCommit->ShouldPreemptivelyUpscale( pUpscaleFocus->eUpscaleFilter, pUpscaleFocus->eUpscaleScaler ); -@@ -8650,7 +8663,7 @@ void update_wayland_res(const std::shared_ptr &doneCommits, st +@@ -8650,7 +8662,7 @@ void update_wayland_res(const std::shared_ptr &doneCommits, st gpuvis_trace_printf( "pushing wait for commit %lu win %lx", newCommit->commitID, w->type == steamcompmgr_win_type_t::XWAYLAND ? w->xwayland().id : 0 ); { @@ -95,18 +94,10 @@ index 0aef598..1473148 100644 if ( bKnownReady ) { newCommit->Signal(); -@@ -9865,6 +9878,109 @@ static void publish_mangoapp_connector_snapshots() +@@ -9865,6 +9877,100 @@ static void publish_mangoapp_connector_snapshots() } } -+// DroidDeck's own HUD reads the frame times DroidDeck's mangoapp shows from a shared file named by -+// DROIDDECK_HUD_FRAMES, also while no mangoapp runs. That mangoapp (Valve's 20260902.1 build) reads the -+// field at byte 16 of each message as its frame time, which is app_frametime_ns here: one time per -+// signalled commit of the connector's windows (mangoapp_nudge_app_frame). The file gets the same commits -+// of connector 0. Little-endian: 0 magic, 4 version, 8 capacity, 12 pid, 16 frames written (u64), -+// 24 info sequence (odd while the info is written), 28 output width, 32 height, 36 refresh mHz, 40 Steam -+// focused, 44 engine (40 bytes), then from 128 one { frametime ns, monotonic time ns } pair per frame, -+// a ring of capacity. +static constexpr uint32_t k_uDroidDeckHudFramesMagic = 0x46484444; +static constexpr uint32_t k_uDroidDeckHudFramesVersion = 1; +static constexpr uint32_t k_uDroidDeckHudFramesCapacity = 1024; @@ -157,7 +148,6 @@ index 0aef598..1473148 100644 + return droiddeck_hud_frames_map() != nullptr; +} + -+// Signal() runs on the image waiter thread or, for a frame finished at import, on steamcompmgr's. +void droiddeck_hud_app_frame( uint64_t ulNow ) +{ + std::unique_lock lock( s_DroidDeckHudFramesMutex ); @@ -205,7 +195,7 @@ index 0aef598..1473148 100644 // mangohudctl posts one control message for every instance to act on. static void relay_mangoapp_control() { -@@ -10843,6 +10959,7 @@ steamcompmgr_main(int argc, char **argv) +@@ -10843,6 +10949,7 @@ steamcompmgr_main(int argc, char **argv) publish_mangoapp_snapshot(); publish_mangoapp_connector_snapshots(); diff --git a/tools/gpuload/build.sh b/tools/gpuload/build.sh new file mode 100755 index 000000000..b2729de7f --- /dev/null +++ b/tools/gpuload/build.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Builds VK_LAYER_DROIDDECK_gpu_load for the arm64 guest: tools/gpuload/build.sh [--native] OUT +# OUT gets libVkLayer_droiddeck_gpuload.so and its implicit-layer manifest. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/../.." && pwd) +. "$repo/tools/docker_platform.sh" + +native=0 +if [ "${1:-}" = --native ]; then native=1; shift; fi +out=${1:?usage: tools/gpuload/build.sh [--native] OUT} +case "$out" in /*) ;; *) out="$PWD/$out" ;; esac + +vkinc=${VK_INCLUDE:-} +if [ -z "$vkinc" ]; then + ndks=("${NDK:-}" "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_LATEST_HOME:-}") + sdk_ndks=("${ANDROID_HOME:-$HOME/Library/Android/sdk}"/ndk/*) + for ((i = ${#sdk_ndks[@]} - 1; i >= 0; i--)); do ndks+=("${sdk_ndks[i]}"); done + for ndk in "${ndks[@]}"; do + [ -n "$ndk" ] && [ -d "$ndk" ] || continue + for cand in "$ndk"/toolchains/llvm/prebuilt/*/sysroot/usr/include; do + if [ -f "$cand/vulkan/vk_layer.h" ]; then vkinc=$cand; break 2; fi + done + done +fi +[ -n "$vkinc" ] && [ -f "$vkinc/vulkan/vulkan_core.h" ] || { echo "tools/gpuload/build.sh: no Vulkan headers (set VK_INCLUDE)" >&2; exit 1; } + +work="$repo/build/gpuload" +rm -rf "$work" && mkdir -p "$work/include" "$work/out" +cp -R "$vkinc/vulkan" "$work/include/" +if [ -d "$vkinc/vk_video" ]; then cp -R "$vkinc/vk_video" "$work/include/"; fi + +cmd=(sh -c ' + set -e + cc -std=c11 -O2 -Wall -Wextra -Werror -Itools/gpuload/src -o build/gpuload/test_gpuload_busy tools/gpuload/tests/test_gpuload_busy.c + build/gpuload/test_gpuload_busy + cc -std=c11 -O2 -Wall -Wextra -Werror -Ibuild/gpuload/include -Itools/gpuload/src -o build/gpuload/test_gpuload_ring tools/gpuload/tests/test_gpuload_ring.c + build/gpuload/test_gpuload_ring + aarch64-linux-gnu-gcc -std=c11 -shared -fPIC -O2 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers \ + -fvisibility=hidden -pthread -Wl,-z,defs -Ibuild/gpuload/include -Itools/gpuload/src \ + -o build/gpuload/out/libVkLayer_droiddeck_gpuload.so tools/gpuload/src/vk_layer_droiddeck_gpuload.c + aarch64-linux-gnu-strip --strip-unneeded build/gpuload/out/libVkLayer_droiddeck_gpuload.so') +if [ "$native" = 1 ]; then + (cd "$repo" && "${cmd[@]}") +else + docker run --rm --platform "${DROIDDECK_DOCKER_PLATFORM:-linux/amd64}" --user "$(id -u):$(id -g)" -v "$repo:/src" -w /src \ + "${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v3}" "${cmd[@]}" +fi + +cp "$here/layer/VK_LAYER_DROIDDECK_gpu_load.json" "$work/out/" +mkdir -p "$out" +cp "$work/out/libVkLayer_droiddeck_gpuload.so" "$work/out/VK_LAYER_DROIDDECK_gpu_load.json" "$out/" +echo "built $out" diff --git a/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json b/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json new file mode 100644 index 000000000..9fa67bc63 --- /dev/null +++ b/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json @@ -0,0 +1,20 @@ +{ + "file_format_version": "1.2.0", + "layer": { + "name": "VK_LAYER_DROIDDECK_gpu_load", + "type": "GLOBAL", + "library_path": "/usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so", + "api_version": "1.3.0", + "implementation_version": "1", + "description": "Times this process's GPU work for DroidDeck's HUD where Android hides the GPU load; idle unless a HUD is shown", + "functions": { + "vkNegotiateLoaderLayerInterfaceVersion": "DroidDeckGpuLoad_NegotiateLoaderLayerInterfaceVersion" + }, + "enable_environment": { + "DROIDDECK_GPU_LOAD": "1" + }, + "disable_environment": { + "DROIDDECK_GPU_LOAD_DISABLE": "1" + } + } +} diff --git a/tools/gpuload/src/gpuload_busy.h b/tools/gpuload/src/gpuload_busy.h new file mode 100644 index 000000000..e5a47fb13 --- /dev/null +++ b/tools/gpuload/src/gpuload_busy.h @@ -0,0 +1,22 @@ +// Busy-time bookkeeping shared by VK_LAYER_DROIDDECK_gpu_load and DroidDeck's compositor, kept free of Vulkan so it +// can be tested on its own. +#ifndef DROIDDECK_GPULOAD_BUSY_H +#define DROIDDECK_GPULOAD_BUSY_H + +#include + +// GPU ticks between the timestamp written before a batch and the one written after it. Batches on one queue run one +// after the other, so these add up without overlap. Ticks are never compared across batches: on Adreno the always-on +// counter behind the timestamps restarts when the GPU powers down between batches (seen on the Retroid Pocket Duo). +// Anything backwards or longer than max_ticks is a bogus pair and counts as nothing. +static inline uint64_t gpuload_interval(uint64_t start, uint64_t end, uint64_t max_ticks) { + return end > start && end - start <= max_ticks ? end - start : 0; +} + +// A timestamp query slot is reused once its end is available; until the GPU runs the slot's reset again the +// query still shows the previous end, so a result equal to the last one harvested is the old one. +static inline int gpuload_result_is_new(uint64_t start_avail, uint64_t end_avail, uint64_t end, uint64_t last_end) { + return start_avail && end_avail && end != last_end; +} + +#endif diff --git a/tools/gpuload/src/gpuload_ring.h b/tools/gpuload/src/gpuload_ring.h new file mode 100644 index 000000000..58f6abcb7 --- /dev/null +++ b/tools/gpuload/src/gpuload_ring.h @@ -0,0 +1,215 @@ +// Timestamp ring shared by VK_LAYER_DROIDDECK_gpu_load (guest) and DroidDeck's compositor (app): each submit batch +// that has command buffers gets one of the ring's command buffers in front (resets a query pair, writes a TOP_OF_PIPE +// timestamp) and one behind (BOTTOM_OF_PIPE timestamp). Finished pairs are read back without waiting. +// The start timestamp sits after the batch's semaphore waits only because Turnip and Qualcomm's driver on kgsl hold a +// whole batch until its waits signal; a driver that starts TOP_OF_PIPE work earlier would count the wait as busy. +// Callers serialize access to one ring (a queue's submits are externally synchronized anyway). +#ifndef DROIDDECK_GPULOAD_RING_H +#define DROIDDECK_GPULOAD_RING_H + +#include +#include +#include +#include + +#include "gpuload_busy.h" + +#define GPULOAD_RING 64 + +typedef VkResult(VKAPI_PTR *gpuload_set_data_fn)(VkDevice device, void *object); + +struct gpuload_vk { + VkDevice device; + PFN_vkCreateCommandPool create_pool; + PFN_vkDestroyCommandPool destroy_pool; + PFN_vkAllocateCommandBuffers alloc_cmds; + PFN_vkBeginCommandBuffer begin; + PFN_vkEndCommandBuffer end; + PFN_vkCmdResetQueryPool cmd_reset; + PFN_vkCmdWriteTimestamp cmd_timestamp; + PFN_vkCreateQueryPool create_queries; + PFN_vkDestroyQueryPool destroy_queries; + PFN_vkGetQueryPoolResults results; + gpuload_set_data_fn set_data; // a layer must give the loader every command buffer it allocates; NULL elsewhere + double period_ns; + uint64_t max_ticks; +}; + +struct gpuload_slot { + VkCommandBuffer start, end; + uint64_t last_end; +}; + +struct gpuload_ring { + int state; // 0 not set up, 1 timing, -1 cannot time + VkCommandPool pool; + VkQueryPool queries; + uint64_t mask; + uint32_t next, oldest, inflight; + struct gpuload_slot slot[GPULOAD_RING]; +}; + +#define GPULOAD_LONGEST_BATCH_NS 2000000000.0 + +static inline void gpuload_vk_period(struct gpuload_vk *v, float timestamp_period) { + v->period_ns = timestamp_period; + v->max_ticks = timestamp_period > 0 ? (uint64_t)(GPULOAD_LONGEST_BATCH_NS / timestamp_period) : 0; +} + +static inline int gpuload_vk_complete(const struct gpuload_vk *v) { + return v->device && v->create_pool && v->destroy_pool && v->alloc_cmds && v->begin && v->end && v->cmd_reset && + v->cmd_timestamp && v->create_queries && v->destroy_queries && v->results && v->period_ns > 0; +} + +static inline void gpuload_ring_release(struct gpuload_ring *r, const struct gpuload_vk *v) { + if (r->queries) v->destroy_queries(v->device, r->queries, NULL); + if (r->pool) v->destroy_pool(v->device, r->pool, NULL); + r->queries = VK_NULL_HANDLE; + r->pool = VK_NULL_HANDLE; + r->next = r->oldest = r->inflight = 0; +} + +static inline int gpuload_ring_record(struct gpuload_ring *r, const struct gpuload_vk *v, uint32_t i) { + const VkCommandBufferBeginInfo begin = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + struct gpuload_slot *s = &r->slot[i]; + if (v->begin(s->start, &begin) != VK_SUCCESS) return 0; + v->cmd_reset(s->start, r->queries, 2 * i, 2); + v->cmd_timestamp(s->start, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, r->queries, 2 * i); + if (v->end(s->start) != VK_SUCCESS) return 0; + if (v->begin(s->end, &begin) != VK_SUCCESS) return 0; + v->cmd_timestamp(s->end, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, r->queries, 2 * i + 1); + return v->end(s->end) == VK_SUCCESS; +} + +// Sets the ring up for a queue of this family; returns 1 when it can time it (r->state says the same afterwards). +static inline int gpuload_ring_setup(struct gpuload_ring *r, const struct gpuload_vk *v, uint32_t family, + uint32_t timestamp_bits) { + r->state = -1; + if (!timestamp_bits || !gpuload_vk_complete(v)) return 0; + r->mask = timestamp_bits >= 64 ? ~0ull : (1ull << timestamp_bits) - 1; + const VkCommandPoolCreateInfo pool = {.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, .queueFamilyIndex = family}; + const VkQueryPoolCreateInfo queries = {.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO, + .queryType = VK_QUERY_TYPE_TIMESTAMP, .queryCount = 2 * GPULOAD_RING}; + VkCommandBuffer cmds[2 * GPULOAD_RING]; + if (v->create_pool(v->device, &pool, NULL, &r->pool) != VK_SUCCESS) { r->pool = VK_NULL_HANDLE; goto fail; } + if (v->create_queries(v->device, &queries, NULL, &r->queries) != VK_SUCCESS) { r->queries = VK_NULL_HANDLE; goto fail; } + const VkCommandBufferAllocateInfo alloc = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = r->pool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 2 * GPULOAD_RING}; + if (v->alloc_cmds(v->device, &alloc, cmds) != VK_SUCCESS) goto fail; + for (uint32_t i = 0; i < 2 * GPULOAD_RING; i++) + if (v->set_data && v->set_data(v->device, cmds[i]) != VK_SUCCESS) goto fail; + for (uint32_t i = 0; i < GPULOAD_RING; i++) { + r->slot[i].start = cmds[2 * i]; + r->slot[i].end = cmds[2 * i + 1]; + r->slot[i].last_end = 0; + if (!gpuload_ring_record(r, v, i)) goto fail; + } + r->next = r->oldest = r->inflight = 0; + r->state = 1; + return 1; +fail: + gpuload_ring_release(r, v); + r->state = -1; + return 0; +} + +// Nanoseconds of GPU time in the batches that finished since the last call. Slots are taken in ring order and a +// queue runs its batches in order, so the oldest slot finishes first. +static inline uint64_t gpuload_ring_harvest(struct gpuload_ring *r, const struct gpuload_vk *v) { + uint64_t ticks = 0; + while (r->inflight) { + struct gpuload_slot *s = &r->slot[r->oldest]; + uint64_t q[4] = {0, 0, 0, 0}; + VkResult res = v->results(v->device, r->queries, 2 * r->oldest, 2, sizeof(q), q, 2 * sizeof(uint64_t), + VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WITH_AVAILABILITY_BIT); + if (res != VK_SUCCESS && res != VK_NOT_READY) break; + uint64_t start = q[0] & r->mask, end = q[2] & r->mask; + if (!gpuload_result_is_new(q[1], q[3], end, s->last_end)) break; + s->last_end = end; + r->oldest = (r->oldest + 1) % GPULOAD_RING; + r->inflight--; + ticks += gpuload_interval(start, end, v->max_ticks); + } + return (uint64_t)((double)ticks * v->period_ns); +} + +static inline int gpuload_plain_batch(const void *next) { + for (const VkBaseInStructure *s = (const VkBaseInStructure *)next; s; s = s->pNext) { + if (s->sType == VK_STRUCTURE_TYPE_DEVICE_GROUP_SUBMIT_INFO) return 0; + if (s->sType == VK_STRUCTURE_TYPE_PROTECTED_SUBMIT_INFO && ((const VkProtectedSubmitInfo *)s)->protectedSubmit) + return 0; + } + return 1; +} + +static inline struct gpuload_slot *gpuload_ring_take(struct gpuload_ring *r) { + if (r->inflight >= GPULOAD_RING) return NULL; + struct gpuload_slot *s = &r->slot[r->next]; + r->next = (r->next + 1) % GPULOAD_RING; + r->inflight++; + return s; +} + +// Gives back the slots of a submit that failed. +static inline void gpuload_ring_unwind(struct gpuload_ring *r, uint32_t used) { + r->next = (r->next + GPULOAD_RING - used) % GPULOAD_RING; + r->inflight -= used; +} + +// A copy of the batches with the ring's command buffers around each one that has command buffers (free() it after +// the submit), or NULL to submit the originals. *used counts the slots taken, for gpuload_ring_unwind. +static inline VkSubmitInfo *gpuload_ring_wrap(struct gpuload_ring *r, uint32_t count, const VkSubmitInfo *infos, + uint32_t *used) { + *used = 0; + size_t cmds = 0; + for (uint32_t i = 0; i < count; i++) cmds += infos[i].commandBufferCount + 2; + VkSubmitInfo *mine = (VkSubmitInfo *)malloc(count * sizeof(*mine) + cmds * sizeof(VkCommandBuffer)); + if (!mine) return NULL; + VkCommandBuffer *list = (VkCommandBuffer *)(mine + count); + for (uint32_t i = 0; i < count; i++) { + uint32_t n = infos[i].commandBufferCount; + mine[i] = infos[i]; + if (!n || !gpuload_plain_batch(infos[i].pNext)) continue; + struct gpuload_slot *s = gpuload_ring_take(r); + if (!s) continue; + list[0] = s->start; + memcpy(list + 1, infos[i].pCommandBuffers, n * sizeof(VkCommandBuffer)); + list[n + 1] = s->end; + mine[i].pCommandBuffers = list; + mine[i].commandBufferCount = n + 2; + list += n + 2; + (*used)++; + } + return mine; +} + +static inline VkSubmitInfo2 *gpuload_ring_wrap2(struct gpuload_ring *r, uint32_t count, const VkSubmitInfo2 *infos, + uint32_t *used) { + *used = 0; + size_t cmds = 0; + for (uint32_t i = 0; i < count; i++) cmds += infos[i].commandBufferInfoCount + 2; + VkSubmitInfo2 *mine = (VkSubmitInfo2 *)malloc(count * sizeof(*mine) + cmds * sizeof(VkCommandBufferSubmitInfo)); + if (!mine) return NULL; + VkCommandBufferSubmitInfo *list = (VkCommandBufferSubmitInfo *)(mine + count); + for (uint32_t i = 0; i < count; i++) { + uint32_t n = infos[i].commandBufferInfoCount; + mine[i] = infos[i]; + if (!n || (infos[i].flags & VK_SUBMIT_PROTECTED_BIT) || !gpuload_plain_batch(infos[i].pNext)) continue; + struct gpuload_slot *s = gpuload_ring_take(r); + if (!s) continue; + memset(&list[0], 0, sizeof(list[0])); + list[0].sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO; + list[0].commandBuffer = s->start; + memcpy(list + 1, infos[i].pCommandBufferInfos, n * sizeof(VkCommandBufferSubmitInfo)); + memset(&list[n + 1], 0, sizeof(list[n + 1])); + list[n + 1].sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO; + list[n + 1].commandBuffer = s->end; + mine[i].pCommandBufferInfos = list; + mine[i].commandBufferInfoCount = n + 2; + list += n + 2; + (*used)++; + } + return mine; +} + +#endif diff --git a/tools/gpuload/src/vk_layer_droiddeck_gpuload.c b/tools/gpuload/src/vk_layer_droiddeck_gpuload.c new file mode 100644 index 000000000..ef58bef6d --- /dev/null +++ b/tools/gpuload/src/vk_layer_droiddeck_gpuload.c @@ -0,0 +1,410 @@ +// VK_LAYER_DROIDDECK_gpu_load: this process's GPU busy time for the HUD where Android keeps the GPU load from apps +// (kgsl sysfs SELinux-blocked, perfcounter reads refused while /sys/class/kgsl/kgsl-3d0/perfcounter is 0). +// The process total in nanoseconds goes to $DROIDDECK_GPU_LOAD_DIR/; nothing is timed while +// $DROIDDECK_GPU_LOAD_DIR/on is missing (the app removes it while no HUD is shown). +#define _GNU_SOURCE +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gpuload_ring.h" + +#define EXPORT __attribute__((visibility("default"))) +#define MAX_FAMILIES 32 +#define WANTED_CHECK_NS 500000000ull +#define WRITE_EVERY_NS 100000000ull + +static const char *g_dir; + +static void say(const char *what) { + // FEX leaves program_invocation_short_name empty for x86 programs. + fprintf(stderr, "[droiddeck-gpu-load] %s (pid %d): %s\n", program_invocation_short_name, (int)getpid(), what); +} + +__attribute__((constructor)) static void init(void) { + const char *dir = getenv("DROIDDECK_GPU_LOAD_DIR"); + g_dir = dir && *dir ? dir : NULL; +} + +static uint64_t now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} + +struct inst { + struct inst *next_in_list; + VkInstance handle; + PFN_vkGetInstanceProcAddr gipa; + PFN_vkDestroyInstance destroy; + PFN_vkGetPhysicalDeviceProperties props; + PFN_vkGetPhysicalDeviceQueueFamilyProperties families; +}; + +struct dev { + struct dev *next_in_list; + VkDevice handle; + PFN_vkGetDeviceProcAddr gdpa; + PFN_vkDestroyDevice destroy; + PFN_vkGetDeviceQueue get_queue; + PFN_vkGetDeviceQueue2 get_queue2; + PFN_vkQueueSubmit submit; + PFN_vkQueueSubmit2 submit2; + PFN_vkQueueSubmit2 submit2_khr; + struct gpuload_vk vk; + uint32_t family_count; + uint32_t timestamp_bits[MAX_FAMILIES]; +}; + +struct queue { + struct queue *next_in_list; + VkQueue handle; + struct dev *dev; + uint32_t family; + uint32_t epoch; + struct gpuload_ring ring; +}; + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static struct inst *g_insts; +static struct dev *g_devs; +static struct queue *g_queues; + +static void *key(const void *obj) { return *(void *const *)obj; } + +static struct inst *inst_for(const void *obj) { + struct inst *r; + pthread_mutex_lock(&g_lock); + for (r = g_insts; r && key(r->handle) != key(obj); r = r->next_in_list) {} + pthread_mutex_unlock(&g_lock); + return r; +} + +static struct dev *dev_for(const void *obj) { + struct dev *r; + pthread_mutex_lock(&g_lock); + for (r = g_devs; r && key(r->handle) != key(obj); r = r->next_in_list) {} + pthread_mutex_unlock(&g_lock); + return r; +} + +static struct queue *queue_for(VkQueue handle) { + struct queue *r; + pthread_mutex_lock(&g_lock); + for (r = g_queues; r && r->handle != handle; r = r->next_in_list) {} + pthread_mutex_unlock(&g_lock); + return r; +} + +static void note_queue(struct dev *d, VkQueue handle, uint32_t family) { + if (!handle || queue_for(handle)) return; + struct queue *q = calloc(1, sizeof(*q)); + if (!q) return; + q->handle = handle; + q->dev = d; + q->family = family; + pthread_mutex_lock(&g_lock); + q->next_in_list = g_queues; + g_queues = q; + pthread_mutex_unlock(&g_lock); +} + +// ---- process total --------------------------------------------------------------------------------------------- + +static _Atomic uint64_t g_busy_ns; +static _Atomic uint64_t g_wanted_at; +static _Atomic int g_wanted; +static _Atomic uint32_t g_epoch = 1; // bumped each time the HUD comes back +static pthread_mutex_t g_write_lock = PTHREAD_MUTEX_INITIALIZER; +static uint64_t g_written_at; +static int g_fd = -1; +static pid_t g_fd_pid; +static int g_announced; + +static int wanted(uint64_t now) { + uint64_t at = atomic_load(&g_wanted_at); + if (at && now - at < WANTED_CHECK_NS) return atomic_load(&g_wanted); + atomic_store(&g_wanted_at, now); + char path[512]; + snprintf(path, sizeof(path), "%s/on", g_dir); + int on = access(path, F_OK) == 0; + if (on && !atomic_exchange(&g_wanted, on)) atomic_fetch_add(&g_epoch, 1); + if (!on) atomic_store(&g_wanted, 0); + return on; +} + +static void publish(uint64_t now) { + if (pthread_mutex_trylock(&g_write_lock)) return; + if (now - g_written_at >= WRITE_EVERY_NS) { + g_written_at = now; + pid_t pid = getpid(); + struct stat st; + // The app deletes files that stopped changing; a forked child needs its own. + if (g_fd >= 0 && (g_fd_pid != pid || fstat(g_fd, &st) || st.st_nlink == 0)) { close(g_fd); g_fd = -1; } + if (g_fd < 0) { + char path[512]; + snprintf(path, sizeof(path), "%s/%d", g_dir, (int)pid); + g_fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + g_fd_pid = pid; + } + if (g_fd >= 0) { + char line[80]; + int n = snprintf(line, sizeof(line), "%20llu %-32.32s\n", (unsigned long long)atomic_load(&g_busy_ns), + program_invocation_short_name); + if (pwrite(g_fd, line, (size_t)n, 0) != n) { close(g_fd); g_fd = -1; } + } + } + pthread_mutex_unlock(&g_write_lock); +} + +// The queue to time this submit on, or NULL to pass it through. +static struct queue *measuring(VkQueue handle) { + if (!g_dir) return NULL; + struct queue *q = queue_for(handle); + if (!q) return NULL; + uint64_t now = now_ns(); + if (!wanted(now)) return NULL; + struct dev *d = q->dev; + if (q->ring.state == 0) { + uint32_t bits = q->family < d->family_count ? d->timestamp_bits[q->family] : 0; + if (gpuload_ring_setup(&q->ring, &d->vk, q->family, bits)) { + if (!__atomic_exchange_n(&g_announced, 1, __ATOMIC_RELAXED)) say("timing this process's GPU work for the HUD"); + } else { + say(bits ? "cannot set up GPU timing on this queue" : "this queue family has no timestamps"); + } + q->epoch = atomic_load(&g_epoch); + } + if (q->ring.state != 1) return NULL; + uint64_t ns = gpuload_ring_harvest(&q->ring, &d->vk); + uint32_t epoch = atomic_load(&g_epoch); + if (q->epoch == epoch) atomic_fetch_add(&g_busy_ns, ns); + else q->epoch = epoch; // timed before the HUD went away + publish(now); + return q; +} + +static VkResult VKAPI_CALL QueueSubmit(VkQueue queue, uint32_t count, const VkSubmitInfo *infos, VkFence fence) { + struct dev *d = dev_for(queue); + if (!d) return VK_ERROR_DEVICE_LOST; + struct queue *q = count ? measuring(queue) : NULL; + uint32_t used = 0; + VkSubmitInfo *mine = q ? gpuload_ring_wrap(&q->ring, count, infos, &used) : NULL; + VkResult r = d->submit(queue, count, mine ? mine : infos, fence); + if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&q->ring, used); + free(mine); + return r; +} + +static VkResult submit2(PFN_vkQueueSubmit2 next, VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) { + struct queue *q = count ? measuring(queue) : NULL; + uint32_t used = 0; + VkSubmitInfo2 *mine = q ? gpuload_ring_wrap2(&q->ring, count, infos, &used) : NULL; + VkResult r = next(queue, count, mine ? mine : infos, fence); + if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&q->ring, used); + free(mine); + return r; +} + +static VkResult VKAPI_CALL QueueSubmit2(VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) { + struct dev *d = dev_for(queue); + return d ? submit2(d->submit2, queue, count, infos, fence) : VK_ERROR_DEVICE_LOST; +} + +static VkResult VKAPI_CALL QueueSubmit2KHR(VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) { + struct dev *d = dev_for(queue); + return d ? submit2(d->submit2_khr, queue, count, infos, fence) : VK_ERROR_DEVICE_LOST; +} + +static void VKAPI_CALL GetDeviceQueue(VkDevice device, uint32_t family, uint32_t index, VkQueue *out) { + struct dev *d = dev_for(device); + if (!d) return; + d->get_queue(device, family, index, out); + if (out && *out) note_queue(d, *out, family); +} + +static void VKAPI_CALL GetDeviceQueue2(VkDevice device, const VkDeviceQueueInfo2 *info, VkQueue *out) { + struct dev *d = dev_for(device); + if (!d) return; + d->get_queue2(device, info, out); + if (out && *out) note_queue(d, *out, info->queueFamilyIndex); +} + +// ---- instance and device --------------------------------------------------------------------------------------- + +static VkResult VKAPI_CALL CreateInstance(const VkInstanceCreateInfo *ci, const VkAllocationCallbacks *alloc, VkInstance *out) { + VkLayerInstanceCreateInfo *link = (VkLayerInstanceCreateInfo *)ci->pNext; + while (link && !(link->sType == VK_STRUCTURE_TYPE_LOADER_INSTANCE_CREATE_INFO && link->function == VK_LAYER_LINK_INFO)) + link = (VkLayerInstanceCreateInfo *)link->pNext; + if (!link) return VK_ERROR_INITIALIZATION_FAILED; + PFN_vkGetInstanceProcAddr next_gipa = link->u.pLayerInfo->pfnNextGetInstanceProcAddr; + link->u.pLayerInfo = link->u.pLayerInfo->pNext; + PFN_vkCreateInstance create = (PFN_vkCreateInstance)next_gipa(VK_NULL_HANDLE, "vkCreateInstance"); + if (!create) return VK_ERROR_INITIALIZATION_FAILED; + struct inst *in = calloc(1, sizeof(*in)); + if (!in) return VK_ERROR_OUT_OF_HOST_MEMORY; + VkResult r = create(ci, alloc, out); + if (r != VK_SUCCESS) { free(in); return r; } + in->handle = *out; + in->gipa = next_gipa; + in->destroy = (PFN_vkDestroyInstance)next_gipa(*out, "vkDestroyInstance"); + in->props = (PFN_vkGetPhysicalDeviceProperties)next_gipa(*out, "vkGetPhysicalDeviceProperties"); + in->families = (PFN_vkGetPhysicalDeviceQueueFamilyProperties)next_gipa(*out, "vkGetPhysicalDeviceQueueFamilyProperties"); + pthread_mutex_lock(&g_lock); + in->next_in_list = g_insts; + g_insts = in; + pthread_mutex_unlock(&g_lock); + return VK_SUCCESS; +} + +static void VKAPI_CALL DestroyInstance(VkInstance instance, const VkAllocationCallbacks *alloc) { + struct inst *in = inst_for(instance); + if (!in) return; + PFN_vkDestroyInstance destroy = in->destroy; + pthread_mutex_lock(&g_lock); + for (struct inst **p = &g_insts; *p; p = &(*p)->next_in_list) + if (*p == in) { *p = in->next_in_list; break; } + pthread_mutex_unlock(&g_lock); + free(in); + if (destroy) destroy(instance, alloc); +} + +static VkLayerDeviceCreateInfo *device_chain(const VkDeviceCreateInfo *ci, VkLayerFunction function) { + VkLayerDeviceCreateInfo *c = (VkLayerDeviceCreateInfo *)ci->pNext; + while (c && !(c->sType == VK_STRUCTURE_TYPE_LOADER_DEVICE_CREATE_INFO && c->function == function)) + c = (VkLayerDeviceCreateInfo *)c->pNext; + return c; +} + +static VkResult VKAPI_CALL CreateDevice(VkPhysicalDevice pd, const VkDeviceCreateInfo *ci, const VkAllocationCallbacks *alloc, + VkDevice *out) { + VkLayerDeviceCreateInfo *link = device_chain(ci, VK_LAYER_LINK_INFO); + if (!link) return VK_ERROR_INITIALIZATION_FAILED; + VkLayerDeviceCreateInfo *callback = device_chain(ci, VK_LOADER_DATA_CALLBACK); + PFN_vkGetInstanceProcAddr next_gipa = link->u.pLayerInfo->pfnNextGetInstanceProcAddr; + PFN_vkGetDeviceProcAddr next_gdpa = link->u.pLayerInfo->pfnNextGetDeviceProcAddr; + link->u.pLayerInfo = link->u.pLayerInfo->pNext; + struct inst *in = inst_for(pd); + PFN_vkCreateDevice create = (PFN_vkCreateDevice)next_gipa(in ? in->handle : VK_NULL_HANDLE, "vkCreateDevice"); + if (!create) return VK_ERROR_INITIALIZATION_FAILED; + struct dev *d = calloc(1, sizeof(*d)); + if (!d) return VK_ERROR_OUT_OF_HOST_MEMORY; + VkResult r = create(pd, ci, alloc, out); + if (r != VK_SUCCESS) { free(d); return r; } + d->handle = *out; + d->gdpa = next_gdpa; +#define LOAD(field, type, name) d->field = (type)next_gdpa(*out, name) + LOAD(destroy, PFN_vkDestroyDevice, "vkDestroyDevice"); + LOAD(get_queue, PFN_vkGetDeviceQueue, "vkGetDeviceQueue"); + LOAD(get_queue2, PFN_vkGetDeviceQueue2, "vkGetDeviceQueue2"); + LOAD(submit, PFN_vkQueueSubmit, "vkQueueSubmit"); + LOAD(submit2, PFN_vkQueueSubmit2, "vkQueueSubmit2"); + LOAD(submit2_khr, PFN_vkQueueSubmit2, "vkQueueSubmit2KHR"); + LOAD(vk.create_pool, PFN_vkCreateCommandPool, "vkCreateCommandPool"); + LOAD(vk.destroy_pool, PFN_vkDestroyCommandPool, "vkDestroyCommandPool"); + LOAD(vk.alloc_cmds, PFN_vkAllocateCommandBuffers, "vkAllocateCommandBuffers"); + LOAD(vk.begin, PFN_vkBeginCommandBuffer, "vkBeginCommandBuffer"); + LOAD(vk.end, PFN_vkEndCommandBuffer, "vkEndCommandBuffer"); + LOAD(vk.cmd_reset, PFN_vkCmdResetQueryPool, "vkCmdResetQueryPool"); + LOAD(vk.cmd_timestamp, PFN_vkCmdWriteTimestamp, "vkCmdWriteTimestamp"); + LOAD(vk.create_queries, PFN_vkCreateQueryPool, "vkCreateQueryPool"); + LOAD(vk.destroy_queries, PFN_vkDestroyQueryPool, "vkDestroyQueryPool"); + LOAD(vk.results, PFN_vkGetQueryPoolResults, "vkGetQueryPoolResults"); +#undef LOAD + d->vk.device = *out; + d->vk.set_data = callback ? (gpuload_set_data_fn)callback->u.pfnSetDeviceLoaderData : NULL; + if (in && in->props && in->families) { + VkPhysicalDeviceProperties props; + in->props(pd, &props); + gpuload_vk_period(&d->vk, props.limits.timestampPeriod); + uint32_t n = 0; + in->families(pd, &n, NULL); + VkQueueFamilyProperties families[MAX_FAMILIES]; + if (n > MAX_FAMILIES) n = MAX_FAMILIES; + in->families(pd, &n, families); + d->family_count = n; + for (uint32_t i = 0; i < n; i++) d->timestamp_bits[i] = families[i].timestampValidBits; + } + // Without the loader callback our command buffers would lack the loader's dispatch pointer. + if (!d->vk.set_data) d->family_count = 0; + pthread_mutex_lock(&g_lock); + d->next_in_list = g_devs; + g_devs = d; + pthread_mutex_unlock(&g_lock); + return VK_SUCCESS; +} + +static void VKAPI_CALL DestroyDevice(VkDevice device, const VkAllocationCallbacks *alloc) { + struct dev *d = dev_for(device); + if (!d) return; + struct queue *mine = NULL; + pthread_mutex_lock(&g_lock); + for (struct queue **p = &g_queues; *p;) { + struct queue *q = *p; + if (q->dev == d) { *p = q->next_in_list; q->next_in_list = mine; mine = q; } else p = &q->next_in_list; + } + for (struct dev **p = &g_devs; *p; p = &(*p)->next_in_list) + if (*p == d) { *p = d->next_in_list; break; } + pthread_mutex_unlock(&g_lock); + while (mine) { + struct queue *q = mine; + mine = q->next_in_list; + gpuload_ring_release(&q->ring, &d->vk); + free(q); + } + PFN_vkDestroyDevice destroy = d->destroy; + free(d); + if (destroy) destroy(device, alloc); +} + +// ---- dispatch -------------------------------------------------------------------------------------------------- + +EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetDeviceProcAddr(VkDevice device, const char *name); + +static PFN_vkVoidFunction device_hook(struct dev *d, const char *name) { + if (!g_dir) return NULL; + if (!strcmp(name, "vkGetDeviceQueue") && d->get_queue) return (PFN_vkVoidFunction)GetDeviceQueue; + if (!strcmp(name, "vkGetDeviceQueue2") && d->get_queue2) return (PFN_vkVoidFunction)GetDeviceQueue2; + if (!strcmp(name, "vkQueueSubmit") && d->submit) return (PFN_vkVoidFunction)QueueSubmit; + if (!strcmp(name, "vkQueueSubmit2") && d->submit2) return (PFN_vkVoidFunction)QueueSubmit2; + if (!strcmp(name, "vkQueueSubmit2KHR") && d->submit2_khr) return (PFN_vkVoidFunction)QueueSubmit2KHR; + return NULL; +} + +EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetInstanceProcAddr(VkInstance instance, const char *name) { + if (!strcmp(name, "vkCreateInstance")) return (PFN_vkVoidFunction)CreateInstance; + if (!strcmp(name, "vkDestroyInstance")) return (PFN_vkVoidFunction)DestroyInstance; + if (!strcmp(name, "vkCreateDevice")) return (PFN_vkVoidFunction)CreateDevice; + if (!strcmp(name, "vkGetInstanceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetInstanceProcAddr; + if (!strcmp(name, "vkGetDeviceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetDeviceProcAddr; + if (!instance) return NULL; + struct inst *in = inst_for(instance); + return in ? in->gipa(instance, name) : NULL; +} + +EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetDeviceProcAddr(VkDevice device, const char *name) { + if (!strcmp(name, "vkGetDeviceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetDeviceProcAddr; + if (!strcmp(name, "vkDestroyDevice")) return (PFN_vkVoidFunction)DestroyDevice; + struct dev *d = dev_for(device); + if (!d) return NULL; + PFN_vkVoidFunction mine = device_hook(d, name); + return mine ? mine : d->gdpa(device, name); +} + +EXPORT VKAPI_ATTR VkResult VKAPI_CALL DroidDeckGpuLoad_NegotiateLoaderLayerInterfaceVersion(VkNegotiateLayerInterface *v) { + if (v->loaderLayerInterfaceVersion > 2) v->loaderLayerInterfaceVersion = 2; + v->pfnGetInstanceProcAddr = DroidDeckGpuLoad_GetInstanceProcAddr; + v->pfnGetDeviceProcAddr = DroidDeckGpuLoad_GetDeviceProcAddr; + v->pfnGetPhysicalDeviceProcAddr = NULL; + return VK_SUCCESS; +} diff --git a/tools/gpuload/tests/test_gpuload_busy.c b/tools/gpuload/tests/test_gpuload_busy.c new file mode 100644 index 000000000..706580e90 --- /dev/null +++ b/tools/gpuload/tests/test_gpuload_busy.c @@ -0,0 +1,24 @@ +#include +#include + +#include "gpuload_busy.h" + +int main(void) { + const uint64_t max = 1000; + assert(gpuload_interval(10, 20, max) == 10); + // Each batch counts on its own, even when the counter restarted in between (later batch has smaller ticks). + assert(gpuload_interval(900000, 900400, max) == 400); + assert(gpuload_interval(50, 350, max) == 300); + // Backwards, empty or too long: nothing. + assert(gpuload_interval(60, 50, max) == 0); + assert(gpuload_interval(70, 70, max) == 0); + assert(gpuload_interval(100, 100 + max + 1, max) == 0); + assert(gpuload_interval(100, 100 + max, max) == max); + // Query results: both halves available and an end that differs from the last harvested one. + assert(gpuload_result_is_new(1, 1, 500, 400)); + assert(!gpuload_result_is_new(1, 1, 400, 400)); + assert(!gpuload_result_is_new(1, 0, 500, 400)); + assert(!gpuload_result_is_new(0, 1, 500, 400)); + puts("test_gpuload_busy: ok"); + return 0; +} diff --git a/tools/gpuload/tests/test_gpuload_ring.c b/tools/gpuload/tests/test_gpuload_ring.c new file mode 100644 index 000000000..4c5bc1395 --- /dev/null +++ b/tools/gpuload/tests/test_gpuload_ring.c @@ -0,0 +1,96 @@ +#include +#include +#include + +#include "gpuload_ring.h" + +// A fake device: query results come from this table, [start, start available, end, end available] per slot. +static uint64_t g_result[GPULOAD_RING][4]; +static uintptr_t g_next_handle = 0x1000; + +static VkResult VKAPI_CALL create_pool(VkDevice d, const VkCommandPoolCreateInfo *ci, const VkAllocationCallbacks *a, VkCommandPool *out) { + (void)d; (void)ci; (void)a; *out = (VkCommandPool)(g_next_handle++); return VK_SUCCESS; +} +static void VKAPI_CALL destroy_pool(VkDevice d, VkCommandPool p, const VkAllocationCallbacks *a) { (void)d; (void)p; (void)a; } +static VkResult VKAPI_CALL alloc_cmds(VkDevice d, const VkCommandBufferAllocateInfo *ai, VkCommandBuffer *out) { + (void)d; + for (uint32_t i = 0; i < ai->commandBufferCount; i++) out[i] = (VkCommandBuffer)(g_next_handle++); + return VK_SUCCESS; +} +static VkResult VKAPI_CALL begin(VkCommandBuffer c, const VkCommandBufferBeginInfo *bi) { (void)c; (void)bi; return VK_SUCCESS; } +static VkResult VKAPI_CALL end(VkCommandBuffer c) { (void)c; return VK_SUCCESS; } +static void VKAPI_CALL cmd_reset(VkCommandBuffer c, VkQueryPool q, uint32_t first, uint32_t n) { (void)c; (void)q; (void)first; (void)n; } +static void VKAPI_CALL cmd_timestamp(VkCommandBuffer c, VkPipelineStageFlagBits s, VkQueryPool q, uint32_t i) { (void)c; (void)s; (void)q; (void)i; } +static VkResult VKAPI_CALL create_queries(VkDevice d, const VkQueryPoolCreateInfo *ci, const VkAllocationCallbacks *a, VkQueryPool *out) { + (void)d; (void)a; assert(ci->queryCount == 2 * GPULOAD_RING); *out = (VkQueryPool)(g_next_handle++); return VK_SUCCESS; +} +static void VKAPI_CALL destroy_queries(VkDevice d, VkQueryPool q, const VkAllocationCallbacks *a) { (void)d; (void)q; (void)a; } +static VkResult VKAPI_CALL results(VkDevice d, VkQueryPool q, uint32_t first, uint32_t n, size_t size, void *data, + VkDeviceSize stride, VkQueryResultFlags flags) { + (void)d; (void)q; (void)size; + assert(n == 2 && stride == 2 * sizeof(uint64_t) && (flags & VK_QUERY_RESULT_WITH_AVAILABILITY_BIT)); + uint64_t *out = data; + for (int k = 0; k < 4; k++) out[k] = g_result[first / 2][k]; + return out[1] && out[3] ? VK_SUCCESS : VK_NOT_READY; +} + +static void finish(uint32_t slot, uint64_t start, uint64_t end) { + g_result[slot][0] = start; g_result[slot][1] = 1; g_result[slot][2] = end; g_result[slot][3] = 1; +} + +int main(void) { + struct gpuload_vk v = { + .device = (VkDevice)(uintptr_t)1, .create_pool = create_pool, .destroy_pool = destroy_pool, .alloc_cmds = alloc_cmds, + .begin = begin, .end = end, .cmd_reset = cmd_reset, .cmd_timestamp = cmd_timestamp, + .create_queries = create_queries, .destroy_queries = destroy_queries, .results = results, + }; + struct gpuload_ring r = {0}; + assert(!gpuload_ring_setup(&r, &v, 0, 64) && r.state == -1); // no timestamp period yet + gpuload_vk_period(&v, 52.083f); + assert(!gpuload_ring_setup(&r, &v, 0, 0) && r.state == -1); // family without timestamps + assert(gpuload_ring_setup(&r, &v, 0, 64) && r.state == 1); + + VkCommandBuffer app[2] = {(VkCommandBuffer)(uintptr_t)0xa1, (VkCommandBuffer)(uintptr_t)0xa2}; + VkProtectedSubmitInfo prot = {.sType = VK_STRUCTURE_TYPE_PROTECTED_SUBMIT_INFO, .protectedSubmit = VK_TRUE}; + VkSubmitInfo batches[3] = { + {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 2, .pCommandBuffers = app}, + {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO}, // semaphores only + {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .pNext = &prot, .commandBufferCount = 1, .pCommandBuffers = app}, + }; + uint32_t used = 0; + VkSubmitInfo *mine = gpuload_ring_wrap(&r, 3, batches, &used); + assert(mine && used == 1 && r.inflight == 1); + assert(mine[0].commandBufferCount == 4 && mine[0].pCommandBuffers[0] == r.slot[0].start && + mine[0].pCommandBuffers[1] == app[0] && mine[0].pCommandBuffers[2] == app[1] && + mine[0].pCommandBuffers[3] == r.slot[0].end); + assert(mine[1].commandBufferCount == 0 && mine[2].commandBufferCount == 1 && mine[2].pCommandBuffers == app); + free(mine); + + assert(gpuload_ring_harvest(&r, &v) == 0 && r.inflight == 1); // not finished yet + finish(0, 1000, 1192); // 192 ticks = 10 us + assert(gpuload_ring_harvest(&r, &v) == 9999 && r.inflight == 0); + + // A failed submit gives its slot back. + mine = gpuload_ring_wrap(&r, 1, batches, &used); + assert(used == 1 && r.inflight == 1); + gpuload_ring_unwind(&r, used); + assert(r.inflight == 0 && r.next == 1); + free(mine); + + // Fill the ring: the 65th batch goes out untimed. + for (int i = 0; i < GPULOAD_RING; i++) { mine = gpuload_ring_wrap(&r, 1, batches, &used); assert(used == 1); free(mine); } + mine = gpuload_ring_wrap(&r, 1, batches, &used); + assert(used == 0 && mine[0].commandBufferCount == 2); + free(mine); + + // Slot 0 comes round again: until the GPU resets it, it still shows the old pair, which must not count twice. + // Slots 1..63 finish after a counter restart (smaller ticks than slot 0's old pair); each still counts. + for (uint32_t s = 1; s < GPULOAD_RING; s++) finish(s, 10 * s, 10 * s + 4); + assert(gpuload_ring_harvest(&r, &v) == (uint64_t)(63 * 4 * 52.083f) && r.inflight == 1 && r.oldest == 0); + finish(0, 5, 9); + assert(gpuload_ring_harvest(&r, &v) == (uint64_t)(4 * 52.083f) && r.inflight == 0); + + gpuload_ring_release(&r, &v); + puts("test_gpuload_ring: ok"); + return 0; +} diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 15c5b3773..f74d43fe6 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -121,7 +121,7 @@ echo "== droiddeck-desktop: Plasma on $WAYLAND_DISPLAY" dbus-run-session -- startplasma-wayland rc=$? echo "== droiddeck-desktop: Plasma ended ($rc)" -if [ "$rc" -eq 0 ]; then +if [ "$rc" -eq 0 ] && [ -z "${BL_BOTTOM_DESKTOP:-}" ]; then /usr/local/bin/droiddeck-steam-launch bigpicture sleep 30 fi diff --git a/tools/linuxfs/desktop/droiddeck-desktop-gpu b/tools/linuxfs/desktop/droiddeck-desktop-gpu index 419636e53..061d64620 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop-gpu +++ b/tools/linuxfs/desktop/droiddeck-desktop-gpu @@ -19,7 +19,6 @@ def enabled(): def main_group(text): - """The [Desktop Entry] group's lines, and everything after it.""" lines = text.splitlines() start = next((i for i, l in enumerate(lines) if l.strip() == "[Desktop Entry]"), None) if start is None: @@ -56,8 +55,6 @@ def chromium_exec(exec_line): def gpu_exec(exec_line): - """The Exec that runs a program on the GPU; a Flatpak app goes through its launcher, which - keeps it on gamescope's X server instead of the desktop's Wayland socket.""" if exec_line.startswith("/usr/bin/flatpak run ") or exec_line.startswith("flatpak run "): exec_line = "/usr/local/bin/droiddeck-flatpak-run " + exec_line.split(" run ", 1)[1] return "droiddeck-gpu " + exec_line diff --git a/tools/linuxfs/fex/vulkanpin.c b/tools/linuxfs/fex/vulkanpin.c index 45aeb9edb..4ab28afe4 100644 --- a/tools/linuxfs/fex/vulkanpin.c +++ b/tools/linuxfs/fex/vulkanpin.c @@ -19,7 +19,6 @@ typedef struct { typedef VkResult (*Enumerate)(const char *, unsigned int *, VkExtensionProperties *); typedef Function (*ProcAddr)(void *, const char *); -/* The driver cannot enumerate devices for an instance with VK_KHR_display, and gamescope's WSI layer aborts on that. */ static const char *const hidden_extensions[] = { "VK_KHR_display", "VK_KHR_get_display_properties2", diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/bin/droiddeck-innoextract b/tools/linuxfs/innoextract/linuxfs/usr/local/bin/droiddeck-innoextract index 217878a36..f055affe6 100755 --- a/tools/linuxfs/innoextract/linuxfs/usr/local/bin/droiddeck-innoextract +++ b/tools/linuxfs/innoextract/linuxfs/usr/local/bin/droiddeck-innoextract @@ -1,7 +1,4 @@ #!/bin/sh -# Runs DroidDeck's bundled innoextract with its own boost libraries. The guest's -# env -i startup has no LD_LIBRARY_PATH, and the rootfs must not grow a second -# copy of boost for the installer fallback. LD_LIBRARY_PATH=/usr/local/lib/droiddeck-inno export LD_LIBRARY_PATH exec /usr/local/lib/droiddeck-inno/innoextract "$@" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 5e335c313..234be2f00 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -1,22 +1,4 @@ #!/usr/bin/python3 -"""The guest side of the agent bridge (docs/agent-control.md). - -Started by droiddeck-session inside the session, so whatever it runs has the session's own -environment: DISPLAY, the games' Xwayland, XDG_RUNTIME_DIR, and the preloads every guest process -gets - which is what lets it reach the Steam client's DevTools port, open to guest processes only. - -The app talks to it through files under $BL_LAUNCH_DIR/agent (the session root, the same path on -both sides): - - req/.json a request the app wrote (tmp + rename) - resp/.json the answer, written the same way - hello.json this daemon is up: pid, version, what it can do - focus.json gamescope's focus and every server's top-level windows, rewritten on change - cdp-port the client's DevTools port, written by droiddeck-session - -Requests: ping, exec, focus, cdp, quit. Each answer is {"ok": true, ...} or -{"ok": false, "error": {"code", "message"}}. -""" import base64 import json @@ -72,7 +54,6 @@ class AgentError(Exception): class X: - """One connection to an Xwayland server. The rootfs has xprop but no python X library.""" def __init__(self, display): num = display.lstrip(":").split(".")[0] @@ -116,7 +97,6 @@ class X: return buf def request(self, data): - """Send one request and return its reply, or None for an X error (a window that went).""" self.s.sendall(data) while True: head = self._read(32) @@ -186,7 +166,6 @@ class X: def windows(x): - """Top-level windows that are mapped or carry an app id: what gamescope picks focus from.""" out = [] for win in x.children(x.root): attrs = x.attributes(win) @@ -230,9 +209,6 @@ def displays(): def focus_snapshot(): - """gamescope's focus as Steam sees it (root properties on the client's server) and every - server's windows. GAMESCOPE_FOCUSABLE_WINDOWS is the list the client orders the base layer - from: a game missing there stays behind the client however healthy its window looks.""" shown = displays() if not shown: raise AgentError("NO_DISPLAY", "This session has no X display") @@ -349,7 +325,6 @@ def tag_game_windows(snap, proc=Path("/proc")): def vr_scene_app(path=Path('/run/droiddeck/droiddeck-steamvr/scene-focus.json'), proc=Path('/proc')): - """Only a live SteamVR scene with its dashboard hidden may claim game focus.""" try: state = json.loads(path.read_text()) server, scene = int(state['serverPid']), int(state['scenePid']) @@ -385,8 +360,6 @@ class VrSceneFocus: class FocusWatcher(threading.Thread): - """Rewrites focus.json when gamescope's focus properties change, and every few seconds when - the windows do (a window can change without any root property moving).""" def __init__(self, path): super().__init__(daemon=True, name="focus") @@ -450,7 +423,6 @@ class FocusWatcher(threading.Thread): class WebSocket: - """A minimal RFC 6455 client: text frames out, text frames in. Enough for CDP.""" def __init__(self, url, timeout): target = urllib.parse.urlsplit(url) @@ -887,11 +859,6 @@ def steam_selection(agent_dir, timeout=5): def steam_shortcut_app_id(game_id): - """Return the 32-bit app id in Steam's shortcut CGameID form, or None for other ids. - - DroidDeck's shortcut IDs are `(appid << 32) | 0x02000000`. The URL still needs that full - CGameID for RunGame; the app-details API separately needs the unsigned 32-bit app id. - """ if not isinstance(game_id, str) or not game_id.isascii() or not game_id.isdigit(): return None try: @@ -907,7 +874,6 @@ def steam_shortcut_app_id(game_id): def wait_for_steam_shortcut_overview(agent_dir, game_id, timeout=45): - """Wait until Steam can map a shortcut CGameID to its 32-bit app overview.""" app_id = steam_shortcut_app_id(game_id) if app_id is None: return {"supported": False, "ready": False} @@ -962,8 +928,6 @@ def steam_shortcut_state(agent_dir, game_id, timeout=6): def game_pids(app_id): - """Our processes started for this Steam app (Steam sets SteamAppId / SteamGameId in their - environment, and Proton's wine processes inherit it).""" wanted = [("SteamAppId=%s" % app_id).encode(), ("SteamGameId=%s" % app_id).encode()] me = os.getpid() pids = [] @@ -1022,8 +986,6 @@ VR_ACTIONS = ("status", "install", "start", "stop") def steamvr(req): - """Cardboard mode's SteamVR: a fixed helper with four actions, unlike exec (no command line from - the request). install downloads 571 MB, so it runs detached and status reports it.""" action = req.get("action", "status") if action not in VR_ACTIONS: raise AgentError("INVALID_REQUEST", "vr action must be one of " + ", ".join(VR_ACTIONS)) @@ -1244,11 +1206,10 @@ def main(argv): if len(argv) != 2 or not argv[1].isascii() or not argv[1].isdecimal(): print("usage: droiddeck-agent --steam-supports-vr ", file=sys.stderr) return 2 - # Optional-VR games (Thumper, VRChat) carry vr_supported without vr_only. expression = ("(o => o ? (o.vr_supported === true || o.vr_only === true) : null)" "(window.appStore?.GetAppOverviewByGameID?.(%s))" % json.dumps(argv[1])) value = None - deadline = time.monotonic() + 12 # Steam's UI is busiest while a game starts. + deadline = time.monotonic() + 12 while value is None and time.monotonic() < deadline: try: value = cdp_eval(Path(base) / "agent", expression, await_promise=False, timeout=8).get("value") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-bottom-desktop b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bottom-desktop new file mode 100755 index 000000000..2c19c23be --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bottom-desktop @@ -0,0 +1,51 @@ +#!/bin/bash +# KDE Plasma on a dual-screen handheld's bottom screen, next to the session on the top one. +set -u +export HOME=${HOME:-/root} +if [ -n "${BL_DEBUG_DIR:-}" ]; then + mkdir -p "$BL_DEBUG_DIR" 2>/dev/null || true + exec >>"$BL_DEBUG_DIR/bottom-desktop.log" 2>&1 +fi +echo "== droiddeck-bottom-desktop $(date -u '+%Y-%m-%d %H:%M:%S UTC') ${BL_WIDTH:-?}x${BL_HEIGHT:-?} beside a ${BL_TOP_SESSION:-?} session" +[ -x /usr/local/bin/droiddeck-desktop ] || { echo "== the desktop package is not installed"; exit 66; } + +# Launch requests would reach the top session's watcher and could replace it. +unset BL_LAUNCH_DIR BL_LOG +export BL_BOTTOM_DESKTOP=1 + +for _ in $(seq 1 100); do + wayland-info >/dev/null 2>&1 && break + sleep 0.1 +done + +if command -v dbus-daemon >/dev/null 2>&1 && command -v droiddeck-netmanager >/dev/null 2>&1 \ + && ! pgrep -f 'dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf' >/dev/null 2>&1; then + mkdir -p /run/dbus /usr/local/share/droiddeck + cat > /usr/local/share/droiddeck/system-bus.conf <<'BUSCONF' + + + system + unix:path=/run/dbus/system_bus_socket + EXTERNAL + /usr/share/droiddeck/system-services + + + + + + + +BUSCONF + rm -f /run/dbus/system_bus_socket + dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf --nofork --nopidfile & + for _ in $(seq 1 50); do + [ -S /run/dbus/system_bus_socket ] && break + sleep 0.1 + done + [ -S /run/dbus/system_bus_socket ] && droiddeck-login1 --with-netmanager & +fi + +# The game on the top screen keeps the CPU first. +renice -n 5 $$ >/dev/null 2>&1 || true +exec /usr/local/bin/droiddeck-desktop diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap index 59bf0baf2..13dafc0ba 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap @@ -68,7 +68,6 @@ def fd_path(fd): def expand(argv): - """argv with every --args FD replaced by the NUL-separated arguments read from FD.""" out = [] i = 0 while i < len(argv): @@ -188,7 +187,6 @@ def real(path): def remaps_nothing(s): - """True when every path in the sandbox is what it is out here already.""" for op in s.ops: t = op["t"] if t == "bind": diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-bookmarks b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-bookmarks index 28bdd8ac5..5cbf57b4a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-bookmarks +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-bookmarks @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -"""Expose available Steam libraries in Dolphin, retaining the old PCManFM bookmark.""" import hashlib import json import os @@ -13,6 +12,7 @@ import xml.etree.ElementTree as ET LIBRARY = Path("/mnt/droiddeck-sd") +ADDED = Path("/mnt/droiddeck-lib") URI = "file:///mnt/droiddeck-sd" KDE_OWNER = "http://www.kde.org" MARK = "DroidDeckSteamLibrary" @@ -43,7 +43,7 @@ def steam_libraries(home, data): if not isinstance(path, str): continue path = Path(re.sub(r'\\(["\\])', r'\1', path)) - managed = path == LIBRARY or path.is_relative_to(home / "Games") + managed = path == LIBRARY or path.is_relative_to(home / "Games") or path.is_relative_to(ADDED) if selected is not None and managed and path not in selected: continue if path == LIBRARY and selected is None and not os.environ.get("BL_LIBRARY_LABEL"): diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games index fe1cf0d97..f49a90dd7 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games @@ -54,7 +54,6 @@ def icon_for(appid): def client_entries(): - """App ids the client already wrote a menu entry for.""" found = set() if not os.path.isdir(APPS_DIR): return found diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index 7db59b048..bf18c79d3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -329,7 +329,6 @@ def mirror(source, provider, name, target): def build_rootfs(base, provider): - """The runtime with glibc and libstdc++ taken from a newer one, which FEX's GL and Vulkan thunks are built against.""" base_files, provider_files = os.path.join(base, "files"), os.path.join(provider, "files") rootfs = os.path.join(STATE, "rootfs") stamp = "%d\n%s\n%s%s\n%s" % (ROOTFS_LAYOUT, os.path.realpath(base), read_text(os.path.join(base_files, MARKER)) or "", @@ -431,8 +430,6 @@ def link_steam_sdk(): def link_guest_sdk(root): - # Proton's lsteamclient only looks in $HOME/.steam/sdk64; on ARM Steam that is the arm64 client, so - # the x86 runtime gets its own link, which FEX resolves before the host path. for sdk, candidates in (("sdk64", ("linux64", "ubuntu12_64")), ("sdk32", ("linux32", "ubuntu12_32"))): want = "x86_64" if sdk == "sdk64" else "i386" directory = next((os.path.join(STEAM_ROOT, candidate) for candidate in candidates @@ -612,17 +609,13 @@ def guest_environment(fex, env): result["FEX_APP_CONFIG_LOCATION"] = CONFIG_DIR + "/" result["FEX_APP_DATA_LOCATION"] = data + "/" result["FEX_APP_CACHE_LOCATION"] = CACHE_DIR + "/" - # Valve's FEX-Emu binds its server socket under $STEAM_COMPAT_DATA_PATH/fex-emu whatever FEX_APP_DATA_LOCATION or - # FEX_SERVERSOCKETPATH say, and sun_path is 108 bytes: compatdata under the app's data folder is too long. if result.get("STEAM_COMPAT_DATA_PATH"): short = data if data != DATA_DIR else "/tmp/fex" os.makedirs(short, exist_ok=True) result["STEAM_COMPAT_DATA_PATH"] = short result.setdefault("FEX_SILENTLOG", "0") result.setdefault("FEX_OUTPUTLOG", "stderr") - # The session's tunables are for the host libc; an x86 ld.so copying them under FEX fails in sbrk. result.pop("GLIBC_TUNABLES", None) - # Mono's incremental Boehm collector corrupts its heap under FEX, so Unity games crash at their first collections. result.setdefault("GC_DISABLE_INCREMENTAL", "1") if fex["portable"]: result["FEX_PORTABLE"] = "1" @@ -713,7 +706,6 @@ def launch(command, env, mode, decide=None): folder = decide if decide and os.path.isdir(decide) else os.path.dirname(program) if thunks == "on" and x86_64 and os.path.isfile(os.path.join(PRELOADS, "x86_64", VULKAN_PIN)) \ and vulkan_thunk_loads(fex, folder): - # Any LD_AUDIT module makes Mono's garbage collector crash under FEX, so the Vulkan thunk is pinned by search path. guest["LD_LIBRARY_PATH"] = ":".join(["/usr/" + VULKAN_PIN_DIR] + split_paths(guest.get("LD_LIBRARY_PATH"))) thunks = "on, its own Vulkan loader too" say("running %s with %s (runtime %s, thunks %s)" % (program, fex["bin"], guest.get("FEX_ROOTFS", "none"), thunks)) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex-update b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex-update index 3611978f5..d88a4a97e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex-update +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex-update @@ -3,7 +3,6 @@ FEX_TAG=fex-2610-r5 FEX_SHA256=0fc1ca2ba13817ad70d1ea157119a75c9c02446b57d166e6d676cce0807c19c4 FEX_URL=https://github.com/Droid-Deck/DroidDeck-Components/releases/download/$FEX_TAG/fex.tzst -# Whether every program in the FEX tree at $1 finds its libraries (r4 did not without libxxhash). fex_loads() { local b out for b in FEX FEXServer unsquashfs; do @@ -13,8 +12,6 @@ fex_loads() { done } -# Installs one FEX build only once its archive matches the sha256 and it loads. The files it replaces are kept -# until the installed copy loads too, and put back otherwise; the tag is written last. fex_install() { local tag=$1 sha=$2 urls=$3 dst=/ tmp u f tmp=$(mktemp -d) @@ -54,8 +51,6 @@ fex_install() { return 1 } -# The build DroidDeck-Components' fex.json names (the app passes it as BL_FEX_*) comes first, then a working -# installed one, then FEX_TAG above. Sets FEX_NOW and FEX_FROM. fex_ready() { local have ok= have=$(cat /usr/local/share/fex-emu/.droiddeck-tag 2>/dev/null || true) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak index eea38e457..8c23140ef 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak @@ -38,10 +38,6 @@ BUS_CONFIG = """ 2 and os.path.basename(command[2]).lower() == "redprelauncher.exe": game = os.path.join(os.path.dirname(command[2]), "bin", "x64", "Cyberpunk2077.exe") if os.path.isfile(game): @@ -246,7 +244,6 @@ def ubisoft_overlay_off(env): def offline_without_anticheat(command, env): - """Start the configured game executable when the player opts into launching without EAC.""" if (env.get("DROIDDECK_ANTICHEAT_OFFLINE") != "1" or len(command) <= 2 or command[1] != "waitforexitandrun" or not re.fullmatch(r"start_protected_game(?:_?x64|_?64)?\.exe", @@ -264,7 +261,6 @@ def offline_without_anticheat(command, env): game = os.path.join(folder, relative) if os.path.isabs(relative) or relative.startswith("..") or not os.path.isfile(game): return command - # Resolve symlinks before accepting an executable inside the game directory. root = os.path.realpath(folder) if os.path.commonpath((root, os.path.realpath(game))) != root: return command @@ -275,7 +271,6 @@ def offline_without_anticheat(command, env): def engine_fixes(exe, args): - """Environment and extra arguments a game needs because of what it is built with.""" game = Game(exe, args) env, extra, notes = {}, [], [] for check in FIXES: @@ -291,7 +286,6 @@ def engine_fixes(exe, args): def win_components(compat, app_id): - """The game's Windows components put into its prefix; returns their WINEDLLOVERRIDES entries.""" if not compat: return "" try: @@ -318,7 +312,6 @@ def game_gpu(env): def vram_cap(env): - """Puts the session's VRAM cap ahead of the game's own DXVK settings, so a game profile can still raise it.""" value = env.get("BL_VRAM_MB", "") if not re.fullmatch(r"[1-9][0-9]{0,6}", value): return env @@ -330,7 +323,6 @@ def vram_cap(env): def parse_cores(text): - """CPU numbers from a list such as "0-3,6"; None when the text is not one.""" cores = set() for part in text.replace(" ", "").split(","): match = re.fullmatch(r"(\d{1,3})(?:-(\d{1,3}))?", part) @@ -353,7 +345,6 @@ def online_cores(): def game_cores(env): - """Pins the game to the cores its profile names, in place of the session's game cores.""" text = env.get("DROIDDECK_GAME_CORES", "").strip() if not text: return @@ -370,7 +361,6 @@ def game_cores(env): def core_speeds(cores, root="/sys/devices/system/cpu"): - """Each core's capacity, else its top clock; None when some core reports neither.""" for name in ("cpu_capacity", "cpufreq/cpuinfo_max_freq"): speeds = {} for core in cores: @@ -385,14 +375,12 @@ def core_speeds(cores, root="/sys/devices/system/cpu"): def fastest_first(cores): - """The cores, fastest first; cores of equal or unknown speed keep their numeric order.""" cores = sorted(cores) speeds = core_speeds(cores) return sorted(cores, key=lambda core: -speeds[core]) if speeds else cores def proton_cpu_limit(command, env): - """This game's entry in Proton's own default_cpu_limit list: a core count, another value such as "P" as text, or None.""" app_id = env.get("SteamGameId", "") if not command or not app_id.isdigit(): return None @@ -412,7 +400,6 @@ def proton_cpu_limit(command, env): def cpu_topology(env, command=()): - """Windows sees the game's cores fastest first, so a thread a game pins to CPU 0 runs on the fastest core.""" if "PROTON_CPU_TOPOLOGY" in env: return if "WINE_CPU_TOPOLOGY" in env: @@ -445,9 +432,6 @@ def cpu_topology(env, command=()): def gl_vsync(env): - """OpenGL stops waiting for each screen refresh; Steam's Frame Limit still holds the game through gamescope.""" - # Wine asks for interval 1 on every GL window, and WineD3D's front-buffer drawing (DirectDraw, - # Direct3D 1-7) never lowers it, so those games ran at the refresh rate with no limit set. if "vblank_mode" in env or game_id(env) is None: return False env["vblank_mode"] = "0" @@ -509,7 +493,6 @@ WINE_DEBUG_SELECTOR = re.compile(r"(?:(trace|warn|err|fixme))?([+-])([A-Za-z_][A def game_debug_channels(*values): - """Normalize Wine selectors, retaining custom channels before required diagnostics.""" selectors = {} for value in values: if not isinstance(value, str) or "\0" in value or len(value) > 8192: @@ -520,8 +503,6 @@ def game_debug_channels(*values): if match is None: continue key = (match[1] or "", match[3]) - # Move the last value to the end: global +/-all and per-class selectors - # have ordered semantics, so replacing a value in its old position is wrong. selectors.pop(key, None) selectors[key] = part for channel in GAME_DEBUG_CHANNELS: @@ -535,7 +516,6 @@ def game_debug_channels(*values): def apply_game_debug_logs(env, config, debug_dir): - """Apply app-owned diagnostics to a real game, without editing its launch command.""" if not isinstance(config, dict) or config.get("version") != 1 or config.get("gameDebugLogs") is not True: return env if (not isinstance(debug_dir, str) or "\0" in debug_dir or len(debug_dir) > 8192 @@ -607,10 +587,6 @@ SSBS_LIB = "/usr/local/lib/libssbs.so" def force_ssbs(command, env): - """DROIDDECK_FORCE_SSBS=1 (a game's or the shared environment): preload libssbs.so into this - Proton's Wine, which then keeps PSTATE.SSBS set across signal returns - speculative store - bypass stays allowed, as force_ssbs.diff does inside a rebuilt ntdll. ARM64 Protons only: the - library is aarch64 and only arm64 Wine has the signal path it fixes.""" if env.get("DROIDDECK_FORCE_SSBS", "").lower() not in ("1", "on", "true", "yes"): return env proton = os.path.dirname(command[0]) if command else "" @@ -631,10 +607,6 @@ EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGN def mark_install_scripts(command, env): - """The client is about to run a game's install scripts (iscriptevaluator.exe): mark every process - in them but a Vulkan runtime installer as already run first, so no redistributable installer - starts - on first launch the shared ones may still be downloading, and on ARM64 Proton the VC++ - ones stop with an error.""" if not any(arg.lower().replace("\\", "/").endswith("iscriptevaluator.exe") for arg in command): return [] root = env.get("BL_X64_STEAM_ROOT") or env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam") @@ -758,7 +730,6 @@ DIRT_SAVED = ".droiddeck-original" def dirt_telemetry_on(prefix, game): - """Whether the game's UDP/motion output is on, as In-game stats or another listener sets it ("dbox" is its default).""" try: folders = [f for f in (Path(prefix) / "pfx/drive_c/users/steamuser/Documents/My Games").iterdir() if f.name.lower().replace(" ", "") == game] @@ -793,7 +764,6 @@ WINE_DLL_DIRS = ("/lib/wine/i386-windows", "/lib/wine/x86_64-windows", "/lib/win def is_wine_dll_link(path): - """A link into a Wine DLL folder: the rule Proton uses to tell its own DLLs in a prefix (file_is_wine_builtin_dll).""" try: return os.path.islink(path) and os.path.dirname(os.readlink(path)).endswith(WINE_DLL_DIRS) except OSError: @@ -812,7 +782,6 @@ def is_dirt_stand_in(path): def dirt_ws2_wanted(command, env): - """The prefix to give the ws2_32 stand-in: a DiRT 3 / Showdown launch with telemetry on and a Proton without the fix.""" prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/") if len(command) < 3 or not prefix or not os.path.isfile(DIRT_WS2): return None @@ -823,7 +792,6 @@ def dirt_ws2_wanted(command, env): def swap_dirt_ws2(prefix): - """Puts the stand-in where the prefix's syswow64/ws2_32.dll is; that file is kept beside it.""" target = prefix_ws2(prefix) saved = target.with_name(target.name + DIRT_SAVED) data = Path(DIRT_WS2).read_bytes() @@ -837,11 +805,6 @@ def swap_dirt_ws2(prefix): def restore_dirt_ws2(prefix, proton): - """Puts a Wine syswow64/ws2_32.dll back if the stand-in is still there; true when it changed something. - - That is the prefix's own one, unless it was a link to a Wine DLL folder and the game ran on another Proton since - the swap: Proton then relinked the prefix's other DLLs to itself but left the stand-in, so a link to the running - Proton's ws2_32.dll goes back instead. Anything else (a native file, a link to a mod's DLL) goes back as it was.""" target = prefix_ws2(prefix) saved = target.with_name(target.name + DIRT_SAVED) own = proton_ws2(proton) @@ -876,10 +839,6 @@ def windows_core_count(env): def ego_core_plan(command, env): - """Codemasters EGO games (DiRT 3, DiRT Showdown, GRID, F1 ...) pick their thread plan by core count from - system/hardware_settings_restrictions.xml. A count with no plan (5 when a phone's slow cores are left out) puts - every worker on one core, and DiRT 3 then stops on a loading screen. Adds a plan for the count Windows sees, - copied from the nearest smaller one; the file as it was is kept beside it. Returns a note, or None.""" count = windows_core_count(env) if not count or len(command) < 3: return None diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog-maintenance b/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog-maintenance index 4a56fd122..1fc6e0f37 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog-maintenance +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog-maintenance @@ -1,5 +1,4 @@ #!/usr/bin/python3 -"""GOG patch installers and the lease shared by maintenance and game launches.""" import fcntl import errno import json @@ -133,7 +132,6 @@ def runner(folder, appid, game, graphical=False): base = desktop_environment(os.environ) if graphical else dict(os.environ) env = run["environment"](base, target, tool, root, window=base.get("BL_DESKTOP") == "1") env["DROIDDECK_GOG_MAINTENANCE"] = "1" - # Installer copy routines use x87 integer loads/stores; reduced precision corrupts paths (FEX #5827). env["FEX_X87REDUCEDPRECISION"] = "0" env.pop("BL_STORE_EXIT", None) return str(Path(tool) / command), env, target.compatdata diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-gpu-window b/tools/linuxfs/overlay/usr/local/bin/droiddeck-gpu-window index 0c1c9818f..5fc4e20a0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-gpu-window +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-gpu-window @@ -1,5 +1,4 @@ #!/usr/bin/python3 -"""Let GTK draw close buttons inside gamescope, which supplies no window decorations.""" import ctypes as c import os import signal diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-ime b/tools/linuxfs/overlay/usr/local/bin/droiddeck-ime index c96aa283b..4f110cfbc 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-ime +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-ime @@ -85,7 +85,7 @@ def connect(): def main(): launch = os.environ.get("BL_LAUNCH_DIR", "") - report = Report(os.path.join(launch, "agent") if launch else "") + report = Report(os.environ.get("BL_IME_DIR") or (os.path.join(launch, "agent") if launch else "")) conn = Connection(connect()) conn.send(DISPLAY_ID, 1, struct.pack("