From beb96e6f0c891ab5692e73ca7d9d7c970b7e057f Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:15:24 -0400 Subject: [PATCH 1/9] docs: record Thor Steam SD allocation findings (#240) --- .../evidence/thor-sd-storage-2026-10-04.txt | 107 +++++ docs/development/proot-performance.md | 5 + .../thor-sd-storage-investigation.md | 379 ++++++++++++++++++ 3 files changed, 491 insertions(+) create mode 100644 docs/development/evidence/thor-sd-storage-2026-10-04.txt create mode 100644 docs/development/thor-sd-storage-investigation.md diff --git a/docs/development/evidence/thor-sd-storage-2026-10-04.txt b/docs/development/evidence/thor-sd-storage-2026-10-04.txt new file mode 100644 index 000000000..17961978f --- /dev/null +++ b/docs/development/evidence/thor-sd-storage-2026-10-04.txt @@ -0,0 +1,107 @@ +Thor SD storage investigation: selected saved evidence +Recorded 2026-10-04; event timestamps are EDT. +These are excerpts, not complete logs or a continuous attribution profile. +See ../thor-sd-storage-investigation.md for context and live-only observations. + +=== Reporter unsupported allocation === +Source: Reporter diagnostic storage.log +volume_removable=true +filesystem=fuse +allocation pid=29062 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=374272 elapsed_us=212 + +=== Thor unsupported allocation === +Source: candidate-storage.log +volume_removable=true +filesystem=fuse +allocation pid=9810 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=650752 elapsed_us=310 + +=== Native-path allocation diagnostics === +Source: direct-storage-completed.log / session 64 +DroidDeck storage diagnostics v1 +selected_library=secondary +volume_removable=true +filesystem=exfat +truncate_allocation_probe=passed +block_size_bytes=131072 +total_bytes=393855500288 +available_bytes=101076697088 +native_observation=enabled +public_symbol_interposition=enabled +direct_syscalls_and_libc_internal_calls=not_observed +pwrite_timing=sampled_1_in_32 +per_process_allocation_detail_limit=64 +storage_log_limit_bytes=524288 +summary pid=26725 comm=python3 allocation_calls=0 allocation_failures=0 allocation_total_us=0 pwrite_sample_every=32 pwrite_samples=0 pwrite_bytes=0 pwrite_failures=0 pwrite_sample_total_us=0 pwrite_sample_max_us=0 ftruncate_calls=0 ftruncate_failures=0 ftruncate_total_us=0 ftruncate_max_us=0 fstat_calls=1 fstat_total_us=0 fstat_max_us=0 fsync_calls=0 fsync_failures=0 fsync_total_us=0 fsync_max_us=0 +allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=667648 elapsed_us=387 +allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=39 elapsed_us=32 +allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=156 elapsed_us=25 +allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=457428 elapsed_us=351 +allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=901928 elapsed_us=864 +allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=15041920 elapsed_us=15292 +allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=337563 elapsed_us=2783 + +=== Fresh real Steam install === +Source: direct-content-completed.log / session 64 +[2026-10-04 17:31:20] AppID 1675830 App update changed : Running Update,Preallocating, +[2026-10-04 17:38:04] AppID 1675830 preallocated 2216 files (7626 MB) +[2026-10-04 17:38:06] AppID 1675830 App update changed : Running Update,Downloading,Staging, +[2026-10-04 17:45:25] AppID 1675830 App update changed : Running Update,Committing, +[2026-10-04 17:45:25] AppID 1675830 starting commit from "/mnt/droiddeck-sd/steamapps/downloading/1675830" to "/mnt/droiddeck-sd/steamapps/common/1000xRESIST" : 2230 updated, 0 moved, 0 deleted files +[2026-10-04 17:47:55] AppID 1675830 state changed : Fully Installed,Update Queued,Update Running, +[2026-10-04 17:47:55] AppID 1675830 finished update, 1 mounted depots (BuildID 21498291) : 1675831 (3663189783241284849), +[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,Update Queued, +[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed, +[2026-10-04 17:47:56] AppID 1675830 scheduler finished : removed from schedule (result No Error, state 0xc) + +=== Normal-app native/FUSE directory read === +Source: app-raw-probe-session.log / session 62 +APP_RAW_PROBE uid=10195 path=/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.002288 calls=2 bytes=154920 read_error=0 +APP_RAW_PROBE uid=10195 path=/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.501306 calls=112 bytes=154872 read_error=0 + +=== Binding regression: bind-probe-before.log === +Source: bind-probe-before.log +BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe +BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe +BIND_PROBE index=2 pass=0 errno=2 bytes=-1 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe +BIND_PROBE index=3 pass=0 errno=2 bytes=-1 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe + +=== Binding regression: bind-probe-after.log === +Source: bind-probe-after.log +BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe +BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe +BIND_PROBE index=2 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe +BIND_PROBE index=3 pass=1 errno=0 bytes=17 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe + +=== Root metadata/mount experiment: metadata-mount-comparison.log === +Source: metadata-mount-comparison.log +original_mount +/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0 +files=64 bytes_per_file=262144 open_seconds=6.707435 truncate_seconds=0.084806 wall_seconds=6.794807 error=0 +mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument +mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument + +=== Root metadata/mount experiment: metadata-syscall-comparison.log === +Source: metadata-syscall-comparison.log +original_mount +/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0 +files=64 bytes_per_file=262144 open_seconds=25.181255 truncate_seconds=0.309577 wall_seconds=25.493210 error=0 +temporary_mount +/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0 +files=64 bytes_per_file=262144 open_seconds=2.013194 truncate_seconds=0.095924 wall_seconds=2.112288 error=0 + +=== Post-install Android low-memory exit === +Source: game-launch-exit-info.txt + ApplicationExitInfo #0: + timestamp=2026-10-04 18:13:48.454 pid=14709 realUid=10195 packageUid=10195 definingUid=10195 user=0 + process=com.droiddeck.launcher reason=3 (LOW_MEMORY) subreason=0 (UNKNOWN) status=0 + importance=100 pss=227MB rss=344MB description=null state=empty trace=null + +=== Combined release unit-test completion === +Source: combined-233-tests.log +> Task :app:testReleaseUnitTest +BUILD SUCCESSFUL in 10s + +=== Combined release build completion === +Source: combined-233-build.log +BUILD SUCCESSFUL in 25s +APK: /Users/danhimebauch/.codex/worktrees/steam-sd-preallocation/DroidDeck/app/build/outputs/apk/release/app-release.apk diff --git a/docs/development/proot-performance.md b/docs/development/proot-performance.md index e9f18847b..3679f43ea 100644 --- a/docs/development/proot-performance.md +++ b/docs/development/proot-performance.md @@ -5,6 +5,11 @@ games should keep running under proot, run directly on glibc, or a mix of the tw fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel 6.12.38-android16, Android 16) over adb, unless it says otherwise. +For Steam SD allocation measurements on AYN Thor (Android 13), see the +[Thor storage investigation](thor-sd-storage-investigation.md). It separates +observed allocation-wrapper time, FUSE directory access and native exFAT metadata +waits; it does not establish a full-install PRoot overhead percentage. + ## Short answer - **Don't drop proot.** On Android it does two jobs. It translates paths. It also answers every diff --git a/docs/development/thor-sd-storage-investigation.md b/docs/development/thor-sd-storage-investigation.md new file mode 100644 index 000000000..b2cfbce55 --- /dev/null +++ b/docs/development/thor-sd-storage-investigation.md @@ -0,0 +1,379 @@ +# Steam SD allocation on Thor + +Investigation recorded on 2026-10-04. Device measurements and PR states below are a +snapshot of that day's work. All event times are EDT (UTC−04:00). + +A fresh 1000xRESIST install on the Thor SD card completed with the native-path and +allocation-fallback patches: 2,216 files preallocated in **6m44s**, followed by a +successful download and commit, **16m36s total**. The observed allocation wrappers +reported zero failures and roughly 7.1 seconds of aggregate execution time. +That wrapper measurement does **not** account for the entire preallocation phase. + +Native directory access was substantially faster than Android's FUSE view in a +small controlled probe. Kernel samples during the remaining delay showed exFAT +file-creation and size-update waits. This supports further investigation of SD +metadata writes. It does not establish that PRoot is free, that `dirsync` alone +causes the delay, or that SD now matches internal-storage install speed. + +Selected source-log excerpts are preserved in +[the evidence file](evidence/thor-sd-storage-2026-10-04.txt). The original full logs +contain unrelated sessions and are not needed to interpret those excerpts. + +## Device and storage + +| Item | Observed configuration | +|---|---| +| Device | AYN Thor, Android 13, kernel 5.15.123, 12 GB RAM and 8 GB swap | +| App | Production-signed `com.droiddeck.launcher`, normal app UID 10195 | +| Configured SD library | `/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` | +| Native view of the same library | `/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` | +| Guest content aliases | `/mnt/droiddeck-sd` and legacy `/mnt/bannerlator-sd` | +| SD filesystem | Native exFAT; its Android `/storage` view reports FUSE | +| Native filesystem magic | `0x2011BAB0`, matching the NDK Linux header | +| Internal comparison location | App-private cache under `/data` | + +The native SD mount was observed with these options: + +```text +rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro +``` + +The device already had root and SELinux Permissive. Neither state was introduced +by this investigation. Root was used for inspection and temporary experiments. +The validated Steam session ran with the app's normal UID and existing groups, +without a root launcher or an app-specific Magisk root grant. + +This distinction does not prove the native path works on an unrooted device with +enforcing SELinux. The production change tests access in the app and retains the +configured path when verification fails. + +## Initial report and allocation failure + +The [linked Discord report](https://discord.com/channels/1552707793247539220/1555632840388116491/1556271955076710560) +included a newer diagnostic ZIP with `storage.log`. Unlike the earlier capture +that lacked allocation instrumentation, this capture established: + +- The selected secondary library was removable and its exposed filesystem was + FUSE. +- Steam called mode-zero `fallocate` and received `EOPNOTSUPP` (errno 95). +- That observed failure returned in 212 microseconds. It was not itself a + multi-minute blocked call. + +A Thor baseline capture likewise showed `fallocate` failing with errno 95 in +310 microseconds. These establish an unsupported allocation API on the tested +paths. They do not attribute the later Steam delay to any particular fallback. + +The suggestion that glibc's `posix_fallocate` emulation accounts for the whole +hang was not established by those logs. The tested Steam calls included +`fallocate`; directory operations and other calls occur outside that wrapper. + +## Published patches and the tested build + +The main base was `26cb9e2f038cdbab4cdc8670d2f64c18d1d90875`. These heads were +checked again while preparing this report; all three storage PRs were open with +successful Build APK checks and clean merge states. This is a dated snapshot, +not a statement about their future merge status. + +| PR | Tested head | Behavior | +|---|---|---| +| [#221](https://github.com/Droid-Deck/DroidDeck/pull/221) | `7962c19283bfb0f18a5c7672e08a879814a3d5a1` | Verify a native SD content alias and handle safe case aliases in the path fast path | +| [#229](https://github.com/Droid-Deck/DroidDeck/pull/229) | `4628ae4c92cc5813013537165a0e95886e6de904` | Use a verified truncate fallback for unsupported selected-library allocation | +| [#233](https://github.com/Droid-Deck/DroidDeck/pull/233) | `5c6765233ae5405d7bd4c1b29f20c9e8343a1fed` | Preserve the complete fast-path binding table, including later private overrides | +| [#225](https://github.com/Droid-Deck/DroidDeck/pull/225) | `0b5908d6d63080f625b51bab3439e2e68466631e` | Repair repeated failed Steam client starts; reviewed separately, excluded from this storage build | + +### Native content path and case handling: #221 + +For a configured `/storage/...` library, the launcher checks the corresponding +`/mnt/media_rw/...` candidate. It requires usable directory permissions and +performs a write/read/delete identity probe through both views. Failure keeps +the original configured path. + +Only the shared SD content bindings change. The configured path remains the +library's identity for private prefixes, migrations and other private storage. +This avoids moving existing saves or runtime state when selecting a faster view. +Removable-volume detection also uses the original configured path because +Android's volume API need not recognize the native alias. + +The C fast path permits case-only canonical-parent differences on known +FUSE/FAT/exFAT filesystems only after checking directory components without +following symlinks. It also handles the supported `F_OK` plus +`AT_SYMLINK_NOFOLLOW` case. Ambiguous paths still fall back to PRoot. + +The final #221 diff was four files, 98 insertions and 20 deletions, including +28 new test lines. The experimental directory cache was removed from that diff. + +### Allocation fallback: #229 + +The launcher first creates a temporary 1 MiB file on the selected content path, +extends it, and checks that `st_blocks` reports at least 2,048 512-byte blocks. +The file must also be cleaned up successfully. The fallback is enabled only for +a removable library that passes this reservation probe. + +For file descriptors on the selected library device, the wrapper tries the real +allocation API. On mode-zero `fallocate` returning `EOPNOTSUPP`, it extends the +file with `ftruncate`. It validates the range, detects overflow and avoids +shrinking an existing file. Other modes and other errors retain their normal +behavior. The POSIX wrapper tries kernel allocation first to avoid unnecessary +libc emulation and preserves POSIX error-return and `errno` semantics. + +This is a capability-tested fallback, not unconditional successful allocation +reporting. A failed extension still returns an error. The optimization operates +independently of the optional diagnostic toggle. + +The reservation probe and one successful full install do not replace separate +future tests for ENOSPC, interrupted installs or other devices/filesystems. + +### Complete binding table: #233 + +The real Steam session had **96 bindings**. The old C limit of 64 silently +dropped later mappings. Several of those were private overrides inside the +shared SD root, so truncation could send an operation to the wrong location or +return `ENOENT`. + +#233 raises capacity to 256 and makes both the launcher and C implementation +disable the fast path if a complete table cannot be represented. Overlong paths +also cause fallback rather than partial mapping. This is a correctness fix as +well as a requirement for trusting the storage fast path. + +Marker files existed only in private directories. A normal app-process probe +read them through these guest paths: + +| Guest path under the library | Before #233 | After #233 | +|---|---|---| +| `SteamLinuxRuntime_4-arm64` | Correct private marker | Correct private marker | +| `steamlinuxruntime_4-arm64` | Correct private marker | Correct private marker | +| `proton experimental (arm64)` | `ENOENT` | Correct private marker | +| Legacy `steamapps/shadercache` | `ENOENT` | Correct private marker | + +Each successful read returned the 17-byte `private-override\n` marker. The +temporary markers and helper executable were removed afterward. + +### Deployment and checks + +The full fresh install used #221 plus #229. #233 was then integrated locally, +tested, built and installed for the binding regression and game-launch check. +It was not merged on GitHub during this investigation. + +The combined integration commit was +`f5d3e93b8eb8912ca3834f7447a6498c95250177`. The production-signed APK was installed +in place with `adb install -r`; its SHA-256 was: + +```text +099a0d7e76b9462b3b238c6f1abeb03570ec9708ace3092adf178ec2bee63e2a +``` + +`tools/build_local.sh` completed for the combined build. The release unit tests +for `SecondaryLibraryTest` and `ProotFastPathTest` passed. The latter covers +96/256 bindings, exceeding capacity, and overlong paths. #225 was reviewed for +scope; its tests were not run as part of this storage validation. + +## Fresh real Steam install + +Game: **1000xRESIST**, app 1675830, depot 1675831, BuildID 21498291. Steam reported +6,582,351,824 download bytes and 7,997,085,707 staging bytes, approximately 8 GB. + +| Event | Time | Evidence | +|---|---|---| +| Fresh preallocation began | 17:31:20 | Steam entered `Preallocating` | +| Preallocation completed | 17:38:04 | 2,216 files, 7,626 MB in Steam's log | +| Download/staging began | 17:38:06 | `Downloading,Staging` | +| Commit began | 17:45:25 | 2,230 updated files | +| Fully installed | 17:47:55 | `Fully Installed`, BuildID 21498291 | +| Scheduler completed | 17:47:56 | `No Error` | + +Preallocation took **404 seconds**. Start to scheduler completion took +**996 seconds**. Live counters showed 2,216 allocation calls, zero allocation +failures and about **7.1 seconds aggregate allocation-wrapper time**. The saved +diagnostic detail log is capped at the first 64 allocation details per process; +that file alone cannot reproduce the live aggregate counter observation. + +The final manifest had `StateFlags=4`, `UpdateResult=0`, all expected download +and staging bytes completed, and `SizeOnDisk=7997085707`. The completed executable +was under `steamapps/common/1000xRESIST`; the active staging directory was gone. + +Transfer rates were mostly 117–132 Mbps, about 15–16.5 MB/s. Two root metadata +experiments ran during downloading, so this is not a clean network/card +throughput benchmark. No matched full internal-storage install was performed. + +The run's `storage.log` reported native exFAT and a passed truncate reservation +probe. Recorded allocation details used `api=fallocate_ftruncate`, with +`result=0` and `api_error=0`. + +## What the metadata measurements show + +Metadata is filesystem bookkeeping: names, directory entries, sizes and space +allocation. Steam's `Preallocating` phase includes work outside the observed +allocation wrappers, including creating and locating files. + +### Directory reads through native and FUSE paths + +A helper ran in a normal app process and listed the same stable asset directory +from a saved staging tree through both host paths. It used the existing fast-path +syscall trampoline to bypass PRoot path translation while retaining the app's +Android permissions. The read buffer was 128 KiB. + +| View | Elapsed | Successful `getdents64` calls | Directory bytes | +|---|---|---|---| +| Native exFAT | About 2.3 ms | 2 | 154,920 | +| Android FUSE | About 501 ms | 112 | 154,872 | + +The views returned slightly different byte counts. This was a single +native-first pass, not a repeated benchmark with fully controlled cache state. +It demonstrates a large cost difference for that directory read, not a +corresponding multiplier for a full Steam install. + +An earlier root listing during concurrent file creation measured roughly +5.56 seconds through FUSE and 9.25 milliseconds native. Its directory was +changing, so it is weaker evidence and is not used as a controlled comparison. + +### File creation and size updates + +During the fresh native-path Steam run, sampled kernel stacks included: + +```text +__sync_dirty_buffer -> exfat_update_bh -> exfat_init_ext_entry + -> exfat_add_entry -> exfat_create -> path_openat + +__wait_on_buffer -> exfat_update_bhs -> exfat_free_dentry_set + -> __exfat_write_inode -> exfat_truncate -> do_sys_ftruncate +``` + +These were live samples, not a continuous attribution profile. They show time +waiting in the native filesystem's creation/update paths during the delay. + +A root-only helper created a temporary directory, then 64 long-named files using +`open(O_CREAT|O_EXCL|O_WRONLY, 0600)`, `ftruncate(fd, 262144)` and `close`. It timed +the creation and truncate calls separately and removed its files afterward. + +| Location/run | Sum of open times | Sum of truncate times | Whole loop | +|---|---|---|---| +| Native SD, original mount | 6.707435 s | 0.084806 s | 6.794807 s | +| Native SD, later repeat | 25.181255 s | 0.309577 s | 25.493210 s | +| Internal app cache, live observation | About 7.88 ms | About 0.498 ms | About 9.1 ms | + +The SD results varied substantially, and these root probes do not reproduce the +normal app's complete Steam workload. The measured internal probe is not a full +internal-install benchmark. Within these probes, file creation cost far more +than the observed truncate calls. + +### `dirsync` and the failed remount comparison + +The [Linux mount documentation](https://man7.org/linux/man-pages/man2/mount.2.html) +defines `MS_DIRSYNC` as synchronous directory changes. Combined with the observed +exFAT buffer waits, this makes synchronous SD metadata updates a strong lead. +It remains an inference about the remaining overall delay. + +A toybox remount attempt returned `Invalid argument`. A direct mount-syscall +experiment changed the root view, but Steam's observed mount table still showed +`dirsync`. A subsequent 64-file probe took 2.112288 seconds; because the effective +flag change was not established and timings were variable, this is **not proof +that removing `dirsync` improves Steam allocation**. The Linux documentation also +notes that attempts to change `MS_DIRSYNC` during a remount are silently ignored. + +Original effective options were restored and checked in root and app/process +views. No remount or root-launch behavior is included in the production patches. + +### What remains attributable to PRoot + +One sampled Steam worker was executing an `openat` through the fast-path +trampoline at `0xffff00020`, with an absolute native host path. That establishes +that some operations bypassed PRoot's path translation in the actual session. +Other syscalls still use PRoot. + +There was no matched PRoot-versus-direct full preallocation comparison. The +fraction of the 404 seconds attributable to PRoot is therefore unknown. The +earlier [PRoot performance notes](proot-performance.md) concern different hardware +and workloads and cannot supply that missing percentage. + +Seven seconds in public allocation wrappers does not mean the remaining +397 seconds are unrelated to allocation: libc-internal/direct-syscall bypasses, +explicit size changes and file-creation work may occur outside those counters. + +## Experiments that did not become the fix + +- **Directory caching:** eager scans held up directory open; creation-driven + invalidation repeatedly reread growing directories. The capped design could + also read and discard a large directory before rereading it. A lazy variant + still left 182 files taking 4m49s while the allocation wrappers accounted for + only 0.879 seconds. These were interrupted/resumed runs, not a matched A/B + benchmark. The final #221 snapshot removes the cache entirely. +- **Allocation fallback alone:** a FUSE-path attempt was still slow after + unsupported allocation calls were handled. A later eager-cache resumed run + accumulated roughly 8.36 seconds in 1,435 successful allocation calls over + about 36 minutes of wall time. Successful wrappers alone were insufficient. +- **Launching Steam through root:** experimental wrappers failed compositor + startup. They did not produce allocation measurements and were reverted. +- **Changing mount flags:** effective removal of `dirsync` from Steam's view was + not demonstrated. The apparent probe speedup is not a validated fix. +- **Replacing PRoot or reformatting storage:** no such replacement, adoptable + storage conversion or ext4 loop-image deployment was validated here. + +The early FUSE runs used partial staging state. Their file counts and durations +must not be compared to the fresh 2,216-file run as a precise speedup ratio. + +## Post-install launch and separate memory failure + +After deploying #221 + #229 + #233, the installed SD game was launched through +Steam. First-run Proton prefix initialization preceded the game window. + +At **18:10:45**, the compositor changed from Steam Big Picture to **1000xRESIST** +and received the game's 1280×720 GPU frames. A screenshot showed the game loading +screen and spinner. A main menu or sustained gameplay was not validated. + +At **18:13:48.454**, Android recorded the foreground app's exit as reason 3, +`LOW_MEMORY`, with main-app PSS 227 MB and RSS 344 MB. Those figures describe that +process, not the complete native process tree or its peak memory use. Subsequent +gamescope/reaper shutdown messages followed the parent being killed. + +This was after the install had completed. It is a separate unresolved launch/ +memory issue and does not undo the successful allocation/download result. +Post-kill free-memory readings cannot establish peak memory pressure. + +## Remaining questions and next useful tests + +1. Run the same fresh game install on internal and secondary storage with the + same manifest, awake device and no competing benchmarks. Compare preallocation, + download/staging and commit separately. +2. Match the file-creation/extension workload through PRoot and through the normal + app's direct syscall path. Keep UID, filesystem, names and sizes identical, + repeat runs and record background I/O. This can quantify the remaining tracer + contribution without changing storage semantics. +3. Establish an actual controlled difference in synchronous directory behavior + before attributing a speedup to mount flags. A remount return code alone is + insufficient; this is separate from a portable app patch. +4. Validate native-path fallback under enforcing SELinux and on unrooted devices, + plus allocation behavior near ENOSPC and after interruption. Preserve the + configured-path fallback if either access or reservation verification fails. +5. Investigate the low-memory exit with process-tree/cgroup memory and Android + kill evidence during loading. Treat it separately from install performance. + +At this point, one fresh SD install and the binding regression are validated. +Internal-speed parity, the exact PRoot share, a `dirsync` remedy, broad device +coverage and stable gameplay remain unproven. + +## Evidence inventory and handoff state + +The device captures used for the final measurements were: + +- `2026-10-04-62-app-raw-probe`: normal-app native/FUSE directory probe. +- `2026-10-04-64-steam`: fresh complete allocation/download/commit. +- Probe sessions before and after #233: private binding markers. +- `2026-10-04-67-steam`: combined-build launch, followed by Android's low-memory + kill; the exit reason was obtained separately with `dumpsys activity exit-info`. + +Local originals during the investigation were under +`/tmp/droiddeck-storage-live/`: `direct-content-completed.log`, +`direct-storage-completed.log`, `app-raw-probe-session.log`, +`bind-probe-before.log`, `bind-probe-after.log`, +`metadata-mount-comparison.log`, `metadata-syscall-comparison.log`, +`game-launch-exit-info.txt`, `combined-233-build.log` and +`combined-233-tests.log`. The repository evidence file preserves the selected +excerpts even if those temporary local files disappear. Live-only counters, +kernel samples and the internal metadata timing are identified above rather +than represented as saved continuous traces. + +The combined signed build and completed SD install remained on Thor. Storage +diagnostics remained enabled. Temporary probe files, executables and obsolete +partial staging were removed after validation. Original effective mount options +were restored; no app-specific Magisk root policy remained. The primary checkout's +unrelated `SessionService.kt` modification was preserved. From 4fa467c800354f6759ed64fde8cbaa15765af080 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 4 Oct 2026 21:33:16 -0400 Subject: [PATCH 2/9] Fetch the Steam client again when it never shows up (#225) A client stopped part way through updating itself can be left unable to draw anything. Its files and our install stamp are still on disk, so the installer never looked at it again and every session sat on "Waiting for Steam" with nothing retried. The session now adds a line to package/droiddeck-unconfirmed each time it starts the client, and the app removes the file on the client's first frame. After two starts in a row with nothing shown, the installer: - drops Steam's update manifests, the stamp, .crash and steam.pid - deletes package zips whose sha1 no longer matches their name - downloads the client again over the old one steamapps, config and userdata are left alone. An automatic repair runs once per failure, until a start is seen again. The Steam page's Client settings get a "Repair Steam client" item that queues the same repair for the next start. The request is only cleared once the download completes, so an interrupted repair is retried. --- .../com/droiddeck/launcher/MainActivity.kt | 5 ++ .../com/droiddeck/launcher/SessionActivity.kt | 2 + .../droiddeck/launcher/session/SteamRepair.kt | 21 +++++++ .../launcher/ui/ModeSettingsDialog.kt | 8 +++ app/src/main/res/values/strings.xml | 4 ++ .../overlay/usr/local/bin/droiddeck-session | 4 ++ .../usr/local/bin/droiddeck-steam-install | 28 +++++++++ tools/tests/test_steam_install.py | 63 +++++++++++++++++++ 8 files changed, 135 insertions(+) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 0414ba2b0..e2e82449c 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -42,6 +42,7 @@ import com.droiddeck.launcher.ui.ComponentsPage import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SteamRepair import com.droiddeck.launcher.ui.ProtonPage import com.droiddeck.launcher.core.CpuCores import com.droiddeck.launcher.core.PhantomProcessLimit @@ -110,6 +111,7 @@ class MainActivity : ComponentActivity() { private var glThread by mutableStateOf(true) private var noGlError by mutableStateOf(true) private var steamDeckMode by mutableStateOf(false) + private var steamRepairQueued by mutableStateOf(false) private var mangoapp by mutableStateOf(true) private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK) private var showProtons by mutableStateOf(false) @@ -1140,6 +1142,7 @@ class MainActivity : ComponentActivity() { syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null, steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null, steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode, + steamRepairQueued = steamRepairQueued, mangoapp = mangoapp, steamController = if (mode == SessionService.MODE_STEAM) steamController else null, runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup, @@ -1218,6 +1221,7 @@ class MainActivity : ComponentActivity() { syncFallback = SessionPrefs.syncFallback(this) }, onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id }, + onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) }, onSteamDeckMode = { on -> SessionPrefs.setSteamDeckMode(this, on) steamDeckMode = on @@ -1337,6 +1341,7 @@ class MainActivity : ComponentActivity() { syncFallback = SessionPrefs.syncFallback(this) steamChannel = SessionPrefs.steamChannel(this) steamDeckMode = SessionPrefs.steamDeckMode(this) + steamRepairQueued = SteamRepair.queued(this) mangoapp = SessionPrefs.mangoapp(this) steamController = SessionPrefs.steamController(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 9d7fa640e..abbe696e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -59,6 +59,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.LoadingState import com.droiddeck.launcher.session.PerfHud import com.droiddeck.launcher.session.PerfMode +import com.droiddeck.launcher.session.SteamRepair import com.droiddeck.launcher.session.GameEnvironmentStore import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionEvents @@ -1933,6 +1934,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) } private val firstFrameListener = Runnable { SessionEvents.firstFrame() + if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this) runOnUiThread { loading.visible = false hud.start() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt new file mode 100644 index 000000000..38fc9c235 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt @@ -0,0 +1,21 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File + +object SteamRepair { + private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root") + private fun request(context: Context) = File(home(context), ".bl-steam-repair") + private fun unconfirmed(context: Context) = File(home(context), ".local/share/Steam/package/droiddeck-unconfirmed") + + fun queued(context: Context): Boolean = request(context).exists() + + fun queue(context: Context): Boolean = runCatching { + request(context).apply { parentFile?.mkdirs() }.writeText("1\n") + }.isSuccess + + fun clientShown(context: Context) { + unconfirmed(context).delete() + } +} 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 714b6590e..36997decf 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -81,6 +81,7 @@ class ModeSettings( val steamChannel: String? = null, /** Steam only: enable the SteamOS client interface and its performance controls. */ val steamDeckMode: Boolean = false, + val steamRepairQueued: Boolean = false, /** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */ val mangoapp: Boolean = true, /** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */ @@ -139,6 +140,7 @@ class ModeSettingsActions( val onForceFullscreen: (Boolean) -> Unit = {}, val onStretch16x9: (Boolean) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, + val onSteamRepair: () -> Unit = {}, val onSteamDeckMode: (Boolean) -> Unit = {}, val onMangoapp: (Boolean) -> Unit = {}, val onSteamController: (String) -> Unit = {}, @@ -368,6 +370,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { note = stringResource(R.string.mode_branch_note), onPick = a.onSteamChannel, ) + SettingsRow( + stringResource(R.string.mode_steam_repair), + stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint), + ) { + SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair) + } } if (steam && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { val hint = when { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11c794fc0..be062ba1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -291,6 +291,10 @@ Public beta Steam Deck public beta Deck mode always uses Steam Deck public beta. + Repair Steam client + Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept. + The Steam client will be downloaded again the next time Steam starts. + Repair Added games no game folders with a .exe found Games folder diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 838a58136..91465d661 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -775,6 +775,10 @@ BUSCONF echo "== steam: client branch on disk was '$(cat "$steam_beta" 2>/dev/null)', set to '$steam_channel' to match -clientbeta" echo "$steam_channel" > "$steam_beta" fi + if [ -z "${_bl_counted:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then + echo start >> "$steam_root/package/droiddeck-unconfirmed" + _bl_counted=1 + fi step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}" "$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" -clientbeta "$steam_channel" \ -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \ diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install index a5722f7d0..d2f59ed12 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install @@ -12,9 +12,33 @@ manifest=steam_client_${steam_channel}_linuxarm64 steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam stamp=$steam_root/package/droiddeck-installed legacy_stamp=$steam_root/package/bannerlator-installed +unconfirmed=$steam_root/package/droiddeck-unconfirmed +repair_request=$HOME/.bl-steam-repair if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then mv -f "$legacy_stamp" "$stamp" || true fi +repair="" +if [ -e "$repair_request" ]; then + repair="requested" +elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \ + && [ "$(grep -cx start "$unconfirmed")" -ge 2 ]; then + repair="the client was started $(grep -cx start "$unconfirmed") times without showing anything" +fi +if [ -n "$repair" ]; then + echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)" + rm -f "$stamp" "$legacy_stamp" "$steam_root/.crash" "$steam_root/steam.pid" \ + "$steam_root"/package/steam_client_*.manifest "$steam_root"/package/steam_client_*.installed + for part in "$steam_root"/package/*.zip.*; do + [ -f "$part" ] || continue + sum=${part##*.} + sum=${sum%%_*} + case $sum in + *[!0-9a-f]*|"") continue ;; + esac + [ ${#sum} -eq 40 ] || continue + [ "$(sha1sum "$part" | cut -d' ' -f1)" = "$sum" ] || rm -f "$part" + done +fi [ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0 work=$(mktemp -d) @@ -68,3 +92,7 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64" ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64" ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32" echo "$version" > "$stamp" +if [ -n "$repair" ]; then + echo repaired > "$unconfirmed" + rm -f "$repair_request" +fi diff --git a/tools/tests/test_steam_install.py b/tools/tests/test_steam_install.py index 0d2052965..e62bba375 100644 --- a/tools/tests/test_steam_install.py +++ b/tools/tests/test_steam_install.py @@ -38,6 +38,10 @@ cp "$TEST_CDN/${2##*/}" "$4" sha = self.bin / 'sha256sum' sha.write_text('#!/bin/bash\nexec shasum -a 256 "$@"\n') sha.chmod(0o755) + if shutil.which('sha1sum') is None: + sha1 = self.bin / 'sha1sum' + sha1.write_text('#!/bin/bash\nexec shasum -a 1 "$@"\n') + sha1.chmod(0o755) self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data), PATH=str(self.bin) + os.pathsep + os.environ['PATH'], TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests)) @@ -104,6 +108,65 @@ cp "$TEST_CDN/${2##*/}" "$4" self.assertEqual(0, self.install('publicbeta').returncode) self.assertEqual('publicbeta\n', (self.steam / 'package/beta').read_text()) + def break_client(self, starts): + self.assert_installed('steamdeck_publicbeta') + self.requests.unlink() + package = self.steam / 'package' + (self.steam / 'steamrtarm64/steam').write_text('half updated') + (package / 'steam_client_steamdeck_publicbeta_linuxarm64.manifest').write_text('manifest') + (package / 'steam_client_steamdeck_publicbeta_linuxarm64.installed').write_text('installed') + good = b'complete package' + (package / ('bins_linuxarm64.zip.vz.' + hashlib.sha1(good).hexdigest() + '_16')).write_bytes(good) + (package / ('bins_misc_linuxarm64.zip.vz.' + hashlib.sha1(b'whole').hexdigest() + '_5')).write_bytes(b'wh') + (package / ('strings_all.zip.' + hashlib.sha1(b'strings').hexdigest())).write_bytes(b'strings') + (self.steam / '.crash').write_text('') + (package / 'droiddeck-unconfirmed').write_text('start\n' * starts) + return package + + def test_two_starts_with_nothing_shown_fetch_the_client_again(self): + package = self.break_client(2) + self.assert_installed('steamdeck_publicbeta') + self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text()) + self.assertFalse((self.steam / '.crash').exists()) + self.assertEqual([], sorted(p.name for p in package.glob('steam_client_*'))) + self.assertEqual(['bins_linuxarm64.zip.vz.', 'strings_all.zip.'], + sorted(p.name[:p.name.rindex('.') + 1] for p in package.glob('*.zip.*'))) + + def test_one_start_with_nothing_shown_is_left_alone(self): + package = self.break_client(1) + self.assertEqual(0, self.install().returncode) + self.assertFalse(self.requests.exists()) + self.assertEqual('half updated', (self.steam / 'steamrtarm64/steam').read_text()) + self.assertTrue((package / 'steam_client_steamdeck_publicbeta_linuxarm64.installed').exists()) + + def test_a_repair_is_not_repeated_until_the_client_has_been_seen(self): + package = self.break_client(2) + self.assertEqual(0, self.install().returncode) + self.requests.unlink() + with (package / 'droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\nstart\n') + self.assertEqual(0, self.install().returncode) + self.assertFalse(self.requests.exists()) + + def test_requested_repair_runs_once_and_is_retried_until_it_completes(self): + package = self.break_client(0) + (package / 'droiddeck-unconfirmed').unlink() + request = self.home / '.bl-steam-repair' + request.write_text('1\n') + archive = self.cdn / 'steamdeck_publicbeta.zip' + original = archive.read_bytes() + archive.write_bytes(b'corrupt download') + self.assertNotEqual(0, self.install().returncode) + self.assertTrue(request.exists()) + self.assertFalse((package / 'droiddeck-installed').exists()) + archive.write_bytes(original) + self.requests.unlink() + self.assert_installed('steamdeck_publicbeta') + self.assertFalse(request.exists()) + self.requests.unlink() + self.assertEqual(0, self.install().returncode) + self.assertFalse(self.requests.exists()) + def test_invalid_channel_fails_before_any_download(self): result = self.install('../unknown') self.assertNotEqual(0, result.returncode) From eaa4a7a0472abc211364c3286f78dd9c48c463e1 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:32:30 -0400 Subject: [PATCH 3/9] Clarify display settings and organize in-session controls (#247) * Clarify display sizing, game fit and scaling controls * Replace aspect and resolution controls with one dimension menu * Organize Steam session settings into tabs * Keep session resolution out of the live controls panel * Group live session controls and add a conditional second-screen tab * Refine session sliders, tab icons and contextual controls * Simplify screen effects with presets and collapsed advanced controls * Give in-session tab icons more spacing * Center the loading cancel button glyph --- .../com/droiddeck/launcher/MainActivity.kt | 15 +- .../com/droiddeck/launcher/SessionActivity.kt | 56 +-- .../droiddeck/launcher/core/DeviceReport.kt | 6 +- .../launcher/session/SessionDisplay.kt | 53 +++ .../launcher/session/SessionPrefs.kt | 74 ++-- .../launcher/ui/GameEnvironmentEditor.kt | 4 +- .../launcher/ui/ModeSettingsDialog.kt | 292 +++++++------ .../droiddeck/launcher/ui/ResolutionRow.kt | 39 ++ .../launcher/ui/SessionDrawerPage.kt | 19 + .../droiddeck/launcher/ui/SessionLoading.kt | 9 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 411 +++++++++++------- .../droiddeck/launcher/ui/SettingsWidgets.kt | 104 +++-- app/src/main/res/values/strings.xml | 28 +- .../launcher/session/SessionDisplayTest.kt | 58 +++ .../session/SessionScalingPrefsTest.kt | 63 +++ .../launcher/ui/SessionDrawerPageTest.kt | 27 ++ 16 files changed, 831 insertions(+), 427 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index e2e82449c..41e8241ba 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -306,13 +306,11 @@ class MainActivity : ComponentActivity() { } // The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings(). private var settingsMode by mutableStateOf(null) - private var resolutionCap by mutableStateOf(1080) - private var customResolution by mutableStateOf?>(null) + private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION) private var fexPreset by mutableStateOf("") private var steamChannel by mutableStateOf("steamdeck_publicbeta") private var runSteamAtStartup by mutableStateOf(false) private var theme by mutableStateOf("graphite") - private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO) private var hdrOn by mutableStateOf(false) private var fpsLimit by mutableStateOf(0) private var upscaler by mutableStateOf(0) @@ -1119,7 +1117,8 @@ class MainActivity : ComponentActivity() { private fun ModeSettingsHost(mode: String) { ModeSettingsPage( ModeSettings( - mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode, + mode = mode, resolution = resolution, + panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this), hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit, upscaler = upscaler, upscaleSharpness = upscaleSharpness, gpuDrivers = drivers.summary(), @@ -1175,9 +1174,7 @@ class MainActivity : ComponentActivity() { } }, onWifiDiscoverySettings = { openWifiDiscoverySettings() }, - onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap }, - onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size }, - onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape }, + onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value }, onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on }, onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) }, onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps }, @@ -1333,8 +1330,7 @@ class MainActivity : ComponentActivity() { showProtons = false showComponents = false showMapping = false - resolutionCap = SessionPrefs.resolutionCap(this, mode) - customResolution = SessionPrefs.customResolution(this, mode) + resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) fexPreset = SessionPrefs.fexPreset(this) fastSync = SessionPrefs.fastSync(this) fsyncFirst = SessionPrefs.fsyncFirst(this) @@ -1346,7 +1342,6 @@ class MainActivity : ComponentActivity() { steamController = SessionPrefs.steamController(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) addedGamesDirs = SessionPrefs.addedGamesDirs(this) - shapeMode = SessionPrefs.shapeMode(this) hdrOn = SessionPrefs.hdr(this, mode) fpsLimit = SessionPrefs.fpsLimit(this, mode) upscaler = SessionPrefs.upscaler(this) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index abbe696e9..8ceab7bb7 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -83,7 +83,9 @@ import com.droiddeck.launcher.wayland.CompositorHost import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import com.droiddeck.launcher.session.ComponentsManager -import com.droiddeck.launcher.ui.DRAWER_PAGES +import com.droiddeck.launcher.ui.SessionDrawerPage +import com.droiddeck.launcher.ui.sessionDrawerPages +import com.droiddeck.launcher.ui.step import kotlin.math.abs /** @@ -206,7 +208,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var closeAtOnce = false /** The drawer's Components tab: the Protons as last read (ComponentsManager). */ private var drawerComponents by mutableStateOf(null) - private var drawerPage by mutableIntStateOf(0) + private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER) private var drawerControllerActive by mutableStateOf(false) private var backActionsInverted by mutableStateOf(false) /** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */ @@ -226,7 +228,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var fexPreset by mutableStateOf("") private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) - private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO) + private var onScreenButtonsVisible by mutableStateOf(false) private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode) private var secondScreenDisplays by mutableStateOf>(emptyList()) private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay) @@ -475,9 +477,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias, frameGen = frameGen, lossless = lossless, - oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode, + oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode, touchAuto = if (usingTouchpad()) "touchpad" else "direct", - shapeMode = shapeMode, fexPreset = fexPreset, + fexPreset = fexPreset, secondScreenMode = secondScreenMode, secondScreenDisplays = secondScreenDisplays, selectedSecondScreenDisplay = selectedSecondScreenDisplay, @@ -529,7 +531,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionService.suspendPolicyChanged(this@SessionActivity) }, onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() }, - onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() }, onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, onSecondScreenMode = ::selectSecondScreenMode, onSecondScreenDisplay = ::selectSecondScreenDisplay, @@ -757,7 +758,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { fexPreset = SessionPrefs.fexPreset(this) suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) oscMode = SessionPrefs.oscMode(this) - shapeMode = SessionPrefs.shapeMode(this) backActionsInverted = SessionPrefs.backActionsInverted(this) } @@ -1013,38 +1013,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun outputSize(): Pair { - val bounds = panelBounds() - val panelW = maxOf(bounds.width(), bounds.height()).toFloat() - val panelH = minOf(bounds.width(), bounds.height()).toFloat() - // Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a - // square display draws for the frame it was made for and cuts the sides off itself. - // Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the - // panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the - // compositor letterboxes onto a squarer panel. - // "Match screen" drops that floor, for a 4:3 or 3:2 handheld whose games should - // fill it. - val aspect = when (SessionPrefs.shapeMode(this)) { - SessionPrefs.SHAPE_WIDE -> 16f / 9f - SessionPrefs.SHAPE_EXACT -> panelW / panelH - else -> maxOf(panelW / panelH, 16f / 9f) - } - // 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest - // thing in the session, and pixels above that cost frames for nothing anyone can see on a - // handheld panel. The mode's settings (the cog beside Play / Desktop) can change - // the cap or lift it to the panel. val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) - // A custom resolution is taken as given; the compositor fits it to the panel. - SessionPrefs.customResolution(this, mode)?.let { return it } - // An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel, - // unless the user chose a resolution for the mode. + val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this) val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) - val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN && - Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0 - else SessionPrefs.resolutionCap(this, mode) - val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt() - val width = (height * aspect).toInt() - // Odd sizes upset the scaler; both dimensions even is what every mode here would be. - return Pair(width and 1.inv(), height and 1.inv()) + // Lightweight emulators keep their panel-sized default until a resolution is chosen. + val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN && + Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) + com.droiddeck.launcher.session.SessionDisplay.MATCH_SCREEN + else SessionPrefs.resolutionChoice(this, mode, panel) + return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, choice) } /** @@ -1262,7 +1239,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (drawerOpen) { if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES + drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty()) + .step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1) releaseDrawerDirection() } return true @@ -1715,6 +1693,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ?: SessionPrefs.oscMode(this) val controls = onScreenControls ?: return if (pipUi) { + onScreenButtonsVisible = false controls.releaseAll() controls.visibility = View.GONE return @@ -1730,6 +1709,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // started from the rail, or Steam, gets it; the drawer turns it on anywhere. else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP } + onScreenButtonsVisible = show if (show == (controls.visibility == View.VISIBLE)) return if (!show) controls.releaseAll() controls.visibility = if (show) View.VISIBLE else View.GONE 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 f95c53df8..48992e1eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -108,9 +108,9 @@ object DeviceReport { h("Display") k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" }) k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz)) - k("Shape setting", SessionPrefs.shapeMode(context)) - k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off") - k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off") + val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context) + k("Resolution (Steam)", SessionPrefs.resolutionChoice(context, SessionService.MODE_STEAM, panelSize)) + k("Resolution (Desktop)", SessionPrefs.resolutionChoice(context, SessionService.MODE_DESKTOP, panelSize)) k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) h("Drivers") diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt new file mode 100644 index 000000000..f1a342e1e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.view.WindowManager + +/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */ +object SessionDisplay { + const val MATCH_SCREEN = "screen" + const val DEFAULT_RESOLUTION = "1280x720" + + 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): Pair = + if (choice == MATCH_SCREEN) screenSize(panel) else { + // Legacy displays may exceed the custom dialog's size limit. Keep their exact size. + val parts = choice.split('x').map { it.toIntOrNull() } + if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!! + else 1280 to 720 + } + + /** Fixed dimensions and the current panel, with no duplicate panel-size preset. */ + fun resolutionOptions(panel: Pair): List = + listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080") + .filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + + fun panelSize(context: Context): Pair { + val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + @Suppress("DEPRECATION") + val mode = manager.defaultDisplay.mode + return maxOf(mode.physicalWidth, mode.physicalHeight) to minOf(mode.physicalWidth, mode.physicalHeight) + } + + /** Interpret an older cap/aspect combination without altering the saved picture size. */ + fun resolve(panel: Pair, cap: Int, shape: String, custom: Pair? = null): Pair { + custom?.let { return it } + val panelW = maxOf(panel.first, panel.second).toFloat() + val panelH = minOf(panel.first, panel.second).toFloat().coerceAtLeast(1f) + // Auto retains wide panels but floors squarer ones at 16:9 for game compatibility. + // Match screen removes that floor; fixed 16:9 also keeps foldable sessions stable. + val aspect = when (shape) { + SessionPrefs.SHAPE_WIDE -> 16f / 9f + SessionPrefs.SHAPE_EXACT -> panelW / panelH + else -> maxOf(panelW / panelH, 16f / 9f) + } + val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt() + return ((height * aspect).toInt() and 1.inv()) to (height and 1.inv()) + } + + /** Mirrors gamescope's even-width 16:9 calculation, including near-16:9 rounding. */ + fun canStretch16x9(size: Pair): Boolean = + (size.second * 16 + 4) / 9 / 2 * 2 > size.first +} 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 cda903be3..220544604 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -88,27 +88,16 @@ object SessionPrefs { const val SHAPE_WIDE = "16:9" const val SHAPE_EXACT = "exact" - /** The choices the settings offer, in order. */ - val shapeChoices = listOf( - SHAPE_AUTO to "Auto (16:9+)", - SHAPE_EXACT to "Match screen", - SHAPE_WIDE to "Always 16:9", - ) - /** * The shape of the display the session presents: the panel's own (never narrower than 16:9), * exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its * panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's * display cannot change size once the session is up. */ - fun shapeMode(context: Context): String = + private fun shapeMode(context: Context): String = prefs(context).getString("shape", null) ?: if (context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) SHAPE_WIDE else SHAPE_AUTO - fun setShapeMode(context: Context, mode: String) { - prefs(context).edit().putString("shape", mode).apply() - } - fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO fun setOscMode(context: Context, mode: String) { @@ -512,11 +501,33 @@ object SessionPrefs { * and the emulators under it get the same GPU headroom. Read once, when the session's display * is sized; a cap the user chose wins over the default. */ - fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode)) + private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720) /** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */ fun resolutionChosen(context: Context, mode: String): Boolean = - prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null + prefs(context).contains("displayResolution.$mode") || + prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null + + /** One per-mode choice now owns both dimensions; old caps/shapes are read only for migration. */ + 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) return value + parseResolution(value)?.let { return "${it.first}x${it.second}" } + } + if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION + val legacy = SessionDisplay.resolve(panel, resolutionCap(context, mode), shapeMode(context), customResolution(context, mode)) + return if (legacy == SessionDisplay.screenSize(panel)) SessionDisplay.MATCH_SCREEN + else "${legacy.first}x${legacy.second}" + } + + fun setResolutionChoice(context: Context, mode: String, choice: String) { + val value = if (choice == SessionDisplay.MATCH_SCREEN) choice else { + val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" } + "${size.first}x${size.second}" + } + prefs(context).edit().putString("displayResolution.$mode", value).apply() + } /** * The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. @@ -596,25 +607,13 @@ object SessionPrefs { prefs(context).edit().putString("theme", id).apply() } - /** What a mode gets when nothing was chosen. */ - @Suppress("UNUSED_PARAMETER") - fun defaultResolutionCap(mode: String): Int = 720 - - fun setResolutionCap(context: Context, mode: String, cap: Int) { - prefs(context).edit().putInt("resolutionCap.$mode", cap).apply() - } - /** * A fixed size for the session's display, per mode, or null. When set it replaces both the * cap and the shape: the compositor fits it to the panel with bars where the shapes differ. */ - fun customResolution(context: Context, mode: String): Pair? = + private fun customResolution(context: Context, mode: String): Pair? = parseResolution(prefs(context).getString("customRes.$mode", null)) - fun setCustomResolution(context: Context, mode: String, size: Pair?) { - prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply() - } - /** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */ fun parseResolution(text: String?): Pair? { val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null @@ -669,20 +668,27 @@ object SessionPrefs { /** * How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's - * modes): Off and Linear both filter bilinearly, Nearest keeps pixels square for 2D and old - * titles, the rest sharpen where the picture is enlarged; FSR (fit) rounds the picture to - * FSR's preferred size first. Sharpen only works at any size. + * modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work + * when enlarged; Sharpen only works at any size. The old Off/Linear and FSR/FSR Fit pairs + * are equivalent on Wayland, so saved aliases resolve to one choice. */ val upscalerChoices = listOf( - 0 to "Off", 1 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 5 to "AMD FSR 1 (fit)", 3 to "Snapdragon GSR", + 0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR", 8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only", ) - fun upscaler(context: Context): Int = - prefs(context).getInt("upscaler", 0).takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0 + fun canonicalUpscaler(mode: Int): Int = when (mode) { + 1 -> 0 + 5 -> 4 + else -> mode.takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0 + } + + fun upscalerHasSharpness(mode: Int): Boolean = canonicalUpscaler(mode) in 3..8 + + fun upscaler(context: Context): Int = canonicalUpscaler(prefs(context).getInt("upscaler", 0)) fun setUpscaler(context: Context, mode: Int) { - prefs(context).edit().putInt("upscaler", mode).apply() + prefs(context).edit().putInt("upscaler", canonicalUpscaler(mode)).apply() } fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt index c11a3cf0a..8dbaff287 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -52,13 +52,13 @@ import kotlinx.coroutines.withContext // dialog, with each variable's actions in a menu off its value as everywhere else. @Composable -fun GameEnvironmentRow(modifier: Modifier = Modifier) { +fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) { var open by remember { mutableStateOf(false) } // Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and // would otherwise give the pad nothing to move from. var byPad by remember { mutableStateOf(false) } val inputMode = LocalInputModeManager.current - SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) { + SettingsRow(stringResource(R.string.game_env_title), if (showHint) stringResource(R.string.game_env_hint) else null) { SecondaryButton(stringResource(R.string.game_env_edit), modifier = modifier) { byPad = inputMode.inputMode == InputMode.Keyboard open = true 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 36997decf..8b14ec19f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -43,10 +43,8 @@ class DownloadRow(val key: String, val label: String, val detail: String, val pr class ModeSettings( val mode: String, - val resolutionCap: Int, - /** A fixed session size, or null for the cap and shape. */ - val customResolution: Pair? = null, - val shapeMode: String, + val resolution: String, + val panelSize: Pair = 1280 to 720, val hdr: Boolean, val hdrReason: String?, /** The GPU drivers in use, as the row that opens them on the Components page says it. */ @@ -74,7 +72,7 @@ class ModeSettings( val fexPreset: String? = null, /** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */ val syncBackend: String? = null, - /** Steam only: games are stretched to fill the screen (null = not a Steam page). */ + /** Steam only: forces game windows fullscreen (null = not a Steam page). */ val forceFullscreen: Boolean? = null, val stretch16x9: Boolean? = null, /** Steam only: the client branch forced on the command line. */ @@ -113,10 +111,7 @@ class ModeSettings( class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List>) class ModeSettingsActions( - val onResolution: (Int) -> Unit, - /** Null clears it. */ - val onCustomResolution: (Pair?) -> Unit = {}, - val onShape: (String) -> Unit, + val onResolution: (String) -> Unit, val onHdr: (Boolean) -> Unit, /** Opens the GPU drivers on the Components page: they are shared by every session. */ val onGpuDrivers: () -> Unit = {}, @@ -160,139 +155,156 @@ class ModeSettingsActions( val onDismiss: () -> Unit, ) +private enum class SteamSettingsTab(val label: Int) { + DISPLAY(R.string.drawer_page_display), + CONTROLS(R.string.drawer_page_controls), + GAMES(R.string.game_settings_title), + AUDIO(R.string.mode_audio), + SESSION(R.string.mode_session), + STEAM(R.string.mode_tab_steam), +} + @Composable fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { val steam = s.mode == SessionService.MODE_STEAM val host = rememberMenuHost() var confirmDeckyRemoval by remember { mutableStateOf(false) } var explainWifiDiscovery by remember { mutableStateOf(false) } - val pageScroll = androidx.compose.foundation.rememberScrollState() + var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) } + val tabs = SteamSettingsTab.entries + val tab = tabs[tabIndex] + val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() } + val tabFocus = remember { tabs.map { androidx.compose.ui.focus.FocusRequester() } } val firstChip = remember { androidx.compose.ui.focus.FocusRequester() } - androidx.compose.runtime.LaunchedEffect(Unit) { - // One frame first: the box has to be laid out before it can take focus. Opened from the - // cog, focus starts on the first control (Resolution) so the d-pad works at once. + fun selectTab(index: Int) { host.open = null; tabIndex = index } + androidx.compose.runtime.LaunchedEffect(steam, tabIndex) { androidx.compose.runtime.withFrameNanos { } - runCatching { firstChip.requestFocus() } + runCatching { + if (!steam || tab == SteamSettingsTab.DISPLAY) firstChip.requestFocus() + else tabFocus[tabIndex].requestFocus() + } } SettingsPage( host, title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title), onBack = a.onDismiss, - scroll = pageScroll, + scroll = scrolls[tabIndex], + modifier = if (steam) Modifier.bumpers( + onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) }, + onNext = { selectTab((tabIndex + 1) % tabs.size) }, + ) else Modifier, + header = if (steam) ({ + TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), focusRequesters = tabFocus) + }) else null, ) { - SettingsGroup(stringResource(R.string.mode_display)) { - val default = SessionPrefs.defaultResolutionCap(s.mode) - var editCustom by remember { mutableStateOf(false) } - val custom = s.customResolution - ChoiceRow( - host, "res", stringResource(R.string.mode_resolution), stringResource(R.string.common_applies_next_session), - listOf(720 to stringResource(R.string.mode_res_720), 900 to stringResource(R.string.mode_res_900), 1080 to stringResource(R.string.mode_res_1080), 0 to stringResource(R.string.mode_res_native)) - .map { (cap, label) -> cap to (if (cap == default) stringResource(R.string.mode_res_default, label) else label) } + - (CUSTOM to (custom?.let { stringResource(R.string.mode_res_custom_value, it.first, it.second) } ?: stringResource(R.string.mode_res_custom))), - if (custom != null) CUSTOM else s.resolutionCap, note = stringResource(R.string.mode_res_note), - chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip), - onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } }, - ) - ChoiceRow( - host, "shape", stringResource(R.string.mode_ratio), - if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto), - com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape, - ) - ChoiceRow( - host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session), - com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit, - note = stringResource(R.string.mode_fps_note), - onPick = a.onFpsLimit, - ) - ChoiceRow( - host, "upscaler", stringResource(R.string.drawer_scaling), "How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.", - com.droiddeck.launcher.session.SessionPrefs.upscalerChoices, s.upscaler, - note = "The sharpening modes work only when the session is smaller than the screen; Linear, Nearest and Sharpen only work at any size. Costs a little GPU time.", - onPick = a.onUpscaler, - ) - SliderRow( - stringResource(R.string.drawer_scaling_sharpness), null, s.upscaleSharpness, 0..100, step = 5, - enabled = s.upscaler != 0, format = { "$it%" }, onChange = a.onUpscaleSharpness, - ) - if (editCustom) CustomResolutionDialog( - initial = custom, - onSave = { size -> editCustom = false; a.onCustomResolution(size) }, - onDismiss = { editCustom = false }, - ) - } - SettingsGroup(stringResource(R.string.mode_hdr)) { - ToggleRow( - host, "hdr", stringResource(R.string.mode_hdr10), - s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) } - ?: stringResource(R.string.mode_hdr_restart), - checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr, - ) - } - SettingsGroup(stringResource(R.string.mode_drivers)) { - SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) { - ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() } + if (!steam || tab == SteamSettingsTab.DISPLAY) { + val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution) + SettingsGroup(stringResource(R.string.display_session)) { + ResolutionRow(host, s.resolution, s.panelSize, a.onResolution, + chipModifier = Modifier.focusRequester(firstChip)) + if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) || + com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) { + val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize) + 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), + listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)), + s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9) + } + } + SettingsGroup(stringResource(R.string.display_image_scaling)) { + ChoiceRow( + host, "upscaler", stringResource(R.string.display_filter), null, + SessionPrefs.upscalerChoices, s.upscaler, + note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler, + ) + if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow( + stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5, + format = { "$it%" }, onChange = a.onUpscaleSharpness, + ) + } + SettingsGroup(stringResource(R.string.display_frame_rate)) { + ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session), + SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit) + } + SettingsGroup(stringResource(R.string.mode_hdr)) { + ToggleRow( + host, "hdr", stringResource(R.string.mode_hdr10), + s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) } + ?: stringResource(R.string.mode_hdr_restart), + checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr, + ) + } + SettingsGroup(stringResource(R.string.mode_drivers)) { + SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) { + ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() } + } } } - SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) { - ChoiceRow( - host, "touch", stringResource(R.string.mode_touch), null, - listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode, - note = stringResource(R.string.mode_touch_note), - onPick = a.onTouch, - ) - if (steam && s.oscMode != null) ChoiceRow( - host, "osc", stringResource(R.string.mode_osc), null, - listOf( - SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), - SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), - SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), - SessionPrefs.OSC_NEVER to stringResource(R.string.common_never), - ), s.oscMode, - note = stringResource(R.string.mode_osc_note), onPick = a.onOsc, - ) - if (steam && s.steamController != null) ChoiceRow( - host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint), - listOf( - SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck), - SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360), - ), s.steamController, - note = stringResource(R.string.mode_controller_note), - onPick = a.onSteamController, - ) - if (steam) ChoiceRow( - host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted), - listOf( - false to SessionPrefs.BACK_MENU_THEN_QAM, - true to SessionPrefs.BACK_QAM_THEN_MENU, - ), s.backActionsInverted, onPick = a.onBackActionsInverted, - ) + if (!steam || tab == SteamSettingsTab.CONTROLS) { + SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) { + ChoiceRow( + host, "touch", stringResource(R.string.mode_touch), null, + listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode, + note = stringResource(R.string.mode_touch_note), + onPick = a.onTouch, + ) + if (steam && s.oscMode != null) ChoiceRow( + host, "osc", stringResource(R.string.mode_osc), null, + listOf( + SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), + SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), + SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), + SessionPrefs.OSC_NEVER to stringResource(R.string.common_never), + ), s.oscMode, + note = stringResource(R.string.mode_osc_note), onPick = a.onOsc, + ) + if (steam && s.steamController != null) ChoiceRow( + host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint), + listOf( + SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck), + SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360), + ), s.steamController, + note = stringResource(R.string.mode_controller_note), + onPick = a.onSteamController, + ) + if (steam) ChoiceRow( + host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted), + listOf( + false to SessionPrefs.BACK_MENU_THEN_QAM, + true to SessionPrefs.BACK_QAM_THEN_MENU, + ), s.backActionsInverted, onPick = a.onBackActionsInverted, + ) + } } - SettingsGroup(stringResource(R.string.mode_session)) { - ChoiceRow( - host, "suspend", stringResource(R.string.mode_suspend), - stringResource(R.string.mode_suspend_hint), - listOf( - SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto), - SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual), - SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never), - ), - s.suspendPolicy, - note = stringResource(R.string.mode_suspend_note), - onPick = a.onSuspendPolicy, - ) + if (!steam || tab == SteamSettingsTab.SESSION) { + SettingsGroup(stringResource(R.string.mode_session)) { + ChoiceRow( + host, "suspend", stringResource(R.string.mode_suspend), + stringResource(R.string.mode_suspend_hint), + listOf( + SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto), + SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual), + SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never), + ), + s.suspendPolicy, + note = stringResource(R.string.mode_suspend_note), + onPick = a.onSuspendPolicy, + ) + } + if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) { + ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, + s.pipAutoEnter, onChange = a.onPipAutoEnter) + } + if (steam) SettingsGroup(stringResource(R.string.mode_startup)) { + ToggleRow( + host, "steam-startup", stringResource(R.string.mode_steam_startup), + stringResource(R.string.mode_steam_startup_hint), + s.runSteamAtStartup, onChange = a.onRunSteamAtStartup, + ) + } } - if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) { - ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, - s.pipAutoEnter, onChange = a.onPipAutoEnter) - } - if (steam) SettingsGroup(stringResource(R.string.mode_startup)) { - ToggleRow( - host, "steam-startup", stringResource(R.string.mode_steam_startup), - stringResource(R.string.mode_steam_startup_hint), - s.runSteamAtStartup, onChange = a.onRunSteamAtStartup, - ) - } - if (steam) SettingsGroup(stringResource(R.string.mode_decky)) { + if (steam && tab == SteamSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) { val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null && s.deckyInstalled != s.deckyLatestRelease.tag val status = when { @@ -351,7 +363,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } - if (steam && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) { + if (steam && tab == SteamSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) { ToggleRow( host, "steamdeck", stringResource(R.string.mode_deck_mode), stringResource(R.string.mode_deck_mode_hint), @@ -377,7 +389,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair) } } - if (steam && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { + if (steam && tab == SteamSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { val hint = when { s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off) !s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied) @@ -396,7 +408,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { } } } - if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) { + if ((!steam || tab == SteamSettingsTab.GAMES) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) { + ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen), + stringResource(R.string.display_force_fullscreen_hint), s.forceFullscreen, onChange = a.onForceFullscreen) + } + if (steam && tab == SteamSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) { for (dir in s.addedGamesDirs) { val n = s.addedGames.count { it.folderPath.startsWith("$dir/") } ActionRow( @@ -421,7 +437,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) }, ) } - if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) { + if (steam && tab == SteamSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) { if (s.syncBackend != null) SettingsRow( stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint), @@ -442,18 +458,9 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset, ) GameEnvironmentRow() - if (s.forceFullscreen != null) ToggleRow( - host, "fill", stringResource(R.string.mode_fill), - stringResource(R.string.mode_fill_hint), - s.forceFullscreen, onChange = a.onForceFullscreen, - ) - if (s.stretch16x9 != null) ToggleRow( - host, "stretch169", stringResource(R.string.mode_stretch169), - stringResource(R.string.mode_stretch169_hint), - s.stretch16x9, onChange = a.onStretch16x9, - ) + } - if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) { + if (steam && tab == SteamSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) { ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio) ChoiceRow( host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint), @@ -462,7 +469,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic) } - if (steam && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) { + if (steam && tab == SteamSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) { val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage } val options = buildList { add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto))) @@ -531,13 +538,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } -/** The Resolution menu's "Custom…" entry. */ -private const val CUSTOM = -1 - /** Width × height for the session, with the common handheld shapes one tap away. */ @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable -private fun CustomResolutionDialog(initial: Pair?, onSave: (Pair) -> Unit, onDismiss: () -> Unit) { +internal fun CustomResolutionDialog(initial: Pair?, onSave: (Pair) -> Unit, onDismiss: () -> Unit) { var w by remember { mutableStateOf(initial?.first?.toString() ?: "") } var h by remember { mutableStateOf(initial?.second?.toString() ?: "") } val parsed = SessionPrefs.parseResolution("${w}x$h") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt b/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt new file mode 100644 index 000000000..429ba1bb9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt @@ -0,0 +1,39 @@ +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.res.stringResource +import com.droiddeck.launcher.R +import com.droiddeck.launcher.session.SessionDisplay + +/** Pre-session resolution choice, applied when the session starts. */ +@Composable +fun ResolutionRow( + host: MenuHost, choice: String, panel: Pair, onPick: (String) -> Unit, + chipModifier: Modifier = Modifier, +) { + val custom = "custom" + val size = SessionDisplay.resolveChoice(panel, choice) + val screen = SessionDisplay.screenSize(panel) + val choices = SessionDisplay.resolutionOptions(panel) + val selected = if (size == screen) SessionDisplay.MATCH_SCREEN else choice + var editCustom by remember { mutableStateOf(false) } + val options = choices.map { value -> + val dimensions = SessionDisplay.resolveChoice(panel, value) + value to if (value == SessionDisplay.MATCH_SCREEN) stringResource(R.string.display_match_screen, screen.first, screen.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) + 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) }) + if (editCustom) CustomResolutionDialog(size, + onSave = { value -> editCustom = false; onPick("${value.first}x${value.second}") }, + onDismiss = { editCustom = false }) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt new file mode 100644 index 000000000..0955651cc --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt @@ -0,0 +1,19 @@ +package com.droiddeck.launcher.ui + +/** Stable page identities keep focus and scroll positions when a secondary display comes and goes. */ +internal enum class SessionDrawerPage(val firstControl: String) { + CONTROLLER("touch"), + SECOND_SCREEN("second-screen:none"), + DISPLAY("hud"), + EFFECTS("look"), + GAMES("anisotropy"), + SESSION("suspend"), +} + +internal fun sessionDrawerPages(hasSecondScreen: Boolean): List = + SessionDrawerPage.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN } + +internal fun List.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage { + val index = indexOf(current).coerceAtLeast(0) + return this[Math.floorMod(index + direction, size)] +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index 5a3bb4e0d..d3d1df388 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -70,6 +70,9 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextOverflow // The session's loading screen, its paused screen and the HUD text style. @@ -273,7 +276,11 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) { .padding(horizontal = 12.dp), ) { Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) { - Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background) + Text("B", color = colors.background, style = TextStyle( + fontSize = 12.sp, lineHeight = 12.sp, fontWeight = FontWeight.Bold, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both), + )) } Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant) } 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 5d0f851d2..9280845b5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -28,15 +28,24 @@ import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.heightIn import androidx.compose.material.icons.outlined.AutoFixHigh +import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.MoreHoriz import androidx.compose.material.icons.outlined.PowerSettingsNew import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.ScreenShare +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.automirrored.outlined.ExitToApp import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material3.Icon +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.ui.semantics.Role import androidx.compose.ui.draw.clipToBounds import android.view.Display import androidx.activity.compose.BackHandler @@ -79,6 +88,8 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.semantics.stateDescription import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally @@ -93,7 +104,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -129,22 +139,33 @@ import com.droiddeck.launcher.input.SecondScreenMode import kotlinx.coroutines.flow.collect import java.util.Locale -private val drawerPageTitles = listOf(R.string.drawer_page_display, R.string.drawer_page_effects, R.string.drawer_page_controls, R.string.drawer_page_components, R.string.drawer_page_settings) +private val SessionDrawerPage.title: Int get() = when (this) { + SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller + SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen + SessionDrawerPage.DISPLAY -> R.string.drawer_page_display + SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects + SessionDrawerPage.GAMES -> R.string.game_settings_title + SessionDrawerPage.SESSION -> R.string.mode_session +} -/** One icon per drawer page, in page order (QAM-style tabs). */ -private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.AutoFixHigh, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings) - -private val drawerPageEntries = listOf("hud", "look", "touch", "cmp-proton", "suspend") +private val SessionDrawerPage.icon get() = when (this) { + SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports + SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare + SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows + SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh + SessionDrawerPage.GAMES -> Icons.Outlined.Layers + SessionDrawerPage.SESSION -> Icons.Outlined.Settings +} private class DrawerFocus { private val requesters = HashMap() - private val last = arrayOfNulls(DRAWER_PAGES) + private val last = mutableMapOf() var focused by mutableStateOf(null) private set private fun requester(key: String) = requesters.getOrPut(key) { FocusRequester() } - fun track(page: Int, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged { + fun track(page: SessionDrawerPage, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged { if (it.isFocused) { last[page] = key focused = key @@ -152,9 +173,9 @@ private class DrawerFocus { } /** Where focus was last on [page], if anywhere. */ - fun target(page: Int) = last[page] + fun target(page: SessionDrawerPage) = last[page] fun request(key: String) = runCatching { requester(key).requestFocus() } - fun forget(page: Int) { last[page] = null } + fun forget(page: SessionDrawerPage) { last.remove(page) } } /** Everything the drawer shows and does. */ @@ -168,13 +189,13 @@ class DrawerActions( val frameGen: FrameGen.Mode, val lossless: Lossless.State, val oscMode: String, + val onScreenButtonsVisible: Boolean, val suspendPolicy: String, val backActionsInverted: Boolean, val touchMode: String, val touchAuto: String, - val shapeMode: String, val fexPreset: String, - /** Steam only: games stretched to the screen's size, changed live (null = not Steam). */ + /** Steam only: forces game windows fullscreen, changed live (null = not Steam). */ val fillScreen: Boolean? = null, val upscaler: Int = 0, val upscaleSharpness: Int = 75, @@ -204,7 +225,6 @@ class DrawerActions( val onSuspendPolicy: (String) -> Unit, val onBackActionsInverted: (Boolean) -> Unit, val onTouch: (String) -> Unit, - val onShape: (String) -> Unit, val onFexPreset: (String) -> Unit, val onFillScreen: (Boolean) -> Unit = {}, val onUpscaler: (Int) -> Unit = {}, @@ -225,7 +245,7 @@ class DrawerActions( /** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */ val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null, val onClose: () -> Unit, - /** Components tab: every Proton with what it uses; null until first read. */ + /** Games tab: every Proton with what it uses; null until first read. */ val components: ComponentsManager.Snapshot? = null, /** Re-reads the Protons (and runs swaps that waited for a game to close). */ val onComponentsRefresh: () -> Unit = {}, @@ -235,16 +255,20 @@ class DrawerActions( @OptIn(ExperimentalComposeUiApi::class) @Composable -fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageChange: (Int) -> Unit, a: DrawerActions) { +internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) { + val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty()) + val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER + LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) } val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val host = rememberMenuHost() var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } + var advancedEffectsExpanded by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } var confirmStop by remember { mutableStateOf(false) } var sheetCoords by remember { mutableStateOf(null) } var stopCoords by remember { mutableStateOf(null) } - val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } } + val pageScroll = remember { SessionDrawerPage.entries.associateWith { ScrollState(0) } } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") val focus = remember { DrawerFocus() } val inputModeManager = LocalInputModeManager.current @@ -254,13 +278,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } BackHandler(enabled = open && confirmStop) { confirmStop = false } LaunchedEffect(page) { host.open = null; appToChooseDisplay = null } - LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() } + LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() } LaunchedEffect(open, controllerActive) { if (open && !controllerActive) focusManager.clearFocus(force = true) } // The page focus was last put on: turning to another (LB, RB, a tab) starts it at its first // option; anything else (a menu closing, the drawer opening again) goes back where it was. - var focusedPage by remember { mutableIntStateOf(page) } + var focusedPage by remember { mutableStateOf(page) } LaunchedEffect(open, page, controllerActive, host.open, appToChooseDisplay, confirmStop) { if (!open) { host.open = null @@ -270,8 +294,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha if (host.open != null || appToChooseDisplay != null || confirmStop) return@LaunchedEffect val turned = page != focusedPage focusedPage = page - // The page's top control: on Settings that is Android apps, when it is shown. - val first = if (page == drawerPageEntries.lastIndex && a.isHomeApp) "apps" else drawerPageEntries[page] + // The page's top control: on Session that is Android apps, when it is shown. + val first = if (page == SessionDrawerPage.SESSION && a.isHomeApp) "apps" else page.firstControl if (turned) focus.forget(page) val target = focus.target(page) ?: first repeat(24) { @@ -350,31 +374,21 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha modifier = Modifier.fillMaxWidth().padding(top = if (short) 6.dp else 12.dp), ) { DrawerBumper("LB", stringResource(R.string.drawer_prev_page), modifier = focus.track(page, "prev")) { - host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES) + host.open = null; onPageChange(pages.step(page, -1)) } - DrawerPageTabs(page = page, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) } + DrawerPageTabs(page = page, pages = pages, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) } DrawerBumper("RB", stringResource(R.string.drawer_next_page), modifier = focus.track(page, "next")) { - host.open = null; onPageChange((page + 1) % DRAWER_PAGES) + host.open = null; onPageChange(pages.step(page, 1)) } } Column( - modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]), + modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll.getValue(page)), ) { CompositionLocalProvider(LocalChipMinWidth provides 120.dp) { when (page) { - 0 -> { + SessionDrawerPage.DISPLAY -> { SettingsGroup(stringResource(R.string.drawer_page_display)) { ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn, chipModifier = focus.track(page, "hud"), onChange = a.onHud) - if (a.fillScreen != null) ToggleRow( - host, "fill", stringResource(R.string.drawer_fill), null, a.fillScreen, - chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen, - ) - ChoiceRow(host, "upscaler", stringResource(R.string.drawer_scaling), null, - SessionPrefs.upscalerChoices, a.upscaler, - chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler) - SliderRow(stringResource(R.string.drawer_scaling_sharpness), null, a.upscaleSharpness, 0..100, step = 5, - enabled = a.upscaler != 0, format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), - onChange = a.onUpscaleSharpness) val fgOpen = host.open == "fg" val fgLabel = FrameGen.label(LocalContext.current, a.frameGen) SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) { @@ -384,14 +398,27 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } + SettingsGroup(stringResource(R.string.display_image_scaling)) { + ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null, + SessionPrefs.upscalerChoices, a.upscaler, + note = stringResource(R.string.display_filter_note), + chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler) + if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow( + stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5, + format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness) + } + if (a.fillScreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) { + ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen), + stringResource(R.string.display_force_fullscreen_live), a.fillScreen, + chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen) + } } - DRAWER_PAGE_EFFECTS -> { - ScreenEffectsGroup(host, a) { key -> focus.track(page, key) } - TextureFilteringGroup(host, a) { key -> focus.track(page, key) } + SessionDrawerPage.EFFECTS -> { + ScreenEffectsGroup(host, a, advancedEffectsExpanded, + onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) } } - DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) } - DRAWER_PAGE_CONTROLS -> { - SettingsGroup(stringResource(R.string.drawer_page_controls)) { + SessionDrawerPage.CONTROLLER -> { + SettingsGroup(stringResource(R.string.drawer_page_controller)) { ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null, listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch) @@ -402,12 +429,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha a.controller?.let { c -> ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble, chipModifier = focus.track(page, "rumble"), onChange = a.onRumble) - ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton, - chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton) - ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton, - chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton) - ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, - chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton) + if (a.onScreenButtonsVisible) { + if (a.steam) { + ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton, + chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton) + ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton, + chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton) + } + ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, + chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton) + } } if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null, listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU), @@ -423,18 +454,23 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } - if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup(stringResource(R.string.drawer_second_screen)) { - ChoiceRow(host, "second-screen-mode", stringResource(R.string.drawer_second_screen_shows), null, - (listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) + - (if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList())) - .map { it to it.label }, - a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode) - if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null, - a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay, - chipModifier = focus.track(page, "second-screen-display"), onPick = a.onSecondScreenDisplay) - } } - else -> { + SessionDrawerPage.SECOND_SCREEN -> { + SecondScreenGroup(host, a) { key -> focus.track(page, key) } + } + SessionDrawerPage.GAMES -> { + Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp, + color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp)) + TextureFilteringGroup(host, a) { key -> focus.track(page, key) } + if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) { + ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null, + FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset, + chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset) + GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false) + } + ComponentsDrawerPage(host, a) { key -> focus.track(page, key) } + } + SessionDrawerPage.SESSION -> { if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) { SettingsRow(stringResource(R.string.drawer_launch_app), null) { DrawerOutlineButton(if (androidAppsExpanded) stringResource(R.string.common_hide) else stringResource(R.string.common_show), modifier = focus.track(page, "apps")) { @@ -466,7 +502,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha SettingsGroup(stringResource(R.string.drawer_session_behavior)) { ChoiceRow( host, "suspend", stringResource(R.string.mode_suspend), - stringResource(R.string.drawer_suspend_hint), + null, listOf( SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto), SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual), @@ -478,17 +514,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha onPick = a.onSuspendPolicy, ) } - SettingsGroup(stringResource(R.string.drawer_next_session)) { - ChoiceRow(host, "shape", stringResource(R.string.mode_ratio), null, - SessionPrefs.shapeChoices, a.shapeMode, - chipModifier = focus.track(page, "shape"), onPick = a.onShape) - } - if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) { - ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch), - FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset, - chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset) - GameEnvironmentRow(modifier = focus.track(page, "game-env")) - } SettingsGroup(stringResource(R.string.drawer_support)) { SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) { DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs")) { @@ -501,12 +526,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, a.pipAutoEnter, onChange = a.onPipAutoEnter) } - Spacer(Modifier.height(18.dp)) - if (a.pipSupported) DrawerOutlineButton(stringResource(R.string.pip_title), modifier = focus.track(page, "pip")) { - host.open = null; a.onPip() - } - if (!a.isHomeApp) DrawerOutlineButton(stringResource(R.string.drawer_background), modifier = focus.track(page, "background")) { - host.open = null; a.onBackground() + Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (a.pipSupported) DrawerActionButton(stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt, + modifier = focus.track(page, "pip")) { host.open = null; a.onPip() } + if (!a.isHomeApp) DrawerActionButton(stringResource(R.string.drawer_background), Icons.AutoMirrored.Outlined.ExitToApp, + modifier = focus.track(page, "background")) { host.open = null; a.onBackground() } } } } @@ -546,6 +570,27 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } +@Composable +private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val source = remember { MutableInteractionSource() } + val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value + val shape = RoundedCornerShape(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.fillMaxWidth().heightIn(min = 52.dp).clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) + .glideBorder(hot, shape, pal.signal) + .hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, onClick = onClick) + .controllerConfirm(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(22.dp)) + Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + } +} + @Composable private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme @@ -636,58 +681,60 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier /** * The drawer's page tabs, QAM-style: one icon per page standing in a line in page order. The * selected one steps forward - full size, bright, a soft glow - while the others step back to - * half size and fade, and the whole line leans a little toward the selection. LB / RB still turn + * a little smaller and fade, and the whole line leans a little toward the selection. LB / RB still turn * the page (the buttons beside it and the pad's bumpers); a tab can be tapped or picked with the * pad like the dots it replaces. */ @Composable -private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (Int) -> Unit) { +private fun DrawerPageTabs(page: SessionDrawerPage, pages: List, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (SessionDrawerPage) -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val count = drawerPageIcons.size + val count = pages.size + val selectedIndex = pages.indexOf(page) val middle = (count - 1) / 2f val motion = Motion.tw(340, easing = FastOutSlowInEasing) androidx.compose.foundation.layout.BoxWithConstraints( contentAlignment = Alignment.Center, modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(), ) { - val tab = if (compact) 40f else 56f + val tab = if (compact) 36f else 44f // Fit the line to the room between LB and RB. Worst case is an end tab selected: full size // ([tab] dp) and pushed outward by the lean, which scales with the spacing. So - // (count-1) * spacing * (1 + lean/64) + 56 <= width - // - never more than the mock's 64 dp, never tighter than 32 dp. - val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP) + // (count-1) * spacing * (1 + lean/spacingMax) + tab <= width + val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(24f, DRAWER_TAB_SPACING_DP) val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP - val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean") - drawerPageIcons.forEachIndexed { index, icon -> - val selected = index == page - val source = remember { MutableInteractionSource() } - val focused = source.collectIsFocusedAsState().value - val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale") - val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha") - val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow") - val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(drawerPageTitles[index])) - val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint") - val select = { onSelect(index) } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .offset(x = ((index - middle) * spacing + lean).dp) - .size(tab.dp) - .graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha } - .clip(RoundedCornerShape(16.dp)) - .background( - Brush.radialGradient( - listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent), - ), - ) - .glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground) - .semantics { contentDescription = pageDescription } - .hoverable(source) - .clickable(interactionSource = source, indication = LocalIndication.current, onClick = select) - .controllerConfirm(onClick = select), - ) { - Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp)) + val lean by animateFloatAsState(-(selectedIndex - middle) * leanStep, motion, label = "tabLean") + pages.forEachIndexed { index, item -> + androidx.compose.runtime.key(item) { + val selected = item == page + val source = remember { MutableInteractionSource() } + val focused = source.collectIsFocusedAsState().value + val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale") + val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha") + val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow") + val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(item.title)) + val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint") + val select = { onSelect(item) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset(x = ((index - middle) * spacing + lean).dp) + .size(tab.dp) + .graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha } + .clip(RoundedCornerShape(16.dp)) + .background( + Brush.radialGradient( + listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent), + ), + ) + .glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground) + .semantics { contentDescription = pageDescription } + .hoverable(source) + .clickable(interactionSource = source, indication = LocalIndication.current, onClick = select) + .controllerConfirm(onClick = select), + ) { + Icon(item.icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp)) + } } } } @@ -696,54 +743,114 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo /** The Look row's value when the rows below match no Look. */ private const val LOOK_CUSTOM = "custom" -/** - * Screen effects on the Effects page: a Look row that moves every row under it, then the rows - * themselves: switches and sliders the d-pad steps (SliderRow), so a pad drives them. - */ +/** Presets and simple effects first; detailed adjustments stay behind Advanced. */ @Composable -private fun ScreenEffectsGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { +private fun ScreenEffectsGroup( + host: MenuHost, a: DrawerActions, advancedExpanded: Boolean, onAdvancedToggle: () -> Unit, + track: (String) -> Modifier, +) { val e = a.effects - val look = ScreenEffectLooks.match(e, a.upscaler) + val preset = ScreenEffectLooks.match(e, a.upscaler) + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current SettingsGroup(stringResource(R.string.drawer_effects)) { - val looks = ScreenEffectLooks.LOOKS.map { it.name to it.name } - ChoiceRow(host, "look", stringResource(R.string.drawer_look), - look?.desc ?: stringResource(R.string.drawer_effects_hint), - if (look == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + looks else looks, - look?.name ?: LOOK_CUSTOM, chipModifier = track("look"), hintLines = 2) { name -> + val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name } + ChoiceRow(host, "look", stringResource(R.string.drawer_look), null, + if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets, + preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name -> ScreenEffectLooks.LOOKS.firstOrNull { it.name == name }?.let { picked -> picked.scalingMode?.let(a.onUpscaler) a.onEffects(picked.effects) } } - ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) } - SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5, enabled = e.cas, - format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) } - ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) } - ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) } - SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5, enabled = e.deband, - format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) } - SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2, - format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) } - SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2, - format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) } - SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5, - format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) } - SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5, - format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) } ToggleRow(host, "fxaa", stringResource(R.string.drawer_fxaa), null, e.fxaa, chipModifier = track("fxaa")) { a.onEffects(e.copy(fxaa = it)) } ToggleRow(host, "toon", stringResource(R.string.drawer_toon), null, e.toon, chipModifier = track("toon")) { a.onEffects(e.copy(toon = it)) } ToggleRow(host, "crt", stringResource(R.string.drawer_crt), null, e.crt, chipModifier = track("crt")) { a.onEffects(e.copy(crt = it)) } ToggleRow(host, "ntsc", stringResource(R.string.drawer_ntsc), null, e.ntsc, chipModifier = track("ntsc")) { a.onEffects(e.copy(ntsc = it)) } + ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) } + } + val source = remember { MutableInteractionSource() } + val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value + val rotation by animateFloatAsState(if (advancedExpanded) 180f else 0f, Motion.tw(180), label = "advancedArrow") + val description = stringResource(if (advancedExpanded) R.string.widgets_expanded else R.string.widgets_collapsed) + val toggle = { host.open = null; onAdvancedToggle() } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = track("effects-advanced").fillMaxWidth().padding(top = 12.dp).heightIn(min = 48.dp) + .glideBorder(hot, RoundedCornerShape(8.dp), pal.signal) + .hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = toggle) + .controllerConfirm(onClick = toggle).semantics { stateDescription = description }.padding(horizontal = 4.dp), + ) { + Text(stringResource(R.string.drawer_effects_advanced).uppercase(), fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant) + Box(Modifier.weight(1f).height(1.dp).background(pal.line)) + Icon(Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.onSurfaceVariant, + modifier = Modifier.size(22.dp).rotate(rotation)) + } + AnimatedVisibility(advancedExpanded) { + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(colors.surface).border(1.dp, pal.line, RoundedCornerShape(14.dp))) { + ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) } + if (e.cas) SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5, + format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) } + ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) } + if (e.deband) SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5, + format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) } + SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2, + format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) } + SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2, + format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) } + SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5, + format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) } + SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5, + format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) } + } } } -/** Texture filtering on the Effects page: DXVK options for the next DirectX 9-11 launch. */ +/** Secondary-display modes are mutually exclusive and apply immediately. */ +@Composable +private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { + val colors = MaterialTheme.colorScheme + 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() + SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) { + Column(Modifier.fillMaxWidth().selectableGroup()) { + modes.forEach { mode -> + val selected = a.secondScreenMode == mode + val source = remember { MutableInteractionSource() } + val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value + val pick = { host.open = null; a.onSecondScreenMode(mode) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = track("second-screen:${mode.id}").fillMaxWidth().heightIn(min = 56.dp) + .background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent) + .glideBorder(hot, RoundedCornerShape(8.dp), pal.signal) + .hoverable(source) + .selectable(selected, interactionSource = source, indication = null, role = Role.RadioButton, onClick = pick) + .controllerConfirm(onClick = pick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f)) + RadioButton(selected, onClick = null, + colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant)) + } + } + } + if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null, + a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay, + chipModifier = track("second-screen-display"), onPick = a.onSecondScreenDisplay) + } +} + +/** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */ @Composable private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { SettingsGroup(stringResource(R.string.drawer_texture)) { - ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), stringResource(R.string.drawer_texture_hint), + ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null, SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy) - ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), stringResource(R.string.drawer_texture_hint), + ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null, SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note), chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias) } @@ -751,16 +858,8 @@ private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (Stri private fun signed(v: Int) = if (v > 0) "+$v" else "$v" -/** Display, Effects, Controls, Components, Settings. */ -const val DRAWER_PAGES = 5 - -private const val DRAWER_PAGE_EFFECTS = 1 -private const val DRAWER_PAGE_CONTROLS = 2 - -private const val DRAWER_PAGE_COMPONENTS = 3 - /** - * The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap + * The Games tab's components: quick swaps between what is already installed, per Proton. A swap * into the Proton a running game uses waits until that game closes; downloading, importing and * deleting stay on the Components page in the app. */ @@ -884,14 +983,14 @@ private fun DrawerStackedChoice( Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } -/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */ -private const val DRAWER_TAB_SPACING_DP = 64f +/** A close row of icons: inactive tabs stay legible while the active tab leads. */ +private const val DRAWER_TAB_SPACING_DP = 48f -private const val DRAWER_TAB_LEAN_DP = 12f +private const val DRAWER_TAB_LEAN_DP = 8f -private const val DRAWER_TAB_SIDE_SCALE = 0.5f +private const val DRAWER_TAB_SIDE_SCALE = 0.8f -private const val DRAWER_TAB_SIDE_ALPHA = 0.7f +private const val DRAWER_TAB_SIDE_ALPHA = 0.8f /** Room between the Stop pill and the box that steps out under it: the pull. */ private val StepPull = 6.dp 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 dc8a345f1..64f56873e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.stateDescription import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.semantics.semantics +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -60,6 +61,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -82,6 +84,10 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.style.TextAlign import kotlin.math.roundToInt import androidx.compose.ui.input.InputMode @@ -468,8 +474,16 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul fun SliderRow( label: String, hint: String?, value: Int, range: IntRange, step: Int = 1, enabled: Boolean = true, format: (Int) -> String = { it.toString() }, modifier: Modifier = Modifier, onChange: (Int) -> Unit, -) = SettingsRow(label, hint) { - ValueSlider(value, range, step, enabled, label, format, modifier, onChange) +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Column(Modifier.fillMaxWidth()) { + ValueSlider(value, range, step, enabled, label, format, + modifier = modifier.padding(horizontal = 6.dp, vertical = 3.dp), onChange = onChange) + if (hint != null) Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 8.dp)) + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + } } @Composable @@ -479,34 +493,39 @@ fun ValueSlider( ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val source = remember { MutableInteractionSource() } + val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value + val currentOnChange by rememberUpdatedState(onChange) val span = (range.last - range.first).coerceAtLeast(1) val fraction = ((value - range.first).toFloat() / span).coerceIn(0f, 1f) - val shape = RoundedCornerShape(99.dp) + val thumbInset = with(LocalDensity.current) { 8.dp.toPx() } var trackPx by remember { mutableIntStateOf(0) } val text = format(value) - // A step from the d-pad lands on the grid of [step] from the range's start; a Look's own - // value, which need not be on it, is kept until the user moves. + val shape = RoundedCornerShape(8.dp) + // Preserve off-grid preset values until the user adjusts; every input snaps to the same step. fun stepped(v: Int) = (((v - range.first).toFloat() / step).roundToInt() * step + range.first).coerceIn(range.first, range.last) - fun fromX(x: Float) { if (trackPx > 0) onChange(stepped(range.first + (x / trackPx * span).roundToInt())) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .heightIn(min = 48.dp) + fun fromX(x: Float) { + val width = trackPx - 2 * thumbInset + if (width > 0) { + val position = ((x - thumbInset) / width).coerceIn(0f, 1f) + currentOnChange(stepped(range.first + (position * span).roundToInt())) + } + } + Column( + modifier = modifier.fillMaxWidth().heightIn(min = 72.dp) + .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent) + .glideBorder(hot, shape, pal.signal) .alpha(if (enabled) 1f else 0.5f) - .hoverable(src) - .focusable(enabled, src) + .hoverable(source).focusable(enabled, source) .onPreviewKeyEvent { event -> val keyEvent = event.nativeKeyEvent val left = keyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT - if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) { - false - } else { - // Every down counts, repeats included, so a held direction keeps stepping. + if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) false + else { + // Repeated key-down events let a held direction keep adjusting. if (enabled && keyEvent.action == KeyEvent.ACTION_DOWN) { val next = stepped(value + if (left) -step else step) - if (next != value) onChange(next) + if (next != value) currentOnChange(next) } true } @@ -515,28 +534,33 @@ fun ValueSlider( stateDescription = text progressBarRangeInfo = ProgressBarRangeInfo(value.toFloat(), range.first.toFloat()..range.last.toFloat()) if (label != null) contentDescription = label - }, + if (!enabled) disabled() + setProgress { next -> + if (!enabled) false else { currentOnChange(stepped(next.roundToInt())); true } + } + } + .padding(horizontal = 8.dp, vertical = 6.dp), ) { - Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, textAlign = TextAlign.End, - maxLines = 1, modifier = Modifier.width(56.dp)) - Spacer(Modifier.width(10.dp)) - Box( - contentAlignment = Alignment.CenterStart, - modifier = Modifier.size(width = 164.dp, height = 30.dp).clip(shape).background(colors.surfaceVariant) - .glideBorder(hot, shape, colors.onBackground, pal.line2) - .onSizeChanged { trackPx = it.width } - .pointerInput(enabled, range, step) { if (enabled) detectTapGestures { fromX(it.x) } } - .pointerInput(enabled, range, step) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (label != null) Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, + color = colors.onBackground, modifier = Modifier.weight(1f)) + else Spacer(Modifier.weight(1f)) + Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1) + } + Canvas( + Modifier.fillMaxWidth().height(40.dp).onSizeChanged { trackPx = it.width } + .pointerInput(enabled, range, step, thumbInset) { if (enabled) detectTapGestures { fromX(it.x) } } + .pointerInput(enabled, range, step, thumbInset) { if (enabled) detectHorizontalDragGestures { change, _ -> fromX(change.position.x); change.consume() } }, ) { - Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(pal.signal)) - Box( - Modifier.padding(4.dp).size(22.dp) - .graphicsLayer { translationX = fraction * (trackPx - 30.dp.toPx()).coerceAtLeast(0f) } - .clip(CircleShape) - .background(pal.onSignal), - ) + val start = Offset(thumbInset, size.height / 2f) + val end = Offset(size.width - thumbInset, start.y) + val thumb = Offset(start.x + (end.x - start.x) * fraction, start.y) + val fill = if (hot) pal.signal else colors.onBackground + drawLine(colors.onSurfaceVariant.copy(alpha = 0.35f), start, end, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round) + if (fraction > 0) drawLine(fill, start, thumb, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round) + drawCircle(fill, radius = 7.dp.toPx(), center = thumb) } } } @@ -584,6 +608,9 @@ fun SettingsPage( scroll: androidx.compose.foundation.ScrollState? = null, scrollContent: Boolean = true, compactLayout: Boolean = false, + /** Controls that remain below the title while the settings scroll, such as a tab strip. */ + header: (@Composable () -> Unit)? = null, + modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { val scrollState = scroll ?: rememberScrollState() @@ -591,7 +618,7 @@ fun SettingsPage( val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim") val narrow = LocalNarrowPane.current && !compactLayout Column( - modifier = Modifier.fillMaxSize().padding( + modifier = modifier.fillMaxSize().padding( horizontal = if (compactLayout) 14.dp else if (narrow) 16.dp else 22.dp, vertical = if (compactLayout) 6.dp else if (narrow) 12.dp else 18.dp, ), @@ -656,6 +683,7 @@ fun SettingsPage( } } } + if (header != null) header() Rise(3, Modifier.weight(1f).fillMaxWidth()) { Column( modifier = Modifier diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be062ba1b..19f259ef2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -474,6 +474,7 @@ Effects Controls Components + Controller Settings %1$s page Steam @@ -489,8 +490,11 @@ Scaling sharpness Screen effects Live. Costs a little GPU time while any effect is on. - Look + Presets Custom + Advanced + Expanded + Collapsed Sharpening (CAS) Sharpening level Fake HDR @@ -514,6 +518,7 @@ PC keyboard Android Second screen + Mode Shows Android apps Launch an app @@ -1015,4 +1020,25 @@ Choose a folder for frontend game files Game files saved to %1$s Frontend files + Session display + Resolution + 16:9 game fit + How 16:9 games fit this display. Next session. + Choose Match screen or a custom size narrower than 16:9. + Keep proportions + Stretch to display + Image scaling + Scaling filter + Shared by Steam and Desktop. + Applies live. Shared by Steam and Desktop. + FSR, GSR and NIS apply when enlarging the session image. Sharpen only also works without enlargement. + Filter sharpness + Frame rate + Game window compatibility + Force game windows fullscreen + Keeps games fullscreen after focus changes. Turn off if a game draws small. Applies live during a session. + Keeps games fullscreen after focus changes. Applies live. + %1$s Sets scaling to %2$s. + Match screen (%1$d×%2$d) + Steam diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt new file mode 100644 index 000000000..b432110b6 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt @@ -0,0 +1,58 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.* +import org.junit.Test + +class SessionDisplayTest { + @Test fun explicitPresetsAndMatchScreenResolveWithoutAnyAspectSetting() { + assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "1280x720")) + assertEquals(1600 to 900, SessionDisplay.resolveChoice(1280 to 720, "1600x900")) + assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1080 to 1440, SessionDisplay.MATCH_SCREEN)) + assertEquals(2400 to 1080, SessionDisplay.resolveChoice(1080 to 2400, SessionDisplay.MATCH_SCREEN)) + } + + @Test fun panelOptionReplacesTheIdenticalPresetAndTracksPanelChanges() { + val thor = SessionDisplay.resolutionOptions(1920 to 1080) + assertEquals(listOf("1280x720", "1600x900", SessionDisplay.MATCH_SCREEN), thor) + val small = SessionDisplay.resolutionOptions(1280 to 720) + assertFalse(small.contains("1280x720")) + assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN)) + assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN)) + } + + @Test fun resolutionCapsPreserveTheSelectedShapeWithoutExceedingPanelHeight() { + assertEquals(1280 to 720, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_AUTO)) + assertEquals(960 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_EXACT)) + assertEquals(1280 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_AUTO)) + assertEquals(1280 to 720, SessionDisplay.resolve(1280 to 720, 1080, SessionPrefs.SHAPE_AUTO)) + } + + @Test fun noCapUsesPanelHeightWhileCustomOverridesBothCapAndAspect() { + assertEquals(1440 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_EXACT)) + assertEquals(1920 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE)) + assertEquals(1024 to 768, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_WIDE, 1024 to 768)) + } + + @Test fun orientationAndOddDimensionsKeepTheExistingEvenLandscapeDisplay() { + assertEquals(1920 to 1080, SessionDisplay.resolve(1081 to 1921, 0, SessionPrefs.SHAPE_EXACT)) + assertEquals(1600 to 720, SessionDisplay.resolve(2400 to 1080, 720, SessionPrefs.SHAPE_AUTO)) + } + + @Test fun gameStretchAvailabilityMatchesTheRuntimeIncludingRounding() { + assertTrue(SessionDisplay.canStretch16x9(960 to 720)) + assertFalse(SessionDisplay.canStretch16x9(1280 to 720)) + assertFalse(SessionDisplay.canStretch16x9(1600 to 720)) + val wide = SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE) + assertFalse(SessionDisplay.canStretch16x9(wide)) + } + + @Test fun removedFilterAliasesRetainTheirEquivalentBehavior() { + assertEquals(0, SessionPrefs.canonicalUpscaler(1)) + assertEquals(4, SessionPrefs.canonicalUpscaler(5)) + assertEquals(0, SessionPrefs.canonicalUpscaler(99)) + assertFalse(SessionPrefs.upscalerHasSharpness(0)) + assertFalse(SessionPrefs.upscalerHasSharpness(1)) + assertFalse(SessionPrefs.upscalerHasSharpness(2)) + for (mode in 3..8) assertTrue(SessionPrefs.upscalerHasSharpness(mode)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt new file mode 100644 index 000000000..aba88831a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class SessionScalingPrefsTest { + @Before fun clearSettings() { + RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun explicitLegacyAspectAndCapKeepTheirSizeUntilAResolutionIsPicked() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) + prefs.edit().putString("shape", SessionPrefs.SHAPE_EXACT).putInt("resolutionCap.steam", 900).commit() + assertEquals("1200x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080)) + SessionPrefs.setResolutionChoice(context, "steam", "1600x900") + prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).commit() + assertEquals("1600x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080)) + assertTrue(SessionPrefs.resolutionChosen(context, "steam")) + } + + @Test fun matchScreenOverridesLegacyCustomAndAspectWithoutAffectingTheOtherMode() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) + prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).putString("customRes.steam", "1024x768") + .putString("customRes.lxqt", "800x600").commit() + assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080)) + SessionPrefs.setResolutionChoice(context, "steam", SessionDisplay.MATCH_SCREEN) + val choice = SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080) + assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1440 to 1080, choice)) + assertEquals("800x600", SessionPrefs.resolutionChoice(context, "lxqt", 1440 to 1080)) + } + + @Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() { + val context = RuntimeEnvironment.getApplication() + assertEquals("1280x720", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080)) + SessionPrefs.setResolutionChoice(context, "steam", "1025×769") + assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080)) + } + + @Test fun legacySavedFiltersStillResolveToAVisibleEquivalentChoice() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) + prefs.edit().putInt("upscaler", 1).putInt("upscaleSharpness", 65).commit() + assertEquals(0, SessionPrefs.upscaler(context)) + prefs.edit().putInt("upscaler", 5).commit() + assertEquals(4, SessionPrefs.upscaler(context)) + assertEquals(65, SessionPrefs.upscaleSharpness(context)) + SessionPrefs.setUpscaler(context, 5) + assertEquals(4, prefs.getInt("upscaler", -1)) + SessionPrefs.setUpscaler(context, 1) + assertEquals(0, prefs.getInt("upscaler", -1)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt new file mode 100644 index 000000000..6c844ead1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt @@ -0,0 +1,27 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.* +import org.junit.Test + +class SessionDrawerPageTest { + @Test fun singleScreenNavigationStartsWithControllerAndSkipsSecondScreen() { + val pages = sessionDrawerPages(false) + assertEquals(SessionDrawerPage.CONTROLLER, pages.first()) + assertFalse(SessionDrawerPage.SECOND_SCREEN in pages) + assertEquals(SessionDrawerPage.DISPLAY, pages.step(SessionDrawerPage.CONTROLLER, 1)) + assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.CONTROLLER, -1)) + assertEquals(SessionDrawerPage.CONTROLLER, pages.step(SessionDrawerPage.SESSION, 1)) + } + + @Test fun connectedSecondaryDisplayAddsItsPageAfterControllerAndPreservesOtherPages() { + val connected = sessionDrawerPages(true) + assertEquals(SessionDrawerPage.SECOND_SCREEN, connected.step(SessionDrawerPage.CONTROLLER, 1)) + assertEquals(SessionDrawerPage.DISPLAY, connected.step(SessionDrawerPage.SECOND_SCREEN, 1)) + assertEquals(sessionDrawerPages(false), connected.filter { it != SessionDrawerPage.SECOND_SCREEN }) + for (pages in listOf(connected, sessionDrawerPages(false))) { + assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.DISPLAY, 1)) + assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.EFFECTS, 1)) + assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1)) + } + } +} From 472bb28c8841d6cec4ddf06407530e6034ec00ee Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 5 Oct 2026 07:33:33 -0400 Subject: [PATCH 4/9] Fix controller rings on linked data dirs, route rumble to the controller, capture the mouse for pointer locks (#242) - #166: export the fake-input ring paths with the spelling the session tree is bound under (Context.getFilesDir(), /data/user/0/...) instead of the canonical path. Where /data/user/0 resolves to /data/data (ColorOS 16) the guest has nothing bound at the canonical prefix, so libfakeinput could not open the rings and every pad, physical and on-screen, was dead. The ring path logic moves to FakeInputRingPaths so it can be unit-tested without the native library. - #219: play a game's rumble on the physical controller that last drove the pad, when Android exposes its motors (strong and weak separately on Android 12+), and fall back to the device's vibrator for the on-screen pad or a controller without rumble. PadBridge records the active controller; the on-screen pad taking over moves rumble back to the phone. - #153: when a program takes a pointer lock (a mouse-look game through gamescope), capture a physical mouse and send its raw deltas as relative motion, so the camera no longer stops where Android's pointer meets the screen edge. Capture is released for the drawer, the PC keyboard, picture in picture, focus loss and pause, and when the lock ends. --- .../com/droiddeck/launcher/SessionActivity.kt | 89 +++++++++++- .../launcher/input/FakeInputRingPaths.java | 39 ++++++ .../launcher/input/FakeInputWriter.java | 23 +-- .../droiddeck/launcher/input/PadBridge.java | 14 ++ .../launcher/session/RumbleComponent.kt | 132 ++++++++++++++---- .../launcher/wayland/WaylandCompositor.java | 12 ++ .../launcher/input/FakeInputRingPathTest.kt | 53 +++++++ .../launcher/session/RumbleComponentTest.kt | 71 +++++++++- 8 files changed, 383 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/FakeInputRingPaths.java create mode 100644 app/src/test/java/com/droiddeck/launcher/input/FakeInputRingPathTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 8ceab7bb7..1d5ed29bf 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -161,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS) private var cursorSerial = 0 private var clientHidesCursor = false + /** + * A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope). + * While it does, a physical mouse is captured and its raw deltas go to the program as relative + * motion. Without capture the deltas are worked out from Android's pointer position, which stops + * at the screen edge, and so did the camera (#153). + */ + private var pointerLocked = false + private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ -> + uiHandler.post { + if (pointerLocked == locked) return@post + pointerLocked = locked + Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released") + updatePointerCapture() + } + } private var cursorImage by mutableStateOf(null) private var cursorHotX by mutableStateOf(0) private var cursorHotY by mutableStateOf(0) @@ -270,10 +285,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onInputDeviceAdded(deviceId: Int) { logInputDevice("connected", deviceId) updateOnScreenControls() + updatePointerCapture() } override fun onInputDeviceRemoved(deviceId: Int) { Log.i(TAG, "input device $deviceId disconnected") updateOnScreenControls() + updatePointerCapture() } override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls() } @@ -324,7 +341,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // below) and the session starts when it is in. if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it") - val root = FrameLayout(this) + // Captured mouse events go down the focus chain, and focus sits somewhere in the overlay: + // the root sees every one of them whichever view that is. + val root = object : FrameLayout(this) { + override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean = + onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event) + override fun dispatchPointerCaptureChanged(hasCapture: Boolean) { + super.dispatchPointerCaptureChanged(hasCapture) + Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off") + } + } surfaceView = SurfaceView(this) surfaceView.holder.addCallback(this) root.addView(surfaceView) @@ -387,6 +413,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { updateOnScreenControls() WaylandCompositor.setFirstFrameListener(firstFrameListener) + WaylandCompositor.setPointerLockListener(pointerLockListener) SessionEvents.markReadyIfPossible() SessionState.endListener = endListener // A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord. @@ -417,6 +444,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // this full-screen host and remain as a white veil after the drawer closes. defaultFocusHighlightEnabled = false setContent { + // The drawer, the PC keyboard and picture in picture take the mouse back. + androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() } DroidDeckTheme { if (pipUi) { if (SessionState.suspended) androidx.compose.foundation.layout.Box( @@ -1327,6 +1356,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return super.dispatchGenericMotionEvent(event) } + /** With no view focused, captured mouse events skip the root and arrive here as trackball events. */ + override fun dispatchTrackballEvent(event: MotionEvent): Boolean = + onCapturedPointer(event) || super.dispatchTrackballEvent(event) + override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (pipUi) return true if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) { @@ -1677,6 +1710,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return true } + /** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */ + private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id -> + val d = InputDevice.getDevice(id) + d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE) + } + + /** Capture the mouse exactly while a program holds a lock and the session has the mouse. */ + private fun updatePointerCapture() { + if (!::surfaceView.isInitialized) return + val view = window.decorView + val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached() + if (want == view.hasPointerCapture()) return + if (want) view.requestPointerCapture() else view.releasePointerCapture() + } + + /** + * A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the + * uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone. + */ + private fun onCapturedPointer(event: MotionEvent): Boolean { + if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + var dx = event.x + var dy = event.y + for (i in 0 until event.historySize) { + dx += event.getHistoricalX(i) + dy += event.getHistoricalY(i) + } + WaylandCompositor.sendPointerDelta(dx, dy) + } + MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> { + val button = when (event.actionButton) { + MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT + MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE + else -> PointerGestures.BTN_LEFT + } + WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0) + } + MotionEvent.ACTION_SCROLL -> { + val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL) + if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0) + } + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these + else -> return false + } + return true + } + /** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */ /** Which controller the session has, so a pad report says what was plugged in and when. */ private fun logInputDevice(what: String, deviceId: Int) { @@ -1848,6 +1930,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { controllerSettings = ControllerPrefs.read(this) updateOnScreenControls() resumed = true + updatePointerCapture() if (!pipUi) sessionClipboard.start() updatePadMotion() readPrefs() @@ -1867,6 +1950,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // A button held when the app goes away would stay held in the ring for the whole session. onScreenControls?.releaseAll() resumed = false + updatePointerCapture() updatePadMotion() keyboard?.takeIf { it.shown }?.hide() super.onPause() @@ -1887,6 +1971,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { goFullscreen() refreshClipboard() } + // Android drops a capture with the focus; a lock still held takes it again on return. + updatePointerCapture() } /** The keyboard/trackpad presentation can own focus instead of the main display. */ @@ -1937,6 +2023,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null if (SessionState.endListener === endListener) SessionState.endListener = null WaylandCompositor.clearFirstFrameListener(firstFrameListener) + WaylandCompositor.clearPointerLockListener(pointerLockListener) super.onDestroy() } diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputRingPaths.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputRingPaths.java new file mode 100644 index 000000000..05e5fc306 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputRingPaths.java @@ -0,0 +1,39 @@ +package com.droiddeck.launcher.input; + +import java.io.File; + +/** + * Where the fake input rings live and how the guest is told about them. Kept apart from + * {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM. + */ +final class FakeInputRingPaths { + static final String RING_DIR_NAME = "fakeinput-rings"; + + private FakeInputRingPaths() {} + + /** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */ + static File ringDir(File fakeInputDir) { + if (fakeInputDir == null) { + return null; + } + File inputDir = fakeInputDir.getAbsoluteFile(); + File parent = inputDir.getParentFile(); + return new File(parent != null ? parent : inputDir, RING_DIR_NAME); + } + + static File ringFile(File fakeInputDir, int slot) { + File ringDir = ringDir(fakeInputDir); + return ringDir != null ? new File(ringDir, "ring" + slot) : null; + } + + /** + * The ring's path as the guest opens it. The session tree is bound into the guest at its own + * host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so + * the export has to use that same spelling. A canonical path is not that: where + * /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves + * to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead. + */ + static String exportPath(File ringFile) { + return ringFile.getAbsolutePath(); + } +} 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 0e4ea2de3..8e617a8d6 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -94,7 +94,6 @@ public class FakeInputWriter { private static final int RING_SNAPSHOT_SEQ_OFFSET = 32; private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40; private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8] - private static final String RING_DIR_NAME = "fakeinput-rings"; // After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and // an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and // units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it. @@ -198,26 +197,8 @@ public class FakeInputWriter { return builder.toString(); } - private static File getRingDir(File fakeInputDir) { - if (fakeInputDir == null) { - return null; - } - File inputDir = fakeInputDir.getAbsoluteFile(); - File parent = inputDir.getParentFile(); - return new File(parent != null ? parent : inputDir, RING_DIR_NAME); - } - private static File getRingFile(File fakeInputDir, int slot) { - File ringDir = getRingDir(fakeInputDir); - return ringDir != null ? new File(ringDir, "ring" + slot) : null; - } - - private static String getCanonicalOrAbsolutePath(File file) { - try { - return file.getCanonicalPath(); - } catch (IOException e) { - return file.getAbsolutePath(); - } + return FakeInputRingPaths.ringFile(fakeInputDir, slot); } private static void initializeRingHeader(ByteBuffer data) { @@ -325,7 +306,7 @@ public class FakeInputWriter { ringSlot.ringFile = ringFile.getAbsoluteFile(); ringSlot.ringRaf = raf; ringSlot.ringChannel = channel; - ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile); + ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile); Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath); return ringSlot; } catch (IOException e) { 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 a385650e6..08328b5d3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -76,6 +76,17 @@ public final class PadBridge { private final java.util.Set seenDevices = new java.util.HashSet<>(); private int lastDeviceId = Integer.MIN_VALUE; + /** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */ + public static final int NO_CONTROLLER = -1; + /** + * The Android input device id of the physical controller that last drove the pad, or + * {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble + * on that slot finds the motors in the player's hands (RumbleComponent). + */ + private static volatile int activeControllerId = NO_CONTROLLER; + + public static int activeControllerId() { return activeControllerId; } + public PadBridge(File fakeInputDir) { writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT); } @@ -90,6 +101,7 @@ public final class PadBridge { } public synchronized void stop() { + activeControllerId = NO_CONTROLLER; systemGuidePressed = false; systemQamPressed = false; qamChordActive = false; @@ -218,6 +230,7 @@ public final class PadBridge { */ public synchronized void applyTouch(java.util.function.Consumer mutation) { mutation.accept(state); + activeControllerId = NO_CONTROLLER; notePlayerInput(); statOnScreen++; scheduleStats(); @@ -299,6 +312,7 @@ public final class PadBridge { /** Per event, so the common case - the same pad as last time - is a single compare. */ private void noteDevice(InputDevice device) { + activeControllerId = device.getId(); if (device.getId() == lastDeviceId) return; lastDeviceId = device.getId(); statDevice = device.getName(); 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 fcf79bd6e..4522068d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt @@ -3,43 +3,58 @@ package com.droiddeck.launcher.session import android.net.LocalServerSocket import android.content.SharedPreferences import android.os.Build +import android.os.CombinedVibration import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import android.util.Log +import android.view.InputDevice +import androidx.annotation.RequiresApi import com.droiddeck.launcher.core.SessionPart import com.droiddeck.launcher.input.ControllerPrefs +import com.droiddeck.launcher.input.PadBridge import java.io.DataInputStream import kotlin.math.max /** - * Rumble for the on-screen pad, on the phone itself. + * Rumble for the pad, on the motors in the player's hands. * - * When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest - * connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and - * the pad slot, four little-endian 16-bit values. This listens for those and drives the device's - * vibrator with the stronger motor's strength for the effect's duration. Best effort both ways: - * a missing listener costs the guest nothing, and a bad packet is dropped. + * When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's + * rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME] + * and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit + * values. This listens for those and plays the effect on the physical controller that last drove + * the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak + * separately where it has two - and otherwise on the device's own vibrator, with the stronger + * motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a + * missing listener costs the guest nothing, and a bad packet is dropped. */ class RumbleComponent : SessionPart() { @Volatile private var server: LocalServerSocket? = null - private var vibrator: Vibrator? = null + private var phone: Motors? = null + /** Where the last effect went, so the next one (or a stop) can end it there. */ + private var playing: Motors? = null + private var controllerId = PadBridge.NO_CONTROLLER + private var controller: Motors? = null private var preferences: SharedPreferences? = null private var enabled = false private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == null || key == "rumble") refreshEnabled() } + /** The motors of the controller with this input device id, or null when it has none. Tests swap it. */ + internal var controllerMotors: (Int) -> Motors? = ::lookupController + @Synchronized private fun refreshEnabled() { enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true - if (!enabled) vibrator?.cancel() + if (!enabled) { playing?.cancel(); playing = null } } override fun start() { val ctx = app() ?: return val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return } - vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator + val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java) + phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) } preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) } refreshEnabled() server = s @@ -49,8 +64,7 @@ class RumbleComponent : SessionPart() { try { val bytes = ByteArray(8) DataInputStream(client.inputStream).readFully(bytes) - val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4) - buzz(max(strong, weak), ms) + buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4)) } catch (e: Exception) { // A partial packet or a closed peer: nothing to play. } finally { @@ -65,8 +79,11 @@ class RumbleComponent : SessionPart() { preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener) preferences = null enabled = false - vibrator?.cancel() - vibrator = null + playing?.cancel() + playing = null + phone = null + controller = null + controllerId = PadBridge.NO_CONTROLLER val s = server server = null try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ } @@ -74,24 +91,91 @@ class RumbleComponent : SessionPart() { private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8) - @Synchronized private fun buzz(strength: Int, ms: Int) { + @Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) { if (!enabled) return - val vibrator = vibrator ?: return - if (!vibrator.hasVibrator()) return - if (strength == 0 || ms == 0) { vibrator.cancel(); return } - val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255) - val duration = ms.toLong().coerceIn(1L, 5000L) - try { - if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude)) - else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE)) - } catch (e: Exception) { - Log.w(TAG, "rumble: $e") + val target = target() + if (target !== playing) { + playing?.cancel() + playing = target + target?.let { Log.i(TAG, "rumble: playing on ${it.name}") } } + if (target == null) return + if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return } + try { + target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L)) + } catch (e: Exception) { + Log.w(TAG, "rumble: ${target.name}: $e") + } + } + + /** The active controller's motors when it has any, else the phone's. */ + private fun target(): Motors? { + val id = PadBridge.activeControllerId() + if (id != controllerId) { + controllerId = id + controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null } + } + return controller ?: phone + } + + /** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */ + internal interface Motors { + val name: String + fun play(strong: Int, weak: Int, ms: Long) + fun cancel() + } + + private class PhoneMotors(private val vibrator: Vibrator) : Motors { + override val name = "the device's vibrator" + override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms)) + override fun cancel() = vibrator.cancel() + } + + /** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */ + @RequiresApi(31) + private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors { + override fun play(strong: Int, weak: Int, ms: Long) { + val combined = CombinedVibration.startParallel() + var any = false + fun add(id: Int, strength: Int) { + if (strength == 0) return + combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms)) + any = true + } + if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak)) + if (any) manager.vibrate(combined.combine()) else manager.cancel() + } + override fun cancel() = manager.cancel() + } + + /** Before Android 12 a pad has one vibrator. */ + private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors { + override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms)) + override fun cancel() = vibrator.cancel() } companion object { private const val TAG = "SessionService" /** Must match the fake evdev layer (fakeinput_steam.cpp). */ const val NAME = "droiddeck-rumble" + + private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect { + val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255) + else VibrationEffect.DEFAULT_AMPLITUDE + return VibrationEffect.createOneShot(ms, amplitude) + } + + private fun lookupController(id: Int): Motors? { + val device = InputDevice.getDevice(id) ?: return null + val name = "\"${device.name}\"" + if (Build.VERSION.SDK_INT >= 31) { + val manager = device.vibratorManager + val ids = manager.vibratorIds.sortedArray() + return if (ids.isEmpty()) null else PadMotors(name, manager, ids) + } + @Suppress("DEPRECATION") + val vibrator = device.vibrator + return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null + } } } 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 73e3c9a9e..84330c98b 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -115,6 +115,11 @@ public final class WaylandCompositor { public static void setPointerLockListener(PointerLockListener l) { pointerLockListener = l; } + /** Unhooks {@code l} only if it is still the listener, so a finishing activity never unhooks its successor's. */ + public static void clearPointerLockListener(PointerLockListener l) { + if (pointerLockListener == l) pointerLockListener = null; + } + /** Invoked from native (droiddeck_on_pointer_lock). */ @SuppressWarnings("unused") static void onPointerLock(boolean locked, int x, int y) { @@ -500,4 +505,11 @@ public final class WaylandCompositor { if (dx == 0 && dy == 0) return; nativeSendSceneInput(6, dx * 256, dy * 256); } + + /** As {@link #sendPointerDelta(int, int)}, keeping a captured mouse's fractions of a pixel. */ + public static void sendPointerDelta(float dx, float dy) { + int x = Math.round(dx * 256f), y = Math.round(dy * 256f); + if (x == 0 && y == 0) return; + nativeSendSceneInput(6, x, y); + } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/FakeInputRingPathTest.kt b/app/src/test/java/com/droiddeck/launcher/input/FakeInputRingPathTest.kt new file mode 100644 index 000000000..d1d2b8d49 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/FakeInputRingPathTest.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.input + +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * The ring paths handed to the guest (FAKE_EVDEV_MEMFD_PATHS) must keep the spelling the session + * tree is bound under. On ColorOS 16 /data/user/0 resolves to /data/data, the guest only has the + * first bound, and a canonical export left every pad dead (#166). A symlinked directory stands in + * for that here. + */ +class FakeInputRingPathTest { + private lateinit var base: File + private lateinit var real: File + private lateinit var link: File + + @Before fun setUp() { + base = Files.createTempDirectory("ring-path").toFile() + real = File(base, "data-data").apply { mkdirs() } + link = File(base, "data-user-0") + Files.createSymbolicLink(link.toPath(), real.toPath()) + } + + @After fun tearDown() { + base.deleteRecursively() + } + + @Test fun ringsSitBesideTheFakeInputNodes() { + val fakeInputDir = File(link, "files/session/dev/input") + assertEquals(File(link, "files/session/dev/fakeinput-rings/ring2"), FakeInputRingPaths.ringFile(fakeInputDir, 2)) + } + + @Test fun theExportKeepsTheBoundSpellingThroughALinkedDataDirectory() { + val fakeInputDir = File(link, "files/session/dev/input").apply { mkdirs() } + val ring = FakeInputRingPaths.ringFile(fakeInputDir, 0)!!.apply { parentFile!!.mkdirs(); writeText("") } + // The link really does resolve elsewhere, as /data/user/0 does on the affected devices. + assertTrue(ring.canonicalPath.startsWith(real.canonicalPath + "/")) + val exported = FakeInputRingPaths.exportPath(ring) + assertEquals(File(link, "files/session/dev/fakeinput-rings/ring0").path, exported) + assertFalse("$exported resolved through the link", exported.startsWith(real.path + "/")) + assertTrue("$exported does not open", File(exported).isFile) + } + + @Test fun aRelativeInputDirIsExportedAbsolute() { + val exported = FakeInputRingPaths.exportPath(FakeInputRingPaths.ringFile(File("session/dev/input"), 1)!!) + assertTrue(exported, File(exported).isAbsolute) + assertTrue(exported, exported.endsWith("/session/dev/fakeinput-rings/ring1")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt index f61e86058..293a48d58 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt @@ -8,6 +8,7 @@ import android.os.Looper import android.os.Vibrator import android.os.VibratorManager import com.droiddeck.launcher.input.ControllerPrefs +import com.droiddeck.launcher.input.PadBridge import java.io.IOException import org.junit.After import org.junit.Before @@ -68,12 +69,74 @@ class RumbleComponentTest { assertTrue(shadowOf(vibrator).isVibrating) } - private fun effect() { - // Inject a decoded force-feedback packet; transport is outside this preference test. - RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) - .apply { isAccessible = true }.invoke(rumble, 65535, 5000) + @Test fun aControllerWithMotorsGetsTheEffectInsteadOfThePhone() { + val pad = FakeMotors() + rumble.controllerMotors = { id -> if (id == PAD_ID) pad else null } + activeController(PAD_ID) + effect(strong = 40000, weak = 1000) + assertEquals(listOf(Triple(40000, 1000, 5000L)), pad.played) + assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating) } + @Test fun aControllerWithoutMotorsFallsBackToThePhone() { + rumble.controllerMotors = { null } + activeController(PAD_ID) + effect() + assertTrue(shadowOf(vibrator).isVibrating) + } + + @Test fun theOnScreenPadTakingOverMovesTheEffectBackToThePhone() { + val pad = FakeMotors() + rumble.controllerMotors = { pad } + activeController(PAD_ID) + effect() + assertTrue(pad.vibrating) + activeController(PadBridge.NO_CONTROLLER) + effect() + assertFalse("the controller kept rumbling after the switch", pad.vibrating) + assertTrue(shadowOf(vibrator).isVibrating) + } + + @Test fun aStopPacketDisablingAndStoppingAllEndTheControllersEffect() { + val pad = FakeMotors() + rumble.controllerMotors = { pad } + activeController(PAD_ID) + effect() + effect(strong = 0, weak = 0) + assertFalse(pad.vibrating) + effect() + ControllerPrefs.setRumble(context, false) + shadowOf(Looper.getMainLooper()).idle() + assertFalse(pad.vibrating) + ControllerPrefs.setRumble(context, true) + shadowOf(Looper.getMainLooper()).idle() + effect() + rumble.stop() + assertFalse(pad.vibrating) + } + + @After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) } + + private fun activeController(id: Int) { + PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id) + } + + private fun effect(strong: Int = 65535, weak: Int = 65535) { + // Inject a decoded force-feedback packet; transport is outside these tests. + RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(rumble, strong, weak, 5000) + } + + private class FakeMotors : RumbleComponent.Motors { + override val name = "fake pad" + val played = mutableListOf>() + var vibrating = false + override fun play(strong: Int, weak: Int, ms: Long) { played += Triple(strong, weak, ms); vibrating = true } + override fun cancel() { vibrating = false } + } + + private companion object { const val PAD_ID = 7 } + @Implements(LocalServerSocket::class) class NoPacketsSocket { @Implementation fun __constructor__(name: String) = Unit From 31ea194f3db0ff8d6b86eef0614442418ccf309a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:46:36 -0400 Subject: [PATCH 5/9] Prepare 0.3.1 release (#263) * docs: draft 0.3.1 release notes * chore: bump version to 0.3.1 (11) --- app/build.gradle | 4 +-- docs/releases/0.3.1.md | 62 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 docs/releases/0.3.1.md diff --git a/app/build.gradle b/app/build.gradle index d6d2a4ab9..d92138f39 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -79,8 +79,8 @@ android { // file out of its own data directory, and this app's entire job is to exec a rootfs: // proot, gamescope, the Steam client and everything it launches all live under files/. targetSdkVersion 28 - versionCode 10 - versionName "0.3.0" + versionCode 11 + versionName "0.3.1" buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\"" buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\"" buildConfigField "int", "PR_NUMBER", "${prNumber}" diff --git a/docs/releases/0.3.1.md b/docs/releases/0.3.1.md new file mode 100644 index 000000000..17e22a47c --- /dev/null +++ b/docs/releases/0.3.1.md @@ -0,0 +1,62 @@ +# DroidDeck 0.3.1 + +### Steam + +- Decky plugins can be imported from ZIP files, with remote binaries verified and installed copies of the same plugin replaced. (@xXJSONDeruloXx, [#79](https://github.com/Droid-Deck/DroidDeck/pull/79)) +- Steam's Internet page shows Android Wi-Fi and cellular connections. Optional Wi-Fi discovery adds network names and nearby networks with Android's Location permission. (@xXJSONDeruloXx, [#150](https://github.com/Droid-Deck/DroidDeck/pull/150)) +- Paste text from the Android clipboard into Steam sessions. (@xXJSONDeruloXx, [#151](https://github.com/Droid-Deck/DroidDeck/pull/151)) +- The Proton selected in Components stays in sync with Steam's default compatibility tool, and runnable builds are marked **Compatible**. Windows titles with a Valve recommendation use that default unless you choose another compatible tool for the game. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#224](https://github.com/Droid-Deck/DroidDeck/pull/224)) + +### Compatibility and performance + +- Choose **esync**, **fsync**, experimental **ntsync**, or **wineserver** for Proton games. These sync options work around Android's missing kernel support, with esync as the default for fresh installs and existing choices preserved. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#223](https://github.com/Droid-Deck/DroidDeck/pull/223)) +- droiddeck-esync packs are matched to the exact Valve, GE-Proton or CachyOS Proton build and fetched automatically. droiddeck-fsync stands in when a pack is not available. (@maxjivi05, [#189](https://github.com/Droid-Deck/DroidDeck/pull/189), [#197](https://github.com/Droid-Deck/DroidDeck/pull/197)) +- Linux-only x86 Steam titles can run through FEX using Steam's FEX and Steam Linux Runtime, with controller input and the device's graphics drivers. Missing dependencies are requested through Steam. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197)) +- .NET and Godot games get automatic launch fixes under FEX, including the settings needed for Slay the Spire 2. Per-game overrides are kept. (@magarto, [#168](https://github.com/Droid-Deck/DroidDeck/pull/168)) + +### Display and effects + +- Live image scaling with AMD FSR 1, Snapdragon GSR and GSR quality, NVIDIA NIS, linear and nearest filtering, plus sharpening. (@magarto, @The412Banner, [#128](https://github.com/Droid-Deck/DroidDeck/pull/128), [#198](https://github.com/Droid-Deck/DroidDeck/pull/198)) +- An **Effects** page adds presets, FXAA, Toon, CRT, NTSC and Fake HDR. Advanced controls cover sharpening, debanding, brightness, contrast, gamma and saturation, with choices saved between sessions. (@The412Banner, @xXJSONDeruloXx, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198), [#247](https://github.com/Droid-Deck/DroidDeck/pull/247)) +- Anisotropic filtering and texture sharpness for DirectX 9–11 games, applied on the next game launch. (@The412Banner, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198)) +- One **Resolution** menu before starting a session, with preset dimensions, Custom and **Match screen**. Resolution, image scaling and fullscreen compatibility have separate controls. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247)) + +### Controls and sessions + +- Standard Android picture-in-picture, opened manually or automatically when leaving a ready session, with second-screen controls restored when you return. (@xXJSONDeruloXx, [#156](https://github.com/Droid-Deck/DroidDeck/pull/156), [#195](https://github.com/Droid-Deck/DroidDeck/pull/195)) +- A quick toggle hides or restores on-screen controls. **Touch Off** ignores touches on the game picture while keeping Android controls usable. (@xXJSONDeruloXx, [#147](https://github.com/Droid-Deck/DroidDeck/pull/147), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196)) +- Optional game vibration and on-screen Steam, Quick Access and keyboard buttons. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194)) +- Steam session settings use tabs for Display, Controls, Games, Audio, Session and Steam. The session menu starts with Controller, gives second-screen modes their own page, and groups live display changes separately from settings for the next game launch. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247)) +- Compact Steam Home and Quick Access shortcuts beside Stop. Switching to Desktop stays in Steam's own menu. (@xXJSONDeruloXx, [#188](https://github.com/Droid-Deck/DroidDeck/pull/188), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196)) +- Back's double-press window is back to 500 ms, making Quick Access easier to open. (@xXJSONDeruloXx, [#193](https://github.com/Droid-Deck/DroidDeck/pull/193)) + +### Apps and launcher + +- Android home-screen shortcuts for installed Steam games and added games. (@xXJSONDeruloXx, [#157](https://github.com/Droid-Deck/DroidDeck/pull/157)) +- Export per-game `.droiddeck` launch files and Steam icons for Android frontends, with optional automatic folder sync as the library changes. (@xXJSONDeruloXx, [#179](https://github.com/Droid-Deck/DroidDeck/pull/179)) +- Import x86 AppImages and run x86 Linux scripts and programs through FEX. Added apps have a **Use FEX** Auto/On/Off choice, and GitHub imports can use an x86_64 AppImage when no ARM64 build is available. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197)) +- Vulkan Qt Quick AppImages such as OpenNOW use Wayland to avoid X11 startup failures. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197)) +- Preview updates show recent published changes and how many builds behind you are when the installed Preview can be matched. (@xXJSONDeruloXx, [#159](https://github.com/Droid-Deck/DroidDeck/pull/159)) +- A new empty-library intro and DroidDeck wordmark on the Steam page, plus an option to reduce app animations. (@xXJSONDeruloXx, [#182](https://github.com/Droid-Deck/DroidDeck/pull/182), [#194](https://github.com/Droid-Deck/DroidDeck/pull/194)) +- The Linux file manager bookmarks the selected game library automatically. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194)) + +### Languages and logs + +- Simplified Chinese. (@Dantalian-dev, [#202](https://github.com/Droid-Deck/DroidDeck/pull/202)) +- Japanese, Korean and Traditional Chinese for Taiwan, Hong Kong and Macau. (@starwishHi, [#216](https://github.com/Droid-Deck/DroidDeck/pull/216)) +- Optional **Storage diagnostics** include allocation and storage timing in shared session logs to help investigate slow Steam installs. (@xXJSONDeruloXx, [#158](https://github.com/Droid-Deck/DroidDeck/pull/158)) +- Session logs retain their final output and include more detail when an AppImage or FEX program fails to start or crashes. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197)) + +### Fixed + +- Steam's Deck controller not being found on handhelds with older kernels. (@xXJSONDeruloXx, [#146](https://github.com/Droid-Deck/DroidDeck/pull/146)) +- Physical and on-screen controllers doing nothing on ColorOS devices with linked app data directories. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242)) +- Game vibration going to the phone instead of the active physical controller. The device vibrator remains the fallback for on-screen controls or controllers without motors. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242)) +- A physical mouse stopping camera movement at the screen edge. Games that request a pointer lock now use Android's relative pointer capture, released while menus or PiP are open. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242)) +- Steam remaining on a black screen after idle sleep or Android session resume. (@xXJSONDeruloXx, @jmarti326, [#155](https://github.com/Droid-Deck/DroidDeck/pull/155), [#172](https://github.com/Droid-Deck/DroidDeck/pull/172)) +- Steam stuck at **Waiting for Steam** after an interrupted client update. After two starts without a frame, DroidDeck fetches the client again while preserving games, settings and user data; **Repair Steam client** also queues a manual repair. (@maxjivi05, [#225](https://github.com/Droid-Deck/DroidDeck/pull/225)) +- Play racing runtime removal. Removal now shows progress and verifies completion, and first-run setup respects the selected Steam channel and saved UI scaling. (@xXJSONDeruloXx, [#185](https://github.com/Droid-Deck/DroidDeck/pull/185)) +- GE-Proton and CachyOS Proton failing their protonfixes checks because Android blocks `/proc/sys/fs/file-max`. (@magarto, [#169](https://github.com/Droid-Deck/DroidDeck/pull/169)) +- DirectAudio not being selected correctly, mismatched FEX helpers surviving component swaps, and FEX or its runtime not being executable from an SD-card Steam library. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197)) + +[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.3.0...0.3.1) From c91daa9147ec685cf2f5d0954e7b99dfe9387cef Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:59:35 -0400 Subject: [PATCH 6/9] Keep the game undimmed while the session menu is open (#267) Remove background dimming from the session menu --- app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9280845b5..cf05c2a5f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -308,7 +308,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } } if (open || veil > 0.01f) Box( - modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000)) + modifier = Modifier.fillMaxSize() .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() }, ) androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) { From b896e7a3b9081a92b43c8b9f4717fb1ac2056966 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:34:31 -0400 Subject: [PATCH 7/9] Compact Steam, Desktop, and Setup settings headers (#268) --- .../droiddeck/launcher/ui/FrontEndGames.kt | 2 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 5 +- .../launcher/ui/ModeSettingsDialog.kt | 46 ++++++++++--------- .../droiddeck/launcher/ui/SettingsWidgets.kt | 19 ++++++-- 4 files changed, 41 insertions(+), 31 deletions(-) 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 33226c3b3..3074257aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -284,7 +284,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni } } -/** A page's title, with room at its right for a status chip. */ +/** A page's title, with room at its right for controls or status. */ @Composable internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) { Row( 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 c404dab6f..d5d408414 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -162,8 +162,6 @@ internal fun SetupPanel( val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) val signedIn = s.offlineAccount != null var showLimitDetails by rememberSaveable { mutableStateOf(false) } - val checks = 4 - val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it } // Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build // and credits are on the Updates page. val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher)) @@ -187,9 +185,8 @@ internal fun SetupPanel( ), ) { PageHeader(stringResource(R.string.setup_title)) { - Chip(if (readyCount == checks) stringResource(R.string.setup_all_set) else stringResource(R.string.setup_n_ready, readyCount, checks), ok = readyCount == checks) + TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus) } - TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus) Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { when (tab) { 0 -> { 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 8b14ec19f..e7ff70f31 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -155,7 +155,7 @@ class ModeSettingsActions( val onDismiss: () -> Unit, ) -private enum class SteamSettingsTab(val label: Int) { +private enum class ModeSettingsTab(val label: Int) { DISPLAY(R.string.drawer_page_display), CONTROLS(R.string.drawer_page_controls), GAMES(R.string.game_settings_title), @@ -171,34 +171,36 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { var confirmDeckyRemoval by remember { mutableStateOf(false) } var explainWifiDiscovery by remember { mutableStateOf(false) } var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) } - val tabs = SteamSettingsTab.entries + val tabs = if (steam) ModeSettingsTab.entries else listOf( + ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION, + ) val tab = tabs[tabIndex] val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() } - val tabFocus = remember { tabs.map { androidx.compose.ui.focus.FocusRequester() } } + val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } } val firstChip = remember { androidx.compose.ui.focus.FocusRequester() } fun selectTab(index: Int) { host.open = null; tabIndex = index } androidx.compose.runtime.LaunchedEffect(steam, tabIndex) { androidx.compose.runtime.withFrameNanos { } runCatching { - if (!steam || tab == SteamSettingsTab.DISPLAY) firstChip.requestFocus() + if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus() else tabFocus[tabIndex].requestFocus() } } SettingsPage( host, - title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title), + title = null, onBack = a.onDismiss, scroll = scrolls[tabIndex], - modifier = if (steam) Modifier.bumpers( + modifier = Modifier.bumpers( onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) }, onNext = { selectTab((tabIndex + 1) % tabs.size) }, - ) else Modifier, - header = if (steam) ({ + ), + header = { TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), focusRequesters = tabFocus) - }) else null, + modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus) + }, ) { - if (!steam || tab == SteamSettingsTab.DISPLAY) { + if (tab == ModeSettingsTab.DISPLAY) { val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution) SettingsGroup(stringResource(R.string.display_session)) { ResolutionRow(host, s.resolution, s.panelSize, a.onResolution, @@ -241,7 +243,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { } } } - if (!steam || tab == SteamSettingsTab.CONTROLS) { + if (tab == ModeSettingsTab.CONTROLS) { SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) { ChoiceRow( host, "touch", stringResource(R.string.mode_touch), null, @@ -277,7 +279,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } - if (!steam || tab == SteamSettingsTab.SESSION) { + if (tab == ModeSettingsTab.SESSION) { SettingsGroup(stringResource(R.string.mode_session)) { ChoiceRow( host, "suspend", stringResource(R.string.mode_suspend), @@ -304,7 +306,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } - if (steam && tab == SteamSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) { + if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) { val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null && s.deckyInstalled != s.deckyLatestRelease.tag val status = when { @@ -363,7 +365,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } - if (steam && tab == SteamSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) { + if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) { ToggleRow( host, "steamdeck", stringResource(R.string.mode_deck_mode), stringResource(R.string.mode_deck_mode_hint), @@ -389,7 +391,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair) } } - if (steam && tab == SteamSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { + if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { val hint = when { s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off) !s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied) @@ -408,11 +410,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { } } } - if ((!steam || tab == SteamSettingsTab.GAMES) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) { + if ((tab == ModeSettingsTab.GAMES || (!steam && tab == ModeSettingsTab.DISPLAY)) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) { ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen), stringResource(R.string.display_force_fullscreen_hint), s.forceFullscreen, onChange = a.onForceFullscreen) } - if (steam && tab == SteamSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) { + if (steam && tab == ModeSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) { for (dir in s.addedGamesDirs) { val n = s.addedGames.count { it.folderPath.startsWith("$dir/") } ActionRow( @@ -437,7 +439,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) }, ) } - if (steam && tab == SteamSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) { + if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) { if (s.syncBackend != null) SettingsRow( stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint), @@ -460,7 +462,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { GameEnvironmentRow() } - if (steam && tab == SteamSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) { + if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) { ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio) ChoiceRow( host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint), @@ -469,7 +471,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic) } - if (steam && tab == SteamSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) { + if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) { val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage } val options = buildList { add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto))) @@ -505,7 +507,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { onChange = a.onStorageDiagnostics, ) } - if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) { + if (!steam && tab == ModeSettingsTab.DISPLAY && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) { ChoiceRow( host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint), listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer, 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 64f56873e..38c510ae6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -598,7 +598,7 @@ fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit) @Composable fun SettingsPage( host: MenuHost, - title: String, + title: String?, onBack: () -> Unit, eyebrow: String? = null, lede: String? = null, @@ -608,7 +608,7 @@ fun SettingsPage( scroll: androidx.compose.foundation.ScrollState? = null, scrollContent: Boolean = true, compactLayout: Boolean = false, - /** Controls that remain below the title while the settings scroll, such as a tab strip. */ + /** Fixed controls below the title, or beside Back when the title is omitted. */ header: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, @@ -624,7 +624,18 @@ fun SettingsPage( ), verticalArrangement = Arrangement.spacedBy(if (compactLayout) 2.dp else 0.dp), ) { - if (narrow) { + if (title == null) { + Rise(0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack) + if (header != null) Box(Modifier.weight(1f)) { header() } + } + } + } else if (narrow) { // Back and the title share one line: a 4:3 screen has no height to spare. Rise(0) { Row( @@ -683,7 +694,7 @@ fun SettingsPage( } } } - if (header != null) header() + if (header != null && title != null) header() Rise(3, Modifier.weight(1f).fillMaxWidth()) { Column( modifier = Modifier From 06e06b9673be9b875c4537bbc79c576d397a66d7 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:07:40 -0400 Subject: [PATCH 8/9] Fix MangoHud double-counting battery power and remaining life (#270) --- .../launcher/session/BatteryComponent.kt | 8 ++- .../launcher/session/BatteryComponentTest.kt | 71 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt 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 9b0834e17..fc6526f59 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt @@ -14,7 +14,7 @@ import kotlin.math.abs * The client reads `/sys/class/power_supply/BAT/...` - a laptop's or a Deck's naming and files * (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android * names its supply `battery` and lays it out differently, and on many phones an app may not read - * it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name) + * it at all, so the client showed no battery. This writes one `BAT1` (the Deck's name) * from Android's own battery API - the same on every device - into a directory the session binds * over `/sys/class/power_supply`, refreshed every few seconds while the session runs. * @@ -31,6 +31,8 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart private var thread: Thread? = null override fun start() { + // Older builds exported the same aggregate battery twice; MangoHud counts both supplies. + File(dir, "BAT0").deleteRecursively() running = true write() thread = Thread({ @@ -39,7 +41,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart if (running) write() } }, "battery-sysfs").apply { isDaemon = true; start() } - Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s") + Log.i(TAG, "battery: BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s") } override fun stop() { @@ -87,7 +89,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart pct <= 15 -> "Low" else -> "Normal" } - for (name in listOf("BAT0", "BAT1")) { + for (name in listOf("BAT1")) { val bat = File(dir, name).apply { mkdirs() } val attrs = linkedMapOf( "type" to "Battery", diff --git a/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt new file mode 100644 index 000000000..fd820e22b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt @@ -0,0 +1,71 @@ +package com.droiddeck.launcher.session + +import android.content.Intent +import android.os.BatteryManager +import java.io.File +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class BatteryComponentTest { + @get:Rule val tmp = TemporaryFolder() + private var battery: BatteryComponent? = null + + @After fun stop() { battery?.stop() } + + @Test fun oneAggregateBatteryKeepsOverlayPowerAndLifetimeConsistentWithQam() { + val supplies = tmp.newFolder("power_supply") + val vpower = tmp.newFolder("vpower") + start(supplies, vpower, -2_000_000L) + assertTelemetry(supplies, vpower) + } + + @Test fun upgradingRemovesTheDuplicateRatherThanLeavingItForOverlayDiscovery() { + val supplies = tmp.newFolder("power_supply") + val vpower = tmp.newFolder("vpower") + val legacy = File(supplies, "BAT0").apply { mkdirs() } + File(legacy, "power_now").writeText("8000000\n") + File(legacy, "charge_now").writeText("4000000\n") + start(supplies, vpower, 2_000_000L) + assertFalse(legacy.exists()) + assertTelemetry(supplies, vpower) + } + + private fun start(supplies: File, vpower: File, currentUa: Long) { + val context = RuntimeEnvironment.getApplication() + context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply { + putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING) + putExtra(BatteryManager.EXTRA_LEVEL, 50) + putExtra(BatteryManager.EXTRA_SCALE, 100) + putExtra(BatteryManager.EXTRA_VOLTAGE, 4000) + }) + shadowOf(context.getSystemService(BatteryManager::class.java)).apply { + setLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER, 4_000_000L) + setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW, currentUa) + setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE, currentUa) + } + battery = BatteryComponent(supplies, vpower).also { it.attach(context); it.start() } + } + + private fun assertTelemetry(supplies: File, vpower: File) { + val discovered = supplies.listFiles()!!.filter { it.name.contains("BAT") } + assertEquals(listOf("BAT1"), discovered.map { it.name }) + fun read(name: String) = File(discovered.single(), name).readText().trim().toLong() + // One 4 Ah battery at 4 V drawing 2 A: 8 W and two hours, regardless of vendor current sign. + assertEquals(8_000_000L, read("power_now")) + assertEquals(16_000_000L, read("energy_now")) + assertEquals(7200L, read("time_to_empty_now")) + assertEquals("7200", File(vpower, "secs_until_shutdown_request").readText().trim()) + assertEquals("50.00", File(vpower, "battery_percent").readText().trim()) + } +} From b89d87cbde09f0d80c2ff34df684d5f461110c9c Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:25:51 -0400 Subject: [PATCH 9/9] Add app UI scale presets to Setup (#272) --- .../com/droiddeck/launcher/MainActivity.kt | 9 +- .../com/droiddeck/launcher/core/AppUiPrefs.kt | 19 ++++ .../launcher/files/FileManagerActivity.kt | 2 +- .../launcher/files/FilePickerActivity.kt | 2 +- .../com/droiddeck/launcher/ui/AppScale.kt | 23 +++++ .../droiddeck/launcher/ui/FrontEndScreen.kt | 2 + .../droiddeck/launcher/ui/FrontEndSetup.kt | 9 ++ .../java/com/droiddeck/launcher/ui/Theme.kt | 16 +++- app/src/main/res/values/strings.xml | 3 + .../com/droiddeck/launcher/ui/AppScaleTest.kt | 90 +++++++++++++++++++ 10 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/core/AppUiPrefs.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AppScale.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/AppScaleTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 41e8241ba..0df7b586f 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -311,6 +311,7 @@ class MainActivity : ComponentActivity() { private var steamChannel by mutableStateOf("steamdeck_publicbeta") private var runSteamAtStartup by mutableStateOf(false) private var theme by mutableStateOf("graphite") + private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) private var hdrOn by mutableStateOf(false) private var fpsLimit by mutableStateOf(0) private var upscaler by mutableStateOf(0) @@ -480,6 +481,7 @@ class MainActivity : ComponentActivity() { displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager refreshPhantomStatus() theme = SessionPrefs.theme(this) + appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this) // Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it. steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this) backActionsInverted = SessionPrefs.backActionsInverted(this) @@ -489,7 +491,7 @@ class MainActivity : ComponentActivity() { applyLauncherFullscreen() updates.start() setContent { - DroidDeckTheme(theme) { + DroidDeckTheme(theme, appScale = appScale) { com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { val sm = settingsMode val page: (@Composable () -> Unit)? = when { @@ -516,6 +518,7 @@ class MainActivity : ComponentActivity() { lossless = lossless, pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, theme = theme, + appScale = appScale, isHomeApp = homeAppSelected, homeScreenEnabled = homeScreenEnabled, defaultHomeLabel = defaultHomeLabel, @@ -645,6 +648,10 @@ class MainActivity : ComponentActivity() { }, onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, + onAppScale = { percent -> + com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent) + appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this) + }, onLauncherFullscreen = { on -> SessionPrefs.setLauncherFullscreen(this, on) launcherFullscreen = on diff --git a/app/src/main/java/com/droiddeck/launcher/core/AppUiPrefs.kt b/app/src/main/java/com/droiddeck/launcher/core/AppUiPrefs.kt new file mode 100644 index 000000000..3021b7196 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/AppUiPrefs.kt @@ -0,0 +1,19 @@ +package com.droiddeck.launcher.core + +import android.content.Context + +object AppUiPrefs { + const val DEFAULT_SCALE = 100 + val scales = listOf(75, 85, 100, 115, 125, 150) + + internal fun normalizeScale(percent: Int): Int = percent.takeIf { it in scales } ?: DEFAULT_SCALE + + fun scale(context: Context): Int = normalizeScale( + context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).getInt("scale", DEFAULT_SCALE), + ) + + fun setScale(context: Context, percent: Int) { + context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit() + .putInt("scale", normalizeScale(percent)).apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt index 88c2462ed..c0a4bcec3 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt @@ -22,7 +22,7 @@ class FileManagerActivity : ComponentActivity() { } val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File) setContent { - DroidDeckTheme { + DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) { Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) { FileManagerScreen(initialDir = initialDir) } diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt index d892c1241..2e66d4daa 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt @@ -43,7 +43,7 @@ class FilePickerActivity : ComponentActivity() { ?.let { File(it) }?.takeIf { it.isDirectory } setContent { - DroidDeckTheme { + DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) { Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) { FileManagerScreen( pickMode = true, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AppScale.kt b/app/src/main/java/com/droiddeck/launcher/ui/AppScale.kt new file mode 100644 index 000000000..806ef22cd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/AppScale.kt @@ -0,0 +1,23 @@ +package com.droiddeck.launcher.ui + +import android.content.res.Configuration +import androidx.compose.ui.unit.Density +import com.droiddeck.launcher.core.AppUiPrefs +import kotlin.math.roundToInt + +internal fun scaledAppDensity(base: Density, percent: Int): Density { + val scale = AppUiPrefs.normalizeScale(percent) / 100f + return if (scale == 1f) base else Density(base.density * scale, base.fontScale) +} + +/** Responsive layouts and popup height limits must see the same dp space as the scaled UI. */ +internal fun scaledAppConfiguration(base: Configuration, percent: Int): Configuration { + val scale = AppUiPrefs.normalizeScale(percent) / 100f + if (scale == 1f) return base + return Configuration(base).apply { + if (densityDpi > 0) densityDpi = (densityDpi * scale).roundToInt() + if (screenWidthDp > 0) screenWidthDp = (screenWidthDp / scale).roundToInt() + if (screenHeightDp > 0) screenHeightDp = (screenHeightDp / scale).roundToInt() + if (smallestScreenWidthDp > 0) smallestScreenWidthDp = (smallestScreenWidthDp / scale).roundToInt() + } +} 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 d8f8b2275..49f72e124 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -101,6 +101,7 @@ class FrontEndState( val gameSyncFolder: String? = null, val pageKey: String? = null, val theme: String = Themes.GRAPHITE, + val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE, val isHomeApp: Boolean = false, val homeScreenEnabled: Boolean = false, val defaultHomeLabel: String? = null, @@ -168,6 +169,7 @@ class FrontEndActions( val onOffline: () -> Unit, val onPageBack: () -> Unit = {}, val onTheme: (String) -> Unit = {}, + val onAppScale: (Int) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, val onAnimationsEnabled: (Boolean) -> Unit = {}, val onStoreEnabled: (Boolean) -> Unit = {}, 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 d5d408414..e6b1777f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -311,6 +311,15 @@ internal fun SetupPanel( } 3 -> { SettingsGroup(stringResource(R.string.setup_launcher)) { + ChoiceRow( + host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), + com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent -> + percent to stringResource( + if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default + else R.string.ctrl_percent, percent, + ) + }, s.appScale, onPick = a.onAppScale, + ) SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) { Box { ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" } 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 331ad9507..45f9e4e35 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -5,9 +5,13 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import com.droiddeck.launcher.core.AppUiPrefs import com.droiddeck.launcher.session.SessionPrefs class Palette( @@ -67,7 +71,7 @@ val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) } val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03111F) else Color.White @Composable -fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) { +fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) { val id = theme ?: SessionPrefs.theme(LocalContext.current) val p = Themes.byId(id) val scheme = darkColorScheme( @@ -77,5 +81,13 @@ fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) { surfaceVariant = p.surfaceVariant, onSurfaceVariant = p.onSurfaceVariant, error = p.error, ) - CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, content = content) } + val baseDensity = LocalDensity.current + val baseConfiguration = LocalConfiguration.current + val density = remember(baseDensity, appScale) { scaledAppDensity(baseDensity, appScale) } + val configuration = remember(baseConfiguration, appScale) { scaledAppConfiguration(baseConfiguration, appScale) } + CompositionLocalProvider( + LocalPalette provides p, + LocalDensity provides density, + LocalConfiguration provides configuration, + ) { MaterialTheme(colorScheme = scheme, content = content) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19f259ef2..b75e9c932 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -595,6 +595,9 @@ Offline mode Sign in to Steam first Launcher + App scale + DroidDeck text and controls + %1$d%% (Default) Theme Launcher appearance Use as Home screen diff --git a/app/src/test/java/com/droiddeck/launcher/ui/AppScaleTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/AppScaleTest.kt new file mode 100644 index 000000000..56a6db50b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/AppScaleTest.kt @@ -0,0 +1,90 @@ +package com.droiddeck.launcher.ui + +import android.content.Context +import android.content.res.Configuration +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.core.AppUiPrefs +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class AppScaleTest { + private val context get() = RuntimeEnvironment.getApplication() + + @Before fun clearScale() { + context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun defaultsToSystemDensityAndCanRestoreItAfterChoosingAnotherScale() { + assertEquals(100, AppUiPrefs.scale(context)) + AppUiPrefs.setScale(context, 75) + assertEquals(75, AppUiPrefs.scale(context)) + AppUiPrefs.setScale(context, 100) + assertEquals(100, AppUiPrefs.scale(context)) + val density = Density(2f, 1.3f) + val configuration = Configuration(context.resources.configuration) + assertSame(density, scaledAppDensity(density, 100)) + assertSame(configuration, scaledAppConfiguration(configuration, 100)) + } + + @Test fun invalidStoredOrRequestedScaleFallsBackToTheDefault() { + context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().putInt("scale", 0).commit() + assertEquals(100, AppUiPrefs.scale(context)) + AppUiPrefs.setScale(context, 1000) + assertEquals(100, AppUiPrefs.scale(context)) + } + + @Test fun scalingDoesNotModifySessionPreferencesOrAndroidDisplayMetrics() { + val session = context.getSharedPreferences("session", Context.MODE_PRIVATE) + session.edit().putString("resolution.steam", "1280x720").putInt("upscaler", 2).commit() + val before = session.all.toMap() + val density = context.resources.displayMetrics.density + val configuration = Configuration(context.resources.configuration) + AppUiPrefs.setScale(context, 150) + scaledAppConfiguration(context.resources.configuration, AppUiPrefs.scale(context)) + assertEquals(before, session.all) + assertEquals(density, context.resources.displayMetrics.density, 0f) + assertEquals(configuration, context.resources.configuration) + } + + @Test fun textAndControlsScaleTogetherWhilePreservingAccessibilityFontScale() { + val base = Density(2f, 1.3f) + for (percent in AppUiPrefs.scales) { + val scaled = scaledAppDensity(base, percent) + assertEquals(1.3f, scaled.fontScale, 0f) + assertEquals(base.run { 44.dp.toPx() } * percent / 100f, scaled.run { 44.dp.toPx() }, 0.001f) + assertEquals(base.run { 16.sp.toPx() } * percent / 100f, scaled.run { 16.sp.toPx() }, 0.001f) + } + } + + @Test fun responsiveWidthAndPopupHeightFitTheSamePhysicalWindowAtEveryScale() { + val base = Configuration().apply { + densityDpi = 320 + screenWidthDp = 960 + screenHeightDp = 540 + smallestScreenWidthDp = 540 + fontScale = 1.3f + orientation = Configuration.ORIENTATION_LANDSCAPE + } + for (percent in AppUiPrefs.scales) { + val scaled = scaledAppConfiguration(base, percent) + val density = scaledAppDensity(Density(2f, 1.3f), percent).density + assertEquals(1920f, scaled.screenWidthDp * density, 2f) + assertEquals(1080f, scaled.screenHeightDp * density, 2f) + assertEquals(1080f, scaled.smallestScreenWidthDp * density, 2f) + assertEquals(1.3f, scaled.fontScale, 0f) + assertEquals(base.orientation, scaled.orientation) + } + assertEquals(960, base.screenWidthDp) + assertEquals(540, base.screenHeightDp) + assertEquals(320, base.densityDpi) + } +}