Compare commits

...
Author SHA1 Message Date
Kurt Himebauch 1d46d2461a feat(agent): setup and storage collapse into a summary card like Setup's, and Codex tool rows show the command without its shell wrapper 2026-11-10 00:40:36 -05:00
Kurt Himebauch 7426c6b682 fix(agent): install Codex's code-mode host with it, without which Codex runs no commands 2026-11-10 00:40:36 -05:00
Kurt Himebauch 0995cbd5c7 fix(agent): a resumed Claude Code turn no longer ends at the result for a background task the last run left behind 2026-11-10 00:40:36 -05:00
Kurt Himebauch 2cbb1e47e0 fix(agent): keep the previous turn's raw output too 2026-11-10 00:40:36 -05:00
Kurt Himebauch 9e3814b546 feat(agent): a turn that ends without a reply says so and offers Retry, the resume is retried once, and each turn's raw output is kept 2026-11-10 00:40:36 -05:00
Kurt Himebauch 3e349d4c78 fix(agent): setup's prepare runs the build itself instead of handing it to the app 2026-11-10 00:40:36 -05:00
Kurt Himebauch deb9e950b7 fix(agent): run the agent's Lab builds in the app's own process and continue the conversation that asked after the restart 2026-11-10 00:40:36 -05:00
Kurt Himebauch d3ae5579ad fix(agent): the notification names a prepare and shows its percent without repeating the title 2026-11-10 00:40:36 -05:00
Kurt Himebauch 58c83968de feat(agent): a setup step that prepares fast Lab builds for the installed version, kept alive while it runs 2026-11-10 00:40:36 -05:00
Kurt Himebauch eb3da7595f feat(agent): rebuild a kept Lab build from Updates, and tidy the chat's tool rows, costs, processes and times 2026-11-10 00:40:35 -05:00
Kurt Himebauch 90b80cb84b fix(agent): an old Lab build's progress is not shown as a failure, and a restart counts as success 2026-11-10 00:40:35 -05:00
Kurt Himebauch dd075ee138 fix(agent): check the Lab build less often while nothing is happening 2026-11-10 00:40:35 -05:00
Kurt Himebauch 173ffac05b feat(agent): conversation history, model and plan mode, live replies, full tool input and output, processes, attachments, queued messages and a Stop that ends the turn 2026-11-10 00:40:35 -05:00
Kurt Himebauch c64a72b2a1 feat(agent): DroidDeck opens on the Agent tab after restarting on a Lab build 2026-11-10 00:40:35 -05:00
Kurt Himebauch 9a6c46e4ac fix(agent): the build tools take about 5 GB 2026-11-10 00:40:35 -05:00
Kurt Himebauch fc46d3c658 feat(agent): show and remove what the agent stores in the Linux userland 2026-11-10 00:40:35 -05:00
Kurt Himebauch 53cdf00b43 feat(agent): continue the conversation after a Lab build restarts the app 2026-11-10 00:40:35 -05:00
Kurt Himebauch 7836c32aac fix(agent): leave room under the chat composer 2026-11-10 00:40:35 -05:00
Kurt Himebauch d9358e8f19 feat(agent): a header, a composer with send and stop, and Markdown replies in the agent chat 2026-11-10 00:40:35 -05:00
Kurt Himebauch 5d25f79fa7 fix(agent): switching harness clears the previous harness's setup results 2026-11-10 00:40:35 -05:00
Kurt Himebauch d44bf32ae8 fix(agent): start the adb server before connecting and retry the connection check 2026-11-10 00:40:35 -05:00
Kurt Himebauch bdaa155baa fix(agent): each harness has its own setup state and conversation 2026-11-10 00:40:35 -05:00
Kurt Himebauch 44e72258b1 fix(agent): show why a setup check failed 2026-11-10 00:40:35 -05:00
Kurt Himebauch 77e30e4b4e fix(agent): connect adb only for commands that use it and run setup checks one at a time 2026-11-10 00:40:35 -05:00
Kurt Himebauch 3f5e3bb19d fix(agent): install helpers off the main thread and offer sign-in only after the install 2026-11-10 00:40:35 -05:00
Kurt Himebauch 8ffac40b4f feat(agent): first-run setup in the Agent tab, a harness choice with Codex, and no permission prompts 2026-11-10 00:40:35 -05:00
Kurt Himebauch 8337166cc9 fix(agent): connect loopback adb, show unread replies, and fit the drawer page to the screen 2026-11-10 00:40:35 -05:00
Kurt Himebauch 2d15205ff7 feat(agent): the agent in the session drawer and as a bubble over the game 2026-11-10 00:40:35 -05:00
Kurt Himebauch 71481ac2ff feat(agent): an Agent tab in the launcher that talks to Claude Code outside any session 2026-11-10 00:40:35 -05:00
Kurt Himebauch 2e34213b28 feat(lab): dd-lab-build --explain logs why tasks ran and Kotlin's build report 2026-11-10 00:40:04 -05:00
Kurt Himebauch 05efdf61d8 fix(lab): a build whose last task was up to date no longer counts as failed 2026-11-09 23:59:53 -05:00
Kurt Himebauch f638979507 perf(lab): compile with the Kotlin daemon, since in-process Kotlin compiles always redo the whole app 2026-11-09 23:43:10 -05:00
Kurt Himebauch 9d73ff75bc perf(lab): BuildConfig describes the installed base, so a Lab commit does not change it 2026-11-09 23:15:23 -05:00
Kurt Himebauch 2213362336 fix(lab): the app's agent hands its builds to the app, so they outlive the agent's turn 2026-11-09 21:45:17 -05:00
Kurt Himebauch 5683be3b9b feat(lab): record which installed commit the build caches are warm for 2026-11-09 21:18:19 -05:00
Kurt Himebauch d7dd802429 feat(lab): a kept build for another version can be rebuilt 2026-11-09 21:17:02 -05:00
Kurt Himebauch 0cf83ce71d fix(lab): report a failed command as a failed build and update progress every five seconds 2026-11-09 21:11:33 -05:00
Kurt Himebauch acecbcd79a feat(lab): estimate the time left and compile the installed commit ahead of the first build 2026-11-09 20:57:57 -05:00
Kurt Himebauch b6d70b177d feat(lab): notify when DroidDeck comes back on a Lab build or turns one off 2026-11-09 20:57:57 -05:00
Kurt Himebauch 5f0519dfbf feat(lab): keep earlier Lab builds and list them in Updates 2026-11-09 20:57:57 -05:00
Kurt Himebauch 914c659f4a fix(lab): size the build heap from the device's memory 2026-11-09 19:05:31 -05:00
Kurt Himebauch a178553254 feat(lab): tell the app's own agent to continue after a Lab build restart 2026-11-09 18:25:14 -05:00
Kurt Himebauch 774abb738d fix(lab): dd-agent connects loopback adb before starting Claude Code 2026-11-09 17:26:10 -05:00
Kurt Himebauch 5d63cc3b9e feat(lab): build Lab builds on the device and report the APK commit to agents 2026-11-09 17:08:29 -05:00
Kurt Himebauch f3f3f9ce08 feat(lab): load an on-device Lab build over the installed app 2026-11-09 16:25:39 -05:00
MaxsTechReview 75a0230304 fix(input): Auto relative mouse stays off for direct touch without a mouse (#2305)
* fix(input): Auto relative mouse stays off for direct touch without a mouse

* fix(input): re-check relative mouse when an input device changes

* fix(input): reapply relative mouse after agent readiness

* fix(input): only the current session clears the agent-ready hook and readiness resets relative mouse retries
2026-11-09 15:02:12 -05:00
Kurt Himebauch 7c89c14a84 fix(dual-screen): drop the Dashboard notifications page and its notification listener (#2342)
* fix(dual-screen): drop the Dashboard notifications page and its notification listener

* test(session): freeze the clock in the memory-pressure policy harness
2026-11-09 12:48:00 -05:00
Kurt Himebauch b837611efe fix(ctl): a full disk no longer crashes the app when an agent call cannot write its answer (#2325) 2026-11-09 12:33:26 -05:00
Kurt Himebauch bf434b2652 fix(hud): steady frame graph on the bottom screen dashboard (#2345) 2026-11-09 12:30:41 -05:00
GiovanniCarbinatti 35941bb9e7 chore: update README.md (#2311) 2026-11-09 06:45:24 -05:00
MaxsTechReview 96a12465e0 fix(session): stop re-compressing game memory every 30 s when the game reads it straight back (#2312)
* fix(session): stop re-compressing game memory every 30 s when the game reads it straight back

On devices with 13 GB or less, the low-memory page-out skipped its
read-back backoff whenever memory was nearly exhausted. When the game
was actively using what was compressed, it read it straight back, so
the session pushed and re-read gigabytes every 30 seconds, pinning all
cores and freezing the game for 5-10 s each time. The read-back check
now applies on that path too: an effective page-out still repeats every
30 s, an ineffective one backs off as on the normal path.

* zRAM: keep the read-back backoff on the nearly-out-of-memory path

* zRAM: retry a skipped page-out after 30 s when nearly out of memory
2026-11-09 02:08:40 -05:00
Kurt Himebauch 88d6b70881 fix(ctl): reach the agent bridge by broadcast when the shell has no content command (#2321) 2026-11-09 00:00:20 -05:00
MaxsTechReview 29823bb1bf fix(steam): Linux-only tools keep their native platform (#2313)
* fix(steam): reconcile tool dependencies by supported platform

* fix(steam): refresh tool metadata and clear stale GE mappings

* fix(steam): clear any Proton mapping from Linux-only tools

* fix(steam): watch folders the client creates after the helper starts

* Watch each missing directory level as it appears

* Make the watcher level test wait on inotify readiness
2026-11-08 22:22:22 -05:00
MaxsTechReview 92a7124589 fix(gpu): skip system Vulkan pruning when the runtime pack is absent (#2316)
fix: skip pruning system Vulkan runtimes when the runtime pack is not installed
2026-11-08 22:07:39 -05:00
Kurt Himebauch bf95a40e3e fix(ci): post new Discord notice for each PR build (#2314)
Post a fresh signed test-build notice for each new PR commit, retire the
previous notice, and include the latest commit subject.
2026-11-08 21:19:22 -05:00
Kurt Himebauch 1355cb3a2b fix(ci): handle empty commit lookup responses 2026-11-08 21:13:53 -05:00
Kurt Himebauch 51407155fc fix(ci): keep commit lookup failures from blocking notices 2026-11-08 21:12:58 -05:00
Kurt Himebauch b4a953c659 fix(ci): post new Discord notice for each PR build 2026-11-08 21:08:22 -05:00
MaxsTechReview 5e438e9248 fix(gog): owned games the listing leaves out stay owned, and search reaches GOG's catalog (#2296)
* fix(gog): owned games the library listing leaves out stay owned

Since the GOG library is read from account/getFilteredProducts, a game
that listing does not return (one hidden in GOG Galaxy, for example)
dropped out of the library, and with it out of the GOG tab, which shows
owned games and the shelves. 0.5.0 read every owned id from
user/data/games and showed them.

The sync now takes the union of the listing and user/data/games. The
listing still names most games at once, and an owned id it lacks is
fetched like any other.

* fix(gog): the GOG tab's search reaches GOG's catalog

GogStoreCatalog.search was never wired to the backend, so searching the
GOG tab only filtered the owned library and the featured shelves; a game
that is neither could not be found. GogBackend now answers search from
GOG's catalog, like the other stores.

* fix(gog): keep cached owned games when the account list is unavailable
2026-11-08 19:37:20 -05:00
MaxsTechReview 6b110581ac fix(gog): install scripts' support files reach the prefix (#2294)
* fix(gog): install scripts' support files reach the prefix

A goggame-<id>.script can copy files into the prefix with supportData
actions: default INI files, Plugins.txt and other settings that mods such
as one-click editions need. Only setRegistry actions were applied, so
those games started without their configuration and ran as the base game.

droiddeck-store-launch now applies supportData after the registry, once
per script revision: copies from {app} and {supportDir} into {userdocs}
and {localappdata}, matching paths without regard to case. Links are
copied as links, paths outside those folders are refused, and existing
files are replaced only when the action asks for it.

* Keep GOG support data copies inside the game and prefix folders

* Copy linked GOG support sources as links and keep a player's own links
2026-11-08 18:45:53 -05:00
MaxsTechReview 92742f37c1 fix(preload): answer netlink requests sent with write and writev (#2304) 2026-11-08 18:40:57 -05:00
MaxsTechReview ecd916d43f fix(input): show the panel tabs under the controls-below-the-game bar (#2302)
* fix(input): show the panel tabs under the controls-below-the-game bar

* fix(input): take the tab row out of the game, not the controls below it
2026-11-08 18:32:40 -05:00
Kurt Himebauch a7c10e3f51 fix(session): give Steam a session bus so launches stop hanging at the install script (#2303) 2026-11-08 16:53:01 -05:00
MaxsTechReview 1760f008ae fix(desktop): Flatpak and Snap apps get the game GPU setting (#2295)
The session's TU_DEBUG=sysmem is meant for the Steam client and
Chromium-based apps. Flatpak and Snap apps inherited it, so emulators
and other programs installed that way rendered through system memory.

droiddeck-game-env now holds the Chromium check next to game_gpu, and a
small droiddeck-game-gpu wrapper applies both to any program. The
AppImage, Flatpak and Snap launchers use it, so the rule lives in one
place.
2026-11-08 14:39:31 -05:00
MaxsTechReview 3bb279f635 fix(appimage): emulators and other AppImage programs get the game GPU setting (#2288)
The session sets TU_DEBUG=sysmem for the Steam client's interface and hands
games their own value in DROIDDECK_GAME_TU_DEBUG, which droiddeck-game-env
applies to Steam launches (#1188). droiddeck-appimage-run never did, so
emulators and other AppImage programs rendered every pass through system
memory instead of Turnip's per render pass choice.

Apply the game value in droiddeck-appimage-run the same way: an empty
value unsets TU_DEBUG, any other value replaces it. Chromium-based apps,
like the Steam client's own interface, keep the session's setting.
2026-11-08 12:23:30 -05:00
MaxsTechReview 3fef1f3fa1 fix(proton): a Proton picked for a game in Steam is kept (#2284)
fix(proton): a Valve Proton picked for a game in Steam stays on the ARM64 build made from it

Steam lists Valve's own Proton Experimental and Proton 11.0 next to the
DroidDeck tools. They are not runnable here, so a per-game pick of one was
replaced with the default. That used to be Proton Experimental ARM64, so the
pick looked kept; since an installed DroidDeck-Proton became the default, the
game is moved to DroidDeck-Proton instead.

When Steam records one of those tools for a title the sync already knows
(a title it mapped or one with a recorded pick), the title now gets the
DroidDeck tool built from the same Valve depot, stops following the default
and the choice is mirrored into the app's per-game setting. The same applies
to Steam's default. Titles nobody picked anything for still follow the
default, and Valve tools with no ARM64 build still fall back to it.
2026-11-08 12:03:21 -05:00
MaxsTechReview a4f26799ab fix(mesa): Zink on PowerVR shows colour again instead of grayscale (#2287)
Mesa 26.0.8, which the Ubuntu runtime builds, carries the backport of
"zink: create views for samplers lazily", which dropped the depth/stencil
aspect check in init_sampler_view(). On drivers with
needs_zs_shader_swizzle (Imagination) every colour view with a constant-1
alpha, such as RGBX/BGRX textures, then got the red-only depth swizzle
view, so Steam's interface and games showed red as R=G=B.

Carry Icenowy Zheng's upstream fix d55418dae7 (Mesa MR 41923), which
restores the aspect check. It applies on top of patch 0004.
2026-11-08 11:48:11 -05:00
MaxsTechReview 08a6a3cba8 ci: run approved fork PR compile jobs on the disposable fork runner (#2246)
* ci: run approved fork PR compile jobs on the disposable fork runner

* ci: ignore fork runner heartbeats from the future

* ci: target the fork runner by its own label

* Read the fork runner heartbeat from a public gist

* Fall back to an anonymous read of the fork runner heartbeat
2026-11-08 11:40:12 -05:00
MaxsTechReview 005fec2125 chore: pin gamescope to the DroidDeck-Components p23 release (#2285) 2026-11-08 11:37:54 -05:00
MaxsTechReview 2e70b8ed73 fix(gamescope): taps reach dialogs and windows away from the screen origin (#2280) 2026-11-08 10:44:00 -05:00
MaxsTechReview 74c0f2127a fix(proot): lay out ARM64 programs like the kernel so large reservations fit (#2279) 2026-11-08 10:43:57 -05:00
Kurt Himebauch 2bd7df5e1a chore: bump version to 0.7.0 (18) (#2278) 2026-11-08 09:36:10 -05:00
Kurt Himebauchandmaxjivi05 b7b96875a1 docs: DroidDeck 0.7.0 release notes (#2170)
* docs: DroidDeck 0.7.0 release notes

* docs: add 0.7.0 release note media

* docs: drop the 30 second summary from the 0.7.0 notes

* docs: add the dual-screen Dashboard shots to the 0.7.0 notes

* docs: show the Dashboard as one stacked dual-screen image

* docs: add the swap screens GIF to the 0.7.0 notes

* docs: swap in the new Syncthing screenshot

* docs: add the HUD dock GIF to the 0.7.0 notes

* docs: add the Game Jolt screenshot to the 0.7.0 notes

* docs: show the full screen in the HUD dock GIF

* docs: merge the GPU bullets, add the no proprietary components line, put images above their bullets

* docs: add the 0.7.0 APK download button to the release notes

* docs: add the first-time contributors to the 0.7.0 notes

* docs: add KeegantheVeganat0r as a first-time contributor

* docs: add the shared Steam libraries screenshot to the 0.7.0 notes

* docs: rewrite the shared Steam libraries bullet and move it up

* docs: add the Mali support photo to the 0.7.0 notes

* docs: center 0.7.0 release media

* docs: credit The412Banner for Mali support in 0.7.0 notes

* docs: add the KDE desktop GIF to the 0.7.0 notes

* docs: add the desktop and swapped game clips to the 0.7.0 notes

* docs: use the Duo photo for the swapped screens

* docs: replace the swap screens GIF with the Duo recording

* docs: drop the swap screens GIF

* docs: keep a blank line before each release image

* docs: tweak 0.7.0 release notes

* docs: move smaller 0.7.0 items to improvements

* docs: fold the bottom-screen HUD into the Strip HUD bullet

* docs: pair the swap screens and KDE desktop media

* docs: move Game Jolt and Syncthing up in 0.7.0 features

* docs: put the swap screens and KDE desktop cluster after Mali in 0.7.0 features

* docs: reword shared Steam libraries and reorder KDE desktop bullet

* docs: add recent merged PRs to the 0.7.0 notes

* docs: auto component updates follow DroidDeck-Proton only

* docs: add input profiles, low-RAM memory, driver Auto and other recent changes to the 0.7.0 notes

* docs: add the second-screen layout editor to the 0.7.0 controller profiles notes

* docs: refresh the 0.7.0 pull request and contributor counts, mention controller profiles in the summary

* docs: 0.7.0 notes add the latest fixes and the DroidDeck-Proton, DD-Turnip and component changes

Adds the user-visible pull requests merged after the last refresh (Library
sort, runtime pack update, desktop app launches, GitHub AppImage variant,
DroidDeck-Proton default, component auto-update cleanup, UBWC and FEX preset
settings, controller navigation) and a section for what shipped in
DroidDeck-Proton 11-11.10 to 11-12.3, DD-Turnip 0.4.0 and DroidDeck-Components
since 0.6.9.

---------

Co-authored-by: maxjivi05 <[email protected]>
2026-11-08 09:32:48 -05:00
MaxsTechReview 51e5e3a2a4 ci: a finished build reports on pull requests from forks (#2277)
When a Build APK run finishes, Build validation finds its pull request with
commits/{sha}/pulls, which GitHub answers with an empty list for a commit that
only exists on a fork. The validator then took the build as superseded, so the
"Build APK" status of every fork pull request stayed at "awaiting APK
compilation" after a successful build. It now falls back to the open pull
request for the run's head owner and branch, still requiring the head commit
to match.
2026-11-08 09:07:36 -05:00
MaxsTechReview c755d05473 ci: cache the pinned Turnip archive and retry its download (#2276)
* ci: cache the pinned Turnip archive and retry its download

"Prepare graphics driver variants" fetched the same checksum-pinned Banners-Turnip
zip from GitHub on every run with a single 120 s attempt, so a 502 or a read
timeout failed whole PR builds three times today. The archive is now cached
between runs (it is still checked against its SHA-256 after a restore), and a
cold download is retried on timeouts, 429 and 5xx with a growing pause.

* ci: keep PowerVR's verified downloads between runs

tools/powervr/fetch.py already keeps checksum-verified downloads in
~/.cache/droiddeck-build, but CI never saved that folder, so every run fetched
the PowerVR components again and a read timeout failed "Build the add-on
packs". The folder joins the existing pack-inputs cache, keyed on
release.json too.
2026-11-08 09:07:31 -05:00
MaxsTechReview aa883830ca fix(session): give the runtime a machine ID so D-Bus programs such as melonDS start (#2275)
* fix(session): give the runtime a machine ID so D-Bus programs start

The Ubuntu runtime ships an empty /etc/machine-id for systemd to fill on first
boot, and nothing fills it in a DroidDeck session. libdbus aborts when it needs
the ID ("D-Bus library appears to be incorrectly set up"), so melonDS closed at
once from the desktop and GTK Flatpaks started outside the session bus failed.

The session now writes a fresh ID once when the file is empty or malformed and
never changes a valid one.

* fix(session): check the whole machine ID file and reject the all-zero ID
2026-11-08 08:54:56 -05:00
MaxsTechReview 45e39b7aaf fix(desktop): catalog AppImages such as RPCS3 start from the desktop menu again (#2274)
fix(desktop): catalog AppImages start from the desktop menu through the shipped runtime

RPCS3 from the desktop menu exited at once: its own AppImage runtime runs
dwarfsextract from a memfd, which PRoot cannot execute (EACCES), and it has no
fallback. Emulator rail launches already avoid this by starting arm64 images
through the shipped uruntime (#1191); the menu entries the catalog writes did not.

The rule now lives in one launcher, droiddeck-appimage-exec, used by the session
and by the catalog's menu entries. Entries written by older versions are moved
to it with the existing menu migration.
2026-11-08 08:39:23 -05:00
MaxsTechReview bc99cacd02 fix(desktop): a GitHub link to one AppImage installs and updates that variant (#2273)
* fix(desktop): a GitHub link to one AppImage installs and updates that variant

* fix(desktop): latest-release links, bad escapes and 32/64-bit variants

A /releases/latest/download/<file>.AppImage link now names its asset too, a
malformed %-escape in the field no longer throws while the dialog composes,
and x86-32 and x86_64 assets no longer collapse to the same variant once
version numbers are dropped.
2026-11-08 08:07:28 -05:00
MaxsTechReview 2beeb715fd fix(runtime): an update that brings a new PRoot pack no longer asks to install the runtime again (#2270)
* fix(runtime): an update that brings a new PRoot pack no longer asks to install the runtime again

* A pack-only install recovers an interrupted swap first and falls back to the full install
2026-11-08 06:27:33 -05:00
MaxsTechReview 39469e6e8f fix(proton): an installed DroidDeck-Proton replaces a Proton Experimental default nobody picked, and added shortcuts follow it (#2266)
* fix(proton): an installed DroidDeck-Proton replaces a Proton Experimental default nobody picked, and added shortcuts follow the default

* test: an owned shortcut left on the previous default follows a default change
2026-11-08 06:07:53 -05:00
MaxsTechReview 15baa296bb fix(storage): a Second library folder that is there is used even when its volume state isn't mounted (#2258)
fix(storage): a saved game folder that is there stays the library
2026-11-08 00:32:04 -05:00
MaxsTechReview e4a4775be5 fix(input): taps outside the PC keyboard reach the game while it is open (#2257)
* fix(input): taps outside the PC keyboard reach the game while it is open

* fix(input): the game's cursor stays visible while only the PC keyboard is open

* fix(input): a touch cancelled mid-stream stays closed until the finger lifts

* A resize also ends a pending two-finger touch

* A new touch clears a dismissal left by a stream that ended elsewhere

* A new touch is not held back by a three-finger tap whose end was consumed
2026-11-07 23:24:53 -05:00
MaxsTechReview 77ef10d601 fix(library): added and Battle.net games sort by when they were played and installed (#2256)
* fix(library): added and Battle.net games sort by when they were played and installed

Recently played and Recently installed put every added game, Battle.net
games included, at the bottom: nothing recorded a launch or an install time
for them, and the Steam play-time lookup was never reached once an entry had
a game. Launching an added game now records its play time, added games carry
their folder's install time (kept in the library cache), Battle.net installs
report theirs, and a zero time falls through to the next source.

* fix(library): an added game's play time is recorded only after it launches and kept when it is rebuilt

* fix(library): an added game's play time is recorded only when its launch is accepted

* fix(library): an added game's play time is recorded when its session actually starts

* fix(library): an added game's play time is recorded when its session service starts
2026-11-07 23:17:43 -05:00
MaxsTechReview 914b3588e1 fix(drivers): leaving a driver bundle no longer keeps its runtime half, and the Auto display picks the latest pair (#2255)
* fix(drivers): leaving a driver bundle's display half also drops its runtime half

Picking either half of an Android + Linux bundle selects both, but picking
another display driver afterwards changed only the display. The session
kept loading the bundle's Linux driver, so switching back to DroidDeck's
drivers could still leave the session failing to start. The runtime driver
now goes back to the default when the display leaves the active bundle; a
runtime driver chosen on its own is kept.

* fix(drivers): picking the Auto display hands both halves to Auto's latest DD-Turnip pair

* fix(drivers): the Auto display pick puts the display on Auto even when no pair fits the GPU
2026-11-07 21:47:04 -05:00
MaxsTechReview 2ade269be3 fix(gamescope): direct-touch drags work again in games that confine a hidden cursor (#2220)
* gamescope: a confine is relative only while it holds the X pointer

Patch 0128 made every hidden-cursor confine relative. Games that hide the
system cursor, draw their own and confine the pointer to their window then
took the host lock, and a direct touch drag became camera motion with no
button held, so cards and items could not be dragged.

A confine now goes relative once the X pointer stays away from the seat
position while input moves, as when a game recentres it or clips it to a
small rectangle, and returns to absolute when the pointer follows again or
the confine ends. Locks and the warp follow rule are unchanged.

* gamescope: a constraint without a hint is relative only while the game holds the X pointer

Xwayland turns a hidden-cursor grab into a lock with no cursor position hint, so the confine-only rule never applied to games that draw their own cursor (Stardew Valley, Balatro). A hinted constraint is relative at once, as upstream. An unhinted lock or confine goes relative once the X pointer stops following the seat's motion, which a recentring or clipping game does and a drawn-cursor game does not.

* gamescope: a pointer constraint is relative only while the game holds the X pointer

Stardew Valley's grab with a hidden cursor becomes an Xwayland lock with a cursor position hint, which upstream made relative at once. Without relative mode that lock still follows gamescope's relative motion and touches still place the pointer, so the hold rule now decides for every constraint, hinted or not.

* gamescope: a hinted lock starts relative and is released once the X pointer follows

Releasing a hinted lock only through the hold rule let a recentring game's first touch swipe press the button. A hinted lock now starts relative as upstream and is released after 30 moving frames on which the X pointer follows the motion gamescope sends, counted from the relative motion while the seat is locked.

* gamescope: count away frames within one half-second window

* gamescope: a hint that arrives with the game's first warp starts the lock relative

* fix(input): end the touch press when a program locks the pointer mid-swipe

A game that only shows its pointer lock after its first warp took the first
swipe as an absolute drag, with the button held until the finger lifted.
The first locked move now ends the forwarded touch before sending deltas.

* gamescope: a new focus window or constraint starts its own pointer hold

A held state from one constrained window carried over to the next one
when focus moved before a reset frame. Track the input focus window and
the constraint, and restart the hold when either changes.

* gamescope: read the pointer constraint under the wlserver lock

GetCursorConstraint asserts the lock is held, so the hold tracker
aborted as soon as a program constrained the pointer.

* fix(input): a mid-swipe pointer lock ends the touch but keeps a held button

The first locked move cancelled the whole direct-touch hold, releasing a
right button held by a long press. It now cancels only the forwarded touch;
the held button is released when the finger lifts, as before.

* fix(input): extra fingers during a locked swipe stay out of the cancelled touch

Once a locked swipe has ended the forwarded touch, a second finger restarted
it with a new touch-down while the swipe carried on as relative motion. Extra
fingers are now ignored until the swipe ends; the final lift still releases a
held button.

* fix(input): a swipe that ended its touch stays closed until the finger lifts
2026-11-07 21:30:21 -05:00
MaxsTechReview 0d034b1996 fix(input): restore the rest of what #1439 removed, and keep the editor on the bottom screen (#2242)
* fix(session): close the bottom panel from the docked menu's Close again

* fix(input): keep imported screen keys on screen when the gamepad is hidden

Screen keys used to be shown on their own, whatever the gamepad did. Since
they were merged into the gamepad layout they disappeared with a controller
connected, in desktop sessions, with on-screen controls set to Never, in
Steam + QAM mode and in Steam's touch mode.

Elements now carry an always-shown flag. The screen key migration sets it on
imported keys (and once on keys merged by earlier builds), and the on-screen
controls keep flagged elements drawn while the rest of the pad is hidden,
in buttons-only mode and with the quick toggle, without duplicating them in
the unfolded strip.

* fix(dual-screen): give the profile controls panel the bottom bar with swap

* fix(dual-screen): keep the layout editor on the bottom screen

The bottom panel was recreated above an open input editor or input
settings window whenever it closed and reopened (session stop/start, or
the bottom screen briefly reported as taken), hiding the editor. The panel
now waits while one of our input windows holds its display and reopens
when the last one closes.

An app arrival recorded before one of our windows covered the bottom
screen no longer marks the screen taken, the editor picks its display only
when a display is added or removed, and it skips a bottom screen another
app holds.

* fix(input): label keyed D-pads and sticks with their keys

Screen key D-pads and sticks used to show each direction's key (W, A, S, D)
when labels were on. The unified renderer drew no direction labels and only
offered Show label for buttons and gamepad sticks, so imported labels did
nothing.

D-pads and sticks bound to directional keys now draw each bound key's name
at its direction through the shared key-label helper, and Show label is
offered for every D-pad and stick.

* fix(input): remember the layout editor's snap choice

* refactor(input): drop controller pref setters and drawer field nothing uses

* fix(input): restore the Deck tabs and right trackpad in the unfolded strip

With Deck controls, the unfolded strip used to offer the Gamepad, Grips,
Left, Right and Both tabs and put a trackpad in the right stick slot. The
unified strip only showed the gamepad with two sticks.

For Deck pads the strip bar now carries the shared Deck panel tabs and
title, the other tabs show the same stock Deck panel layouts as the second
screen, and the docked gamepad takes its stick or trackpad per side from the
Deck gamepad layout. Other pads keep the plain two-stick strip.

* fix(input): carry the Deck gamepad stick or trackpad choice into its layout

The Deck controls Gamepad tab used to let each side switch between a stick
and a trackpad. That choice is now part of the editable Deck gamepad
layout, but the saved choice was never moved over and its setter was left
without callers.

The library upgrade now applies a saved non-default choice to the Deck
gamepad layout once, only while that layout is still the stock one, and the
unused setter is removed.

* chore(i18n): drop the Deck surface switch string nothing shows

* fix(input): offer Show label only where a label can be drawn

* fix(input): Show label follows the unsaved action and diagonal-only sticks

The element properties now get the editor's draft action, so changing a
stick or D-pad to keys offers Show label before saving. A stick bound only
on its diagonals counts as labelled too.

* fix(input): only sticks count their diagonal keys for Show label

Diagonal key names are drawn on sticks only, so a D-pad bound just on its
diagonals no longer offers a label it can't show.
2026-11-07 21:04:34 -05:00
204 changed files with 7501 additions and 812 deletions

No files matched your search

+1
View File
@@ -10,3 +10,4 @@ tools/mangoapp/mangoapp text eol=lf
app/src/main/assets/desktop-removal/*.json text eol=lf
app/src/main/assets/desktop-removal/*.txt text eol=lf
*.pdf binary
app/src/main/assets/agent/* text eol=lf
+10 -3
View File
@@ -313,6 +313,11 @@ jobs:
- name: Build the DiRT 3 / DiRT Showdown ws2_32 stand-in
run: python3 tools/components/prepare.py dirt-ws2
- name: Cache graphics driver inputs
uses: actions/cache@v4
with:
path: app/build/quest-inputs
key: quest-inputs-${{ hashFiles('tools/quest/build-driver.py') }}
- name: Prepare graphics driver variants
run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
- name: Record and pack prepared components
@@ -558,9 +563,11 @@ jobs:
- name: Cache checksum-pinned pack inputs
uses: actions/cache@v4
with:
path: ~/.cache/droiddeck-pack-inputs
key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
restore-keys: pack-inputs-v1-
path: |
~/.cache/droiddeck-pack-inputs
~/.cache/droiddeck-build
key: pack-inputs-v2-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env', 'tools/powervr/release.json') }}
restore-keys: pack-inputs-v2-
- name: Build the add-on packs
run: |
python3 tools/powervr/fetch.py
+23 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 2
outputs:
labels: ${{ steps.runner.outputs.labels || '"ubuntu-24.04"' }}
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
steps:
- name: Pick the compile runner
id: runner
@@ -36,3 +36,25 @@ jobs:
fi
echo "labels=$labels" >> "$GITHUB_OUTPUT"
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
- name: Pick the fork runner
id: fork
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
labels='"ubuntu-24.04"'
gist=https://api.github.com/gists/0c7ed7ad6d6493de84f5123689c64ae6
body=$(curl -fsS --max-time 15 -H "Authorization: Bearer $GH_TOKEN" "$gist" 2>/dev/null || curl -fsS --max-time 15 "$gist" 2>/dev/null || echo '{}')
IDLE_AT=$(jq -r 'select(.owner.login == "maxjivi05") | .files["idle-at"].content // "0"' <<< "$body" 2>/dev/null || echo 0)
echo "Heartbeat: ${IDLE_AT:-none}"
age=-1
if [[ "${IDLE_AT:-0}" =~ ^[0-9]+$ ]] && [ "${IDLE_AT:-0}" -gt 0 ]; then
age=$(( $(date +%s) - IDLE_AT ))
fi
if [ "$age" -ge 0 ] && [ "$age" -lt 180 ]; then
labels='"droiddeck-fork"'
fi
echo "labels=$labels" >> "$GITHUB_OUTPUT"
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
+6 -5
View File
@@ -7,10 +7,10 @@
DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linux desktop, integration with GOG, Epic Games, Xbox, and other storefronts, Steam VR, and more!
<p align="center"><b>100% open source. No ads. No telemetry. No source withheld.</b></p>
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
<p align="center">
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.6.9%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.6.9"></a>
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.0/DroidDeck-0.7.0.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.7.0%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.7.0"></a>
<a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a>
</p>
@@ -36,9 +36,9 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu
## Install
1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx is experimental. Mali, Xclipse, PowerVR and Adreno 710 are unsupported. No root needed.
1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx, Mali, Xclipse and PowerVR are experimental. Adreno 710 is currently unsupported. No root needed.
2. About 3 GB free for the runtime, plus 1.1 GB for the desktop and emulators.
3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk), install the Linux runtime, press **Play** and sign in to Steam.
3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.0/DroidDeck-0.7.0.apk), install the Linux runtime, press **Play** and sign in to Steam.
4. On Android 14 and newer, turn on **Disable child process restrictions** in Developer options. On Android 12 and 13, Steam's first launch shows a **Fix it for me** button that walks you through it.
Smart glasses setup is in the [support matrix](docs/smart-glasses-support.md). Release notes are in [docs/releases](docs/releases).
@@ -79,7 +79,8 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
- Zink on PowerVR keeps colour textures in colour with Icenowy Zheng's upstream [Mesa](https://gitlab.freedesktop.org/mesa/mesa) fix d55418dae7 (MIT), carried as a runtime patch.
- Games and AppImage, Flatpak and Snap programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`.
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
- The arm64 client's sign in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest).
+2 -2
View File
@@ -131,8 +131,8 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 17
versionName "0.6.9"
versionCode 18
versionName "0.7.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
+9 -8
View File
@@ -91,14 +91,6 @@
</intent-filter>
<meta-data android:name="android.accessibilityservice" android:resource="@xml/home_button_service" />
</service>
<service android:name=".session.BottomNotificationListener"
android:label="@string/bottom_notifications_listener"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<receiver android:name=".core.ShortcutsListingReceiver" android:exported="false"
android:process=":shortcuts_sync">
<intent-filter>
@@ -310,6 +302,7 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:theme="@style/SessionTheme" />
<service android:name=".agent.AgentHostService" android:exported="false" />
<service
android:name=".session.SessionService"
android:exported="false"
@@ -352,6 +345,14 @@
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<receiver
android:name=".agent.AgentCallReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="${applicationId}.AGENT_CALL" />
</intent-filter>
</receiver>
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Opens a URL in Android's browser on the display the agent is being used on ($DD_BROWSER_DISPLAY).
url=$1
[ -n "$url" ] || exit 1
serial=${ADB_SERIAL:-127.0.0.1:5555}
adb connect "$serial" >/dev/null 2>&1
quoted=$(printf "%s" "$url" | sed "s/'/'\\''/g")
adb -s "$serial" shell "am start --display ${DD_BROWSER_DISPLAY:-0} -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
echo "Open this on any device: $url" >&2
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# GitHub sign-in through Android's browser, with the one-time code typed in for the person.
serial=${ADB_SERIAL:-127.0.0.1:5555}
display=${DD_BROWSER_DISPLAY:-0}
adb connect "$serial" >/dev/null 2>&1
coproc GH { BROWSER=true gh auth login --hostname github.com --git-protocol https --web 2>&1; }
typed=
while IFS= read -r line <&"${GH[0]}"; do
printf '%s\n' "$line"
code=$(grep -oE '[A-Z0-9]{4}-[A-Z0-9]{4}' <<<"$line" | head -1)
if [ -n "$code" ] && [ -z "$typed" ]; then
typed=1
echo >&"${GH[1]}"
adb -s "$serial" shell "am start --display $display -a android.intent.action.VIEW -d https://github.com/login/device" >/dev/null 2>&1
# Chrome drops characters from `input text` and from fast key events on this page.
keys=
for ((i = 0; i < ${#code}; i++)); do
c=${code:i:1}
[ "$c" = - ] || keys+="input -d $display keyevent KEYCODE_$c; sleep 0.4; "
done
(sleep 7; adb -s "$serial" shell "$keys" >/dev/null 2>&1) &
fi
done
wait "$GH_PID"
@@ -7,8 +7,11 @@ import com.droiddeck.launcher.session.EnhancedSessionLogShare
import com.droiddeck.launcher.session.GpuClockPin
class App : Application() {
override fun attachBaseContext(base: android.content.Context) =
override fun attachBaseContext(base: android.content.Context) {
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName() else packageName
com.droiddeck.launcher.lab.LabOverlay.load(this, process == packageName)
}
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
@@ -39,6 +42,9 @@ class App : Application() {
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" ||
process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return
CrashHandler.install(this)
com.droiddeck.launcher.agent.AgentHost.refresh(this)
if (com.droiddeck.launcher.agent.AgentHost.available) com.droiddeck.launcher.lab.LabNotice.openOnInstall = "agent"
com.droiddeck.launcher.lab.LabNotice.started(this)
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
@@ -603,11 +603,19 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
}
fun selectDriver(id: String, linux: Boolean) {
if (!linux && id == TurnipDriver.AUTO) {
DriverBundle.selectDisplay(activity, id)
pairModeOpen = false
lastAutoCheck = 0L
setMode(auto = true)
refreshDrivers()
return
}
val bundle = DriverBundle.containing(activity, id, linux)
when {
bundle != null -> useBundle(bundle)
linux -> SessionPrefs.setLinuxDriver(activity, id)
else -> SessionPrefs.setAndroidDriver(activity, id)
else -> DriverBundle.selectDisplay(activity, id)
}
pairModeOpen = false
setMode(auto = false, mix = true)
@@ -23,6 +23,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -873,6 +874,7 @@ class FrontEndActivity : ComponentActivity() {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
Library.recordPlayed(this, game)
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
.returnTo(returnHome)
@@ -888,7 +890,15 @@ class FrontEndActivity : ComponentActivity() {
putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
}
}, steamSession = true)
}
.playing(game), steamSession = true)
}
private fun Intent.playing(game: Library.SteamGame): Intent =
apply { if (game.library == Library.ADDED) putExtra(SessionActivity.EXTRA_PLAYED_GAME, game.gameIdString) }
private fun recordPlayed(intent: Intent) {
intent.getStringExtra(SessionActivity.EXTRA_PLAYED_GAME)?.let { Library.recordPlayed(this, it) }
}
private fun Intent.returnTo(returnHome: Boolean): Intent {
@@ -915,14 +925,17 @@ class FrontEndActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
.playing(game)
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, next, returnHome) -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> {
recordPlayed(next)
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
else -> pendingSwitch = next
}
}
@@ -1091,6 +1104,8 @@ class FrontEndActivity : ComponentActivity() {
gameSpaceOffered = savedInstanceState?.getBoolean(STATE_GAME_SPACE_OFFERED) == true
if (savedInstanceState == null) readIntegrationIntent(intent)
readGameIntent(intent)
// A Lab build is usually the agent's work, so the app comes back where the agent is.
if (savedInstanceState == null && navRequest == null && com.droiddeck.launcher.lab.LabOverlay.promoted && com.droiddeck.launcher.agent.AgentHost.available) navRequest = "agent"
gameSyncFolder = GameFileSync.folder(this)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
@@ -1123,6 +1138,10 @@ class FrontEndActivity : ComponentActivity() {
applyLauncherFullscreen()
updates.start()
setContent {
val prootPack by com.droiddeck.launcher.core.AddonPack.PROOT.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(prootPack) {
if (prootPack == com.droiddeck.launcher.core.AddonPack.State.Ready && installed == null) refresh()
}
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
@@ -2276,14 +2295,14 @@ class FrontEndActivity : ComponentActivity() {
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
handToBattleNet(full) { pendingSwitch = next } -> {}
handToBattleNet(full, next) -> {}
else -> pendingSwitch = next
}
}
}, "battlenet-open").start()
}
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): Boolean {
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
@@ -2294,10 +2313,13 @@ class FrontEndActivity : ComponentActivity() {
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
ui.post {
if (isDestroyed) return@post
if (!handed) otherwise()
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
if (!handed) pendingSwitch = next
else {
recordPlayed(next)
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
}
}, "battlenet-request").start()
return true
@@ -178,6 +178,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private var unfoldedPanel by mutableStateOf<OnScreenControls?>(null)
private var unfoldedColumn: android.widget.LinearLayout? = null
private var unfoldedDeckSurface: com.droiddeck.launcher.ui.InputSurfaceView? = null
private var unfoldedDeckPanel by mutableStateOf<com.droiddeck.launcher.input.DeckPanel?>(null)
private var deckPanelLayouts: Map<com.droiddeck.launcher.input.DeckPanel, com.droiddeck.launcher.input.SavedInputLayout?> = emptyMap()
private lateinit var sessionRoot: FrameLayout
private var inputRouter: InputRouter? = null
private var physicalInputBindings: com.droiddeck.launcher.input.PhysicalInputBindings? = null
@@ -283,6 +286,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var directHoldBehavior by mutableStateOf(com.droiddeck.launcher.input.TouchGestureConfig.MOUSE_BEHAVIOR_CLICK)
private var relativeMouseMode by mutableIntStateOf(0)
@Volatile private var appliedRelativeMouseMode: Int? = null
private val agentReadyListener: () -> Unit = { relativeMouseRetries = 0; requestRelativeMouse() }
private val appTextFocus get() = wowChat.request != null
private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) }
private var wowChatBottom: SecondScreenPresentation? = null
@@ -565,6 +569,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var sessionActivityDisplayId = Display.DEFAULT_DISPLAY
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenPresentation? = null
private val inputWindowDisplays = mutableListOf<Int>()
private val bottomScreenListener: () -> Unit = {
if (secondScreenPresentation?.display?.displayId?.let(com.droiddeck.launcher.input.DualScreen::taken) == true) closeSecondScreen(reset = false)
updateBottomGame()
@@ -632,6 +637,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
padBridge?.syncConnectedControllers()
refreshControls()
updatePointerCapture()
requestRelativeMouse()
updateHandCamera()
refreshSecondScreenDisplays()
}
@@ -647,12 +653,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::padMouse.isInitialized) padMouse.reset()
refreshControls()
updatePointerCapture()
requestRelativeMouse()
updateHandCamera()
refreshSecondScreenDisplays()
}
override fun onInputDeviceChanged(deviceId: Int) {
joyConVr?.refresh()
refreshControls()
requestRelativeMouse()
refreshSecondScreenDisplays()
}
}
@@ -661,6 +669,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AgentGuest.readyListener = agentReadyListener
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
runtimeRemovalBlocked = true
@@ -977,7 +986,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
} else {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay({ cursorPos }, cursorVisible && !uiPointer && !bottomOutputActive,
CursorOverlay({ cursorPos }, cursorVisible && (!uiPointer || onlyPcKeyboardOpen()) && !bottomOutputActive,
resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (memoryHud.text.isNotEmpty()) HudText(memoryHud.text, hudColor, hudOpacity)
@@ -1139,6 +1148,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
if (!drawerOpen && !pcKeyboardOpen) com.droiddeck.launcher.ui.AgentBubble {
drawerPage = SessionDrawerPage.AGENT
drawerOpen = true
}
if (!drawerDocked) SessionMenu(docked = false)
LaunchedEffect(achievementsOpen) {
if (achievementsOpen) {
@@ -1222,7 +1235,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
oscMode = oscMode, suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
micAvailable = SessionState.micAvailable && !SessionState.suspended,
micEnabled = SessionState.micEnabled,
@@ -1443,6 +1456,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
},
onClose = { drawerOpen = false },
onDismissScreen = if (docked) ({ selectSecondScreenMode(SecondScreenMode.NONE) }) else null,
components = drawerComponents,
onComponentsRefresh = {
refreshDrawerComponents()
@@ -1955,6 +1969,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val nextRoutedTouchMode = effectiveTouchMode()
if (routedTouchMode != nextRoutedTouchMode) gestureTouchStreamGate.reset()
routedTouchMode = nextRoutedTouchMode
requestRelativeMouse()
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
@@ -2225,6 +2240,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_GAME_ID),
sessionActivityDisplayId,
)
intent.getStringExtra(EXTRA_PLAYED_GAME)?.let { com.droiddeck.launcher.frontend.Library.recordPlayed(this, it) }
}
applyFrameGen()
applyGameAlignment()
@@ -2315,9 +2331,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::twoFingerGestures.isInitialized) twoFingerGestures.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
scrollTouchGesture = false
gesturePointerReady = false
if (sessionTouchDown) dismissTouch = true
}
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
@@ -2826,6 +2844,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return onScreenControls?.let { takes(it, it::takesTouchAt) } == true ||
steamTouchControls?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedPanel?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedDeckSurface?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedColumn?.getChildAt(0)?.let { takes(it) { _, _ -> true } } == true
}
@@ -2972,7 +2991,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun applyRelativeMouse() {
val mode = desiredRelativeMouseMode
val wanted = desiredRelativeMouseMode
val touchpadRoute = com.droiddeck.launcher.input.TouchRouter.decide(com.droiddeck.launcher.input.TouchRouter.Snapshot(
uiBlocking = false,
mode = routedTouchMode,
scrollLayer = false,
steamSession = SessionState.mode == SessionService.MODE_STEAM,
steamDesktopUi = SessionState.steamUi == "desktop",
)) == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD
val mode = com.droiddeck.launcher.input.TouchGestureConfig.gamescopeRelativeMouse(wanted, mouseAttached(), touchpadRoute)
if (appliedRelativeMouseMode == mode) return
val request = org.json.JSONObject()
.put("kind", "exec")
@@ -2988,7 +3015,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.w(TAG, "gamescope did not take relative mouse $mode: ${result.exceptionOrNull() ?: answer}")
if (relativeMouseRetries++ < RELATIVE_MOUSE_MAX_RETRIES) uiHandler.postDelayed({ requestRelativeMouse() }, 2_000)
}
if (desiredRelativeMouseMode != mode) requestRelativeMouse()
if (desiredRelativeMouseMode != wanted) requestRelativeMouse()
}
private fun refreshGestureConfig() {
@@ -3378,6 +3405,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
var presentation = secondScreenPresentation
if (presentation?.display?.displayId != target.displayId) {
if (target.displayId in inputWindowDisplays) {
panelMode(mode)
return
}
dropDockedDrawer()
presentation?.closeControls()
presentation = secondScreenPresentation(target)
@@ -3432,50 +3463,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun bottomPages(): List<BottomPage> = sessionBottomPages(BottomPageActions(
takeScreenshot = ::captureScreenshot,
fps = { if (::hud.isInitialized) hud.liveFps else null },
openSettings = ::openSettingsBelow,
runningGame = { gestureGame },
))
private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) {
val presentation = secondScreenPresentation
if (presentation == null) {
if (!bottomOutputActive) return
listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
runCatching { startActivity(android.content.Intent(it)) }
.onFailure { e -> Log.w(TAG, "could not open ${it.action}", e) }
.isSuccess
}
return
}
val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId)
val opened = listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
runCatching { startActivity(android.content.Intent(it).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle()) }
.onFailure { e -> Log.w(TAG, "could not open ${it.action} on display ${presentation.display.displayId}", e) }
.isSuccess
}
if (!opened) return
presentation.hide()
var checks = 0
fun showAgain() {
try {
presentation.show()
} catch (e: Exception) {
Log.w(TAG, "could not show the dashboard again", e)
if (secondScreenPresentation === presentation) closeSecondScreen(reset = true)
}
}
uiHandler.postDelayed(object : Runnable {
override fun run() {
if (secondScreenPresentation !== presentation || presentation.isShowing) return
if (done()) {
showAgain()
return
}
if (++checks < SETTINGS_WATCH_CHECKS) uiHandler.postDelayed(this, SETTINGS_WATCH_MS) else showAgain()
}
}, SETTINGS_WATCH_MS)
}
private fun captureScreenshot(onDone: (Boolean) -> Unit) {
val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (bottomOutputActive) bottomGame?.surfaceView
else if (::surfaceView.isInitialized) surfaceView else null
@@ -3518,8 +3508,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val main = window.decorView.display
if (role == InputRole.PRIMARY) return bottomGame?.display ?: main
if (bottomGame != null) return main
return displayManager.getDisplay(selectedSecondScreenDisplay)
?: secondScreenDisplays.firstOrNull()?.let { displayManager.getDisplay(it.id) }
return com.droiddeck.launcher.input.SecondScreenDisplays.editorCandidates(selectedSecondScreenDisplay,
secondScreenDisplays.map { it.id }, com.droiddeck.launcher.input.DualScreen::taken).firstNotNullOfOrNull(displayManager::getDisplay)
}
fun inputWindowShown(displayId: Int) { inputWindowDisplays += displayId }
fun inputWindowClosed(displayId: Int) {
inputWindowDisplays -= displayId
if (started && displayId !in inputWindowDisplays && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
}
private fun refreshInputProfile() {
@@ -3530,6 +3527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
adaptiveSticks = primaryInputLayout?.adaptiveSticks
quickButtons = primaryInputLayout?.takeIf { it.editable }?.let { l -> l.quickButtons.associateWith { it !in l.style.hidden } }.orEmpty()
secondaryInputLayout = library.panelLayout(desktop, secondScreenMode.panel(this))
deckPanelLayouts = com.droiddeck.launcher.input.DeckPanel.entries.associateWith { library.panelLayout(desktop, it.bindings) }
inputUsesLocalActions = InputControl.padControls.any { inputProfile.action(InputRole.PHYSICAL, it) !is InputAction.Native }
inputUsesPointer = inputProfile.bindings.values.any { bindings ->
bindings.values.any { it is InputAction.Pointer || it is InputAction.MouseButton || it is InputAction.Scroll }
@@ -3543,6 +3541,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls?.updateLayout(primaryInputLayout)
bottomInputControls?.updateLayout(primaryInputLayout)
unfoldedPanel?.updateLayout(primaryInputLayout)
if (unfoldedPanel != null) applyUnfoldedControls()
showUnfoldedDeckPanel()
updateOnScreenPad()
secondScreenPresentation?.refreshControls()
}
@@ -3586,7 +3586,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val width = sessionRoot.width
val height = sessionRoot.height
val wanted = controlsBelowWanted()
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
val tabRow = unfoldedColumn?.getChildAt(0)?.height?.let {
it - (com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt()
} ?: 0
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height, tabRow)
else FrameLayout.LayoutParams.MATCH_PARENT
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
if (params.height != gameHeight) {
@@ -3594,11 +3597,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
else "unfolded controls: game full screen")
}
val dock = com.droiddeck.launcher.input.OnScreenLayout.Dock(!com.droiddeck.launcher.session.SessionDisplay.foldable(this), SessionState.deckPad)
val portrait = !com.droiddeck.launcher.session.SessionDisplay.foldable(this)
val dock = if (SessionState.deckPad) com.droiddeck.launcher.input.OnScreenLayout.Dock.deck(portrait, deckPanelLayouts[com.droiddeck.launcher.input.DeckPanel.GAMEPAD])
else com.droiddeck.launcher.input.OnScreenLayout.Dock(portrait, false)
var panel = unfoldedPanel
if (panel != null && (!wanted || panel.dock != dock)) {
sessionRoot.removeView(unfoldedColumn)
unfoldedColumn = null
unfoldedDeckSurface = null
unfoldedDeckPanel = null
unfoldedPanel = null
panel = null
if (!wanted) updateOnScreenControls()
@@ -3608,21 +3615,47 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
panel = OnScreenControls(this, padBridge, dock).also { it.updateLayout(primaryInputLayout) }
val column = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
addView(unfoldedBar(), android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
(com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt()))
addView(unfoldedBar().apply {
addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
if (bottom - top != oldBottom - oldTop) post { applyUnfoldedControls() }
}
}, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT))
addView(panel, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
sessionRoot.addView(column, sessionRoot.indexOfChild(surfaceView) + 1)
unfoldedColumn = column
unfoldedPanel = panel
showUnfoldedDeckPanel()
updateOnScreenControls()
}
unfoldedColumn?.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
}
private fun unfoldedBar(): View = com.droiddeck.launcher.ui.bottomScreenBarView(this) {
com.droiddeck.launcher.ui.sessionBarModel(getString(R.string.deck_tab_gamepad), { chooseUnfoldedControls(false) },
{ drawerOpen = true }, ::tapSteamButton, ::tapQam)
val deck = unfoldedDeckPanel
com.droiddeck.launcher.ui.sessionBarModel(getString(if (deck != null) R.string.second_mode_deck_controls else R.string.deck_tab_gamepad),
{ chooseUnfoldedControls(false) }, { drawerOpen = true }, ::tapSteamButton, ::tapQam,
tabs = deck?.let { com.droiddeck.launcher.ui.deckPanelTabs(this, it, ::selectDeckPanel) })
}
private fun showUnfoldedDeckPanel() {
val column = unfoldedColumn
val panel = unfoldedPanel
val selected = SessionPrefs.deckPanel(this).takeIf { column != null && panel?.dock?.grips == true }
unfoldedDeckPanel = selected
val layout = selected?.takeIf { it != com.droiddeck.launcher.input.DeckPanel.GAMEPAD }?.let { deckPanelLayouts[it] }
if (layout != null && unfoldedDeckSurface?.tag == layout) return
unfoldedDeckSurface?.let { it.releaseInputs(); column?.removeView(it) }
unfoldedDeckSurface = null
if (column == null || panel == null) return
if (layout == null) { panel.visibility = View.VISIBLE; return }
panel.releaseAll()
panel.visibility = View.GONE
unfoldedDeckSurface = com.droiddeck.launcher.ui.InputSurfaceView(this, padBridge, layout.elements, InputRole.PRIMARY, layout.style, haptics = true).also {
it.tag = layout
column.addView(it, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
}
private fun bottomKeyboardPresentation(): SecondScreenPresentation? {
@@ -3798,6 +3831,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
gestureTouchStreamGate.reset()
routedTouchMode = next
requestRelativeMouse()
if (directTouchCursorHidden()) cursorVisible = false
}
@@ -3918,9 +3952,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var lockedSwipeX = 0f
private var lockedSwipeY = 0f
private var dismissTouch = false
private var sessionTouchDown = false
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
sessionTouchDown = true
dismissTouch = false
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> sessionTouchDown = false
}
if (drawerOpen && drawerDocked && event.actionMasked == MotionEvent.ACTION_DOWN) {
drawerLayerOpen = false
drawerOpenState = false
@@ -3932,7 +3974,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) dismissTouch = false
return true
}
val uiBlocking = drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
val uiBlocking = drawerOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
if (event.actionMasked == MotionEvent.ACTION_DOWN && !uiBlocking && routedTouchMode != SessionPrefs.TOUCH_GESTURES) {
scrollTouchGesture = scrollTouchMode()
scrollRestX = 0f
@@ -3960,7 +4002,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.input.TouchRouter.Route.SCROLL -> scrollTouch.onTouch(event)
else -> {
if (com.droiddeck.launcher.input.TouchRouter.steamKeyboardZone(snapshot)) {
val wasActive = threeFingerTap.active
val wasActive = threeFingerTap.active && event.actionMasked != MotionEvent.ACTION_DOWN
if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard()
if (threeFingerTap.active && !wasActive) {
if (route == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD) touchpad.cancel()
@@ -4036,9 +4078,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) lockedSwipeUsed = false
if (lockedSwipeUsed && (event.actionMasked != MotionEvent.ACTION_MOVE || !programLocksPointer())) return true
if (programLocksPointer() && event.actionMasked == MotionEvent.ACTION_MOVE) {
if (lockedSwipeUsed) directTouchHold.cancelPendingHold() else directTouchHold.cancelTouch()
lockedSwipeUsed = true
directTouchHold.cancelPendingHold()
val scale = sceneScale(rect)
sendSceneDelta((event.x - swipeX) * scale, (event.y - swipeY) * scale)
return true
@@ -4155,6 +4198,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applyPlatformPointer(appUiOwnsPointer())
}
private fun onlyPcKeyboardOpen(): Boolean =
pcKeyboardOpen && !drawerOpen && !achievementsOpen && !inputSettingsOpen && !pipUi && !glassesOutputActive && !touchEditorOpen && !wowChatOnTop
private fun appUiOwnsPointer(): Boolean =
drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen || wowChatOnTop
@@ -4245,7 +4291,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
updateOnScreenPad()
val c = onScreenControls
glassesSlot = when {
c == null || c.visibility != View.VISIBLE -> 0
c == null || c.visibility != View.VISIBLE || c.isPinnedOnly -> 0
c.isButtonsOnly -> -1
primaryInputLayout?.let { l -> l.elements.any { it.quickButton == InputCommand.KEYBOARD && !l.style.hides(it) } }
?: (controllerSettings?.keyboardButton != false) -> 2
@@ -4260,12 +4306,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
fun showPinned() {
controls.setPinnedOnly(true)
val pinned = controls.hasPinned && !pipUi && !inputSettingsOpen
if (!pinned) controls.releaseAll()
controls.visibility = if (pinned) View.VISIBLE else View.GONE
}
if (pipUi || unfoldedPanel != null || pcKeyboardOpen || inputSettingsOpen ||
(SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) {
onScreenButtonsVisible = false
hideBottomControls()
controls.releaseAll()
controls.visibility = View.GONE
showPinned()
steamTouchControls?.let { it.cancelEditing(); it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
@@ -4298,7 +4349,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val drawn = show && !keyboardCoversPad()
if (steam != null) {
hideBottomControls()
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
showPinned()
if (!drawn) { steam.cancelEditing(); steam.releaseAll() }
steam.visibility = if (drawn) View.VISIBLE else View.GONE
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
@@ -4307,7 +4358,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
val bottomRoot = bottomGame?.surfaceView?.parent as? android.view.ViewGroup
if (drawn && bottomRoot != null) {
if (bottomRoot != null && (drawn || controls.hasPinned)) {
controls.releaseAll(); controls.visibility = View.GONE
var bottom = bottomInputControls
if (bottom?.parent !== bottomRoot) {
@@ -4320,13 +4371,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
bottom.updateLayout(primaryInputLayout)
bottom.setButtonsOnly(buttonsOnly)
bottom.setPinnedOnly(!drawn)
bottom.visibility = View.VISIBLE
return
}
hideBottomControls()
if (drawn == (controls.visibility == View.VISIBLE)) return
if (!drawn) controls.releaseAll()
controls.visibility = if (drawn) View.VISIBLE else View.GONE
val wasDrawn = controls.visibility == View.VISIBLE && !controls.isPinnedOnly
if (!drawn) showPinned() else { controls.setPinnedOnly(false); controls.visibility = View.VISIBLE }
if (drawn == wasDrawn) return
Log.i(TAG, "on-screen controls " + when {
!drawn -> "hidden"
buttonsOnly -> "Steam + QAM"
@@ -5069,7 +5121,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.KEYBOARD_TRACKPAD.label), listOf(keyboardExtra)))
SecondScreenMode.DECK_CONTROLS -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.DECK_CONTROLS.label),
tabs = com.droiddeck.launcher.ui.deckPanelTabs(this@SessionActivity, SessionPrefs.deckPanel(this@SessionActivity), ::selectDeckPanel)))
else -> Unit
else -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(secondScreenMode.label)))
}
androidx.compose.runtime.key(profileLayout) {
androidx.compose.ui.viewinterop.AndroidView(
@@ -5286,6 +5338,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onDestroy() {
if (AgentGuest.readyListener === agentReadyListener) AgentGuest.readyListener = null
com.droiddeck.launcher.input.HomeButtonShortcuts.detach(this)
relativeMouseRequests.shutdownNow()
backQamInput.cancel()
@@ -5390,13 +5443,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_LINK_TASK = "linkTask"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_PLAYED_GAME = "com.droiddeck.launcher.PLAYED_GAME"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TEXT_FIELD_TAP_MS = 1_000L
private const val PAUSED_FRAME_MAX_EDGE = 1920f
private const val PAUSED_FRAME_REDRAW_MS = 150L
private const val SETTINGS_WATCH_MS = 1_000L
private const val SETTINGS_WATCH_CHECKS = 600
private const val BOTTOM_RECOVERY_MS = 1_000L
private const val BOTTOM_RECOVERY_TRIES = 3
private const val TAG = "SessionActivity"
@@ -0,0 +1,44 @@
package com.droiddeck.launcher.agent
import android.content.Context
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* Loopback adb for the agent, so droiddeckctl in the Linux userland reaches the app. It reuses
* the Wireless debugging pairing DroidDeck already made (for the phantom process fix): the paired
* key goes to the guest as an adb vendor key, and the serial is the paired TLS connect port.
* Without a pairing it falls back to adbd on 127.0.0.1:5555, which some devices and `adb tcpip`
* leave listening.
*/
object AgentAdb {
data class Link(val serial: String, val vendorKeys: String, val paired: Boolean)
private const val LEGACY = "127.0.0.1:5555"
private const val VENDOR_KEY = "/root/.android/droiddeck-paired.adbkey"
/** The display the browser opens on for sign-ins: the one the agent is being used on. */
@Volatile var browserDisplay = 0
/** Blocks while it looks up the connect port when none is saved; call it off the main thread. */
fun prepare(app: Context): Link {
val key = File(app.noBackupFilesDir, "wireless-adb-key.pem")
val address = WirelessAdbFix.savedConnectionAddress(app)
if (address == null || !key.isFile) return Link(LEGACY, VENDOR_KEY, false)
val guestKey = File(LinuxRuntime.rootDir(app), VENDOR_KEY.removePrefix("/"))
runCatching {
if (!guestKey.isFile || !guestKey.readBytes().contentEquals(key.readBytes())) {
guestKey.parentFile?.mkdirs()
guestKey.writeBytes(key.readBytes())
guestKey.setReadable(false, false)
guestKey.setReadable(true, true)
}
}
val host = address.substringBeforeLast(':')
val port = address.substringAfterLast(':').toIntOrNull()
?: WirelessAdbFix.findConnectPort(app, host.trim('[', ']'))
?: return Link(LEGACY, VENDOR_KEY, false)
return Link("$host:$port", VENDOR_KEY, true)
}
}
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.compose.runtime.mutableStateListOf
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* Files the user attaches to a message, copied into the Linux userland so the agent's own tools
* can read them (Claude Code's Read shows images and PDFs to the model).
*/
object AgentAttachments {
/** Guest paths waiting to go with the next message. */
val pending = mutableStateListOf<String>()
private const val GUEST_DIR = "/root/.cache/droiddeck/attachments"
private val IMAGE = setOf("png", "jpg", "jpeg", "webp", "gif", "bmp")
fun hostDir(context: Context) = File(LinuxRuntime.rootDir(context.applicationContext), GUEST_DIR.removePrefix("/"))
fun host(context: Context, guestPath: String) = File(LinuxRuntime.rootDir(context.applicationContext), guestPath.removePrefix("/"))
fun isImage(path: String) = path.substringAfterLast('.', "").lowercase() in IMAGE
/** Copies [uris] for [chat] (or a new conversation) on the calling thread; returns their guest paths. */
fun copy(context: Context, chat: String?, uris: List<Uri>): List<String> {
val app = context.applicationContext
val folder = chat ?: "new"
val dir = File(hostDir(app), folder).apply { mkdirs() }
return uris.mapNotNull { uri ->
runCatching {
val name = displayName(app, uri).replace(Regex("[^A-Za-z0-9._-]"), "_").ifEmpty { "file" }
var target = File(dir, name)
var n = 1
while (target.exists()) target = File(dir, "${name.substringBeforeLast('.')}-${n++}${name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }}")
app.contentResolver.openInputStream(uri)!!.use { input -> target.outputStream().use { input.copyTo(it) } }
"$GUEST_DIR/$folder/${target.name}"
}.getOrNull()
}
}
/** The message the CLI gets: the user's text, then where the attached files are. */
fun prompt(text: String, files: List<String>): String {
if (files.isEmpty()) return text
val list = files.joinToString("\n") { "- $it" }
return "$text\n\nAttached files (read them with your tools):\n$list".trim()
}
private fun displayName(context: Context, uri: Uri): String =
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(0) else null
} ?: uri.lastPathSegment?.substringAfterLast('/') ?: "file"
}
@@ -284,6 +284,8 @@ class AgentBridgeProvider : ContentProvider() {
return JSONObject()
.put("schema", SCHEMA)
.put("build", BuildConfig.BUILD_LABEL)
.put("apkCommit", com.droiddeck.launcher.lab.LabOverlay.apkCommit)
.put("lab", com.droiddeck.launcher.lab.LabOverlay.active?.label ?: JSONObject.NULL)
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
.put("runtime", JSONObject()
.put("installed", LinuxRuntime.isInstalled(context))
@@ -0,0 +1,41 @@
package com.droiddeck.launcher.agent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import java.io.File
/**
* The agent bridge for devices whose shell has no `content` command: the same calls as
* [AgentBridgeProvider], answered in a file under the app's external files folder.
*/
class AgentCallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val method = intent.getStringExtra("method") ?: return
val id = intent.getStringExtra("id")?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) } ?: return
val arg = intent.getStringExtra("arg")
val request = intent.getStringExtra(AgentBridgeProvider.EXTRA_REQUEST)
val pending = goAsync()
Thread({
val json = try {
val extras = Bundle().apply { putString(AgentBridgeProvider.EXTRA_REQUEST, request) }
context.contentResolver.call("${context.packageName}.agent", method, arg, extras)
?.getString(AgentBridgeProvider.RESULT_JSON) ?: """{"ok":false,"error":{"code":"NO_RESPONSE","message":"no response"}}"""
} catch (e: Exception) {
org.json.JSONObject().put("ok", false)
.put("error", org.json.JSONObject().put("code", "COMMAND_FAILED").put("message", e.message ?: e.javaClass.simpleName)).toString()
}
try {
val dir = File(context.getExternalFilesDir(null), "agent-calls").apply { mkdirs() }
File(dir, "$id.json.part").writeText(json)
File(dir, "$id.json.part").renameTo(File(dir, "$id.json"))
} catch (e: Exception) {
android.util.Log.w("AgentCallReceiver", "could not write the answer to $method: ${e.message}")
} finally {
pending.finish()
}
}, "agent-call-$id").start()
}
}
@@ -0,0 +1,134 @@
package com.droiddeck.launcher.agent
import android.content.Context
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.agent.AgentHost.Entry
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Conversations kept on the device, one JSON Lines file each in `files/agent/chats`: a header
* line with the harness, a line with the CLI's conversation id once it has one, then the entries.
*/
object AgentChats {
data class Chat(val id: String, val harness: String, val title: String, val updated: Long)
class Loaded(val harness: String?, val conversation: String?, val entries: List<Entry>)
private const val MAX_OUTPUT = 24_000
fun dir(context: Context) = File(context.filesDir, "agent/chats")
fun file(context: Context, id: String) = File(dir(context), "$id.jsonl")
fun newId(context: Context): String {
var stamp = System.currentTimeMillis()
while (file(context, stamp.toString()).exists()) stamp++
return stamp.toString()
}
/** Newest first; chats with nothing asked yet are left out. */
fun list(context: Context): List<Chat> = dir(context).listFiles { f -> f.name.endsWith(".jsonl") }.orEmpty()
.mapNotNull { f ->
val loaded = read(f)
val title = loaded.entries.firstNotNullOfOrNull { (it as? Entry.Asked)?.text } ?: return@mapNotNull null
Chat(f.name.removeSuffix(".jsonl"), loaded.harness ?: ClaudeCode.id, title.lineSequence().first().take(120), f.lastModified())
}
.sortedByDescending { it.updated }
fun delete(context: Context, id: String) {
file(context, id).delete()
File(AgentAttachments.hostDir(context), id).deleteRecursively()
}
fun start(file: File, harness: String) {
file.parentFile?.mkdirs()
file.writeText(JSONObject().put("harness", harness).toString() + "\n")
}
fun appendConversation(file: File, conversation: String) =
runCatching { file.appendText(JSONObject().put("conversation", conversation).toString() + "\n") }
fun append(file: File, entry: Entry) = runCatching { file.appendText(encode(entry) + "\n") }
/** The transcript before chats were kept, moved into the first chat. */
fun migrate(context: Context, harness: String, conversation: String?): String? {
val old = File(context.filesDir, "agent/transcript.jsonl")
if (!old.isFile) return null
val id = newId(context)
val target = file(context, id)
start(target, harness)
conversation?.let { appendConversation(target, it) }
target.appendText(old.readText())
old.delete()
return id
}
@VisibleForTesting
internal fun encode(entry: Entry): String = when (entry) {
is Entry.Asked -> JSONObject().put("asked", entry.text).apply { if (entry.files.isNotEmpty()) put("files", JSONArray(entry.files)) }
is Entry.Said -> JSONObject().put("said", entry.text)
is Entry.Thought -> JSONObject().put("thought", entry.text)
is Entry.Result -> JSONObject().put("result", entry.id).put("output", entry.output).put("failed", entry.failed)
is Entry.Used -> JSONObject().put("used", entry.tool).put("detail", entry.detail).put("id", entry.id).put("input", entry.input)
.apply { entry.output?.let { put("output", it).put("failed", entry.failed) } }
is Entry.Finished -> JSONObject().put("finished", true).put("error", entry.error ?: JSONObject.NULL).put("stopped", entry.stopped).apply {
entry.usage?.let { put("ms", it.durationMs).put("cost", it.costUsd).put("in", it.inputTokens).put("out", it.outputTokens) }
}
}.toString()
@VisibleForTesting
internal fun read(file: File): Loaded {
var harness: String? = null
var conversation: String? = null
val entries = mutableListOf<Entry>()
runCatching {
file.forEachLine { line ->
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@forEachLine
when {
o.has("harness") -> harness = o.getString("harness")
o.has("conversation") -> conversation = o.getString("conversation")
else -> decode(o)?.let { merge(entries, it) }
}
}
}
return Loaded(harness, conversation, entries)
}
private fun decode(o: JSONObject): Entry? = when {
o.has("asked") -> Entry.Asked(o.getString("asked"), o.optJSONArray("files")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty())
o.has("said") -> Entry.Said(o.getString("said"))
o.has("thought") -> Entry.Thought(o.getString("thought"))
o.has("used") -> Entry.Used(o.optString("id"), o.getString("used"), o.optString("detail"), o.optString("input"),
if (o.has("output")) o.optString("output") else null, o.optBoolean("failed"))
o.has("result") -> Entry.Result(o.getString("result"), o.optString("output"), o.optBoolean("failed"))
o.has("finished") -> Entry.Finished(
if (o.isNull("error")) null else o.optString("error"), o.optBoolean("stopped"),
if (o.has("ms")) AgentHost.Usage(o.optLong("ms"), o.optDouble("cost", 0.0), o.optLong("in"), o.optLong("out")) else null,
)
else -> null
}
/** A tool's result is shown on the call it answers, and a call seen again (an updated todo list) replaces it. */
fun merge(entries: MutableList<Entry>, entry: Entry): Boolean {
if (entry is Entry.Used && entry.id.isNotEmpty()) {
val same = entries.indexOfLast { it is Entry.Used && it.id == entry.id }
if (same >= 0) {
entries[same] = entry
return true
}
}
if (entry !is Entry.Result) {
entries += entry
return true
}
val index = entries.indexOfLast { it is Entry.Used && it.id == entry.id && entry.id.isNotEmpty() }
if (index < 0) return false
entries[index] = (entries[index] as Entry.Used).copy(output = clip(entry.output), failed = entry.failed)
return true
}
/** Long outputs keep their end, where errors and summaries usually are. */
fun clip(output: String) = if (output.length <= MAX_OUTPUT) output else "…\n" + output.takeLast(MAX_OUTPUT)
}
@@ -33,6 +33,7 @@ object AgentGuest {
var keyboardDismissals by mutableIntStateOf(0)
private set
@Volatile private var keyboardRoute = false
@Volatile var readyListener: (() -> Unit)? = null
@Volatile private var routeDir: File? = null
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
@@ -59,6 +60,7 @@ object AgentGuest {
when (path) {
"hello.json" -> readJson(File(dir, path))?.let {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
readyListener?.invoke()
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
@@ -0,0 +1,278 @@
package com.droiddeck.launcher.agent
import androidx.annotation.VisibleForTesting
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/** One coding agent CLI in the Linux userland, run one process per turn. */
interface AgentHarness {
val id: String
val name: String
/** Absolute guest path of the CLI. */
val binary: String
/** Guest paths of everything the CLI needs installed, [binary] included. */
val files: List<String> get() = listOf(binary)
/** A guest shell script that installs the CLI. */
val install: String
/** A guest command whose exit status is 0 when the CLI is signed in. */
val signedIn: List<String>
/** A guest command that signs in through $BROWSER. */
val signIn: List<String>
/** Whether [Options.plan] means anything to this CLI. */
val plans: Boolean
/** Models to offer besides the CLI's default; [rootfs] is where the CLI's own files can be read. */
fun models(rootfs: File): List<Model>
/** The guest command for one turn; [conversation] is null for the first one. */
fun turn(message: String, conversation: String?, options: Options): List<String>
/** One line of the CLI's output, or null when it carries nothing to show. */
fun parse(line: String): AgentEvent?
data class Model(val id: String, val label: String)
data class Options(val model: String? = null, val plan: Boolean = false, val brief: String? = null)
companion object {
val all: List<AgentHarness> = listOf(ClaudeCode, Codex)
fun byId(id: String?): AgentHarness = all.firstOrNull { it.id == id } ?: ClaudeCode
}
}
/** What one line of output carries: whole entries, or text still being written. */
data class AgentEvent(
val conversation: String? = null,
val entries: List<AgentHost.Entry> = emptyList(),
val text: String? = null,
val thinking: String? = null,
/** Tokens the conversation's context holds after this step. */
val context: Long? = null,
val usage: AgentHost.Usage? = null,
)
/** Claude Code: `claude -p` with stream-json output, resumed by session id. */
object ClaudeCode : AgentHarness {
override val id = "claude"
override val name = "Claude Code"
override val binary = "/root/.local/bin/claude"
override val install = "curl -fsSL https://claude.ai/install.sh | bash"
override val signedIn = listOf("sh", "-c", "$binary auth status | grep -q '\"loggedIn\": true'")
override val signIn = listOf(binary, "auth", "login", "--claudeai")
override val plans = true
override fun models(rootfs: File) = listOf(
AgentHarness.Model("opus", "Opus"), AgentHarness.Model("sonnet", "Sonnet"), AgentHarness.Model("haiku", "Haiku"),
)
// DroidDeck runs the agent unattended on the user's own device; permission prompts have no one to answer them.
override fun turn(message: String, conversation: String?, options: AgentHarness.Options) = buildList {
addAll(listOf(binary, "-p", message, "--output-format", "stream-json", "--verbose", "--include-partial-messages"))
addAll(listOf("--permission-mode", if (options.plan) "plan" else "bypassPermissions"))
options.model?.let { addAll(listOf("--model", it)) }
options.brief?.let { addAll(listOf("--append-system-prompt", it)) }
if (conversation != null) addAll(listOf("--resume", conversation))
}
override fun parse(line: String): AgentEvent? {
val o = runCatching { JSONObject(line) }.getOrNull() ?: return null
val conversation = o.optString("session_id").takeIf { it.isNotEmpty() }
return when (o.optString("type")) {
"stream_event" -> {
val delta = o.optJSONObject("event")?.takeIf { it.optString("type") == "content_block_delta" }?.optJSONObject("delta") ?: return null
when (delta.optString("type")) {
"text_delta" -> AgentEvent(conversation, text = delta.optString("text"))
"thinking_delta" -> AgentEvent(conversation, thinking = delta.optString("thinking"))
else -> null
}
}
"assistant" -> {
val message = o.optJSONObject("message")
val content = message?.optJSONArray("content") ?: JSONArray()
val entries = (0 until content.length()).mapNotNull { i ->
val block = content.optJSONObject(i) ?: return@mapNotNull null
when (block.optString("type")) {
"text" -> block.optString("text").trim().takeIf { it.isNotEmpty() }?.let { AgentHost.Entry.Said(it) }
"thinking" -> block.optString("thinking").trim().takeIf { it.isNotEmpty() }?.let { AgentHost.Entry.Thought(it) }
"tool_use" -> {
val input = block.optJSONObject("input")
AgentHost.Entry.Used(block.optString("id"), block.optString("name"), detail(input), fullInput(block.optString("name"), input))
}
else -> null
}
}
val usage = message?.optJSONObject("usage")
val context = usage?.let { it.optLong("input_tokens") + it.optLong("cache_read_input_tokens") + it.optLong("cache_creation_input_tokens") + it.optLong("output_tokens") }
AgentEvent(conversation, entries, context = context?.takeIf { it > 0 })
}
"user" -> {
val content = o.optJSONObject("message")?.optJSONArray("content") ?: return conversation?.let { AgentEvent(it) }
val results = (0 until content.length()).mapNotNull { i ->
val block = content.optJSONObject(i)?.takeIf { it.optString("type") == "tool_result" } ?: return@mapNotNull null
AgentHost.Entry.Result(block.optString("tool_use_id"), resultText(block.opt("content")), block.optBoolean("is_error"))
}
AgentEvent(conversation, results)
}
// A resumed session first answers background tasks the last run left behind; that result is not the turn's.
"result" -> if (o.optJSONObject("origin")?.optString("kind") == "task-notification") conversation?.let { AgentEvent(it) } else AgentEvent(
conversation,
listOf(AgentHost.Entry.Finished(if (o.optBoolean("is_error")) o.optString("result").ifEmpty { o.optString("subtype") } else null)),
usage = AgentHost.Usage(
o.optLong("duration_ms"), o.optDouble("total_cost_usd", 0.0),
o.optJSONObject("usage")?.let { it.optLong("input_tokens") + it.optLong("cache_read_input_tokens") + it.optLong("cache_creation_input_tokens") } ?: 0,
o.optJSONObject("usage")?.optLong("output_tokens") ?: 0,
),
)
else -> conversation?.let { AgentEvent(it) }
}
}
private fun resultText(content: Any?): String = when (content) {
is String -> content
is JSONArray -> (0 until content.length()).mapNotNull { i ->
val part = content.optJSONObject(i) ?: return@mapNotNull null
when (part.optString("type")) {
"text" -> part.optString("text")
"image" -> "[image]"
else -> null
}
}.joinToString("\n")
null, JSONObject.NULL -> ""
else -> content.toString()
}
private fun detail(input: JSONObject?): String {
input ?: return ""
for (key in listOf("command", "file_path", "pattern", "url", "query", "description", "prompt")) {
input.optString(key).takeIf { it.isNotEmpty() }?.let { return firstLine(it) }
}
return ""
}
@VisibleForTesting
internal fun fullInput(tool: String, input: JSONObject?): String {
input ?: return ""
return when {
tool == "Bash" -> input.optString("command")
tool == "Edit" -> "${input.optString("file_path")}\n\n- ${input.optString("old_string").replace("\n", "\n- ")}\n\n+ ${input.optString("new_string").replace("\n", "\n+ ")}"
tool == "Write" -> "${input.optString("file_path")}\n\n${input.optString("content")}"
tool == "TodoWrite" -> todos(input.optJSONArray("todos"))
else -> input.toString(2)
}
}
private fun todos(list: JSONArray?) = (0 until (list?.length() ?: 0)).mapNotNull { i ->
val todo = list?.optJSONObject(i) ?: return@mapNotNull null
val mark = when (todo.optString("status")) { "completed" -> "[x]"; "in_progress" -> "[~]"; else -> "[ ]" }
"$mark ${todo.optString("content")}"
}.joinToString("\n")
}
/** OpenAI Codex: `codex exec --json`, resumed by thread id. */
object Codex : AgentHarness {
override val id = "codex"
override val name = "Codex"
override val binary = "/root/.local/bin/codex"
// Code mode runs in a helper that each release ships as its own download.
private const val HOST = "/root/.local/bin/codex-code-mode-host"
override val files get() = listOf(binary, HOST)
override val install = """
set -e
release=${'$'}(curl -fsSL https://api.github.com/repos/openai/codex/releases/latest)
mkdir -p /root/.local/bin
for name in codex codex-code-mode-host; do
url=${'$'}(printf '%s' "${'$'}release" | grep -o "https://[^\"]*/${'$'}name-aarch64-unknown-linux-musl.tar.gz" | head -1)
curl -fsSL "${'$'}url" | tar -xz -C /tmp
install -m755 /tmp/${'$'}name-aarch64-unknown-linux-musl /root/.local/bin/${'$'}name
done
""".trimIndent()
override val signedIn = listOf(binary, "login", "status")
override val signIn = listOf(binary, "login")
override val plans = false
/** Codex keeps the models its account can use in `~/.codex/models_cache.json` once it has run. */
override fun models(rootfs: File): List<AgentHarness.Model> = runCatching {
val o = JSONObject(File(rootfs, "root/.codex/models_cache.json").readText())
val list = o.optJSONArray("models") ?: JSONArray()
(0 until list.length()).mapNotNull { i ->
val m = list.optJSONObject(i) ?: return@mapNotNull null
val slug = m.optString("slug").ifEmpty { m.optString("id") }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
if (m.has("visibility") && m.optString("visibility") != "list") return@mapNotNull null
AgentHarness.Model(slug, m.optString("display_name").ifEmpty { slug })
}
}.getOrDefault(emptyList())
// Codex's own sandbox needs Landlock and seccomp, which proot does not provide.
override fun turn(message: String, conversation: String?, options: AgentHarness.Options) = buildList {
addAll(listOf(binary, "exec"))
if (conversation != null) addAll(listOf("resume", conversation))
addAll(listOf("--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check"))
options.model?.let { addAll(listOf("-m", it)) }
// codex exec has no system prompt option, so a new conversation starts with the brief.
add(if (conversation == null && options.brief != null) "${options.brief}\n\n$message" else message)
}
override fun parse(line: String): AgentEvent? {
val o = runCatching { JSONObject(line) }.getOrNull() ?: return null
val item = o.optJSONObject("item")
val id = item?.optString("id").orEmpty()
val entries = when (o.optString("type")) {
"thread.started" -> return AgentEvent(o.optString("thread_id").takeIf { it.isNotEmpty() })
"item.started" -> when (item?.optString("type")) {
"command_execution" -> shellCommand(item.optString("command")).let { listOf(AgentHost.Entry.Used(id, "Shell", firstLine(it), it)) }
"mcp_tool_call" -> listOf(AgentHost.Entry.Used(id, item.optString("tool").ifEmpty { "Tool" }, item.optString("server"), item.optJSONObject("arguments")?.toString(2).orEmpty()))
"web_search" -> listOf(AgentHost.Entry.Used(id, "Search", item.optString("query"), item.optString("query")))
else -> emptyList()
}
"item.updated" -> when (item?.optString("type")) {
"todo_list" -> listOf(todoEntry(id, item))
else -> emptyList()
}
"item.completed" -> when (item?.optString("type")) {
"agent_message" -> item.optString("text").trim().takeIf { it.isNotEmpty() }?.let { listOf(AgentHost.Entry.Said(it)) }.orEmpty()
"reasoning" -> item.optString("text").trim().takeIf { it.isNotEmpty() }?.let { listOf(AgentHost.Entry.Thought(it)) }.orEmpty()
"command_execution" -> listOf(AgentHost.Entry.Result(id, item.optString("aggregated_output"),
item.has("exit_code") && !item.isNull("exit_code") && item.optInt("exit_code") != 0))
"mcp_tool_call" -> listOf(AgentHost.Entry.Result(id, item.optJSONObject("result")?.toString(2) ?: item.optJSONObject("error")?.optString("message").orEmpty(),
item.optString("status") == "failed"))
"web_search" -> listOf(AgentHost.Entry.Result(id, "", false))
"file_change" -> listOf(
AgentHost.Entry.Used(id, "Edit", changedFiles(item), changedFiles(item).replace(", ", "\n")),
AgentHost.Entry.Result(id, item.optString("status"), item.optString("status") == "failed"),
)
"todo_list" -> listOf(todoEntry(id, item))
"error" -> listOf(AgentHost.Entry.Said("⚠ " + item.optString("message")))
else -> emptyList()
}
"turn.completed" -> return AgentEvent(
entries = listOf(AgentHost.Entry.Finished(null)),
usage = o.optJSONObject("usage")?.let { AgentHost.Usage(0, 0.0, it.optLong("input_tokens") + it.optLong("cached_input_tokens"), it.optLong("output_tokens")) },
)
"turn.failed" -> listOf(AgentHost.Entry.Finished(o.optJSONObject("error")?.optString("message") ?: "turn failed"))
"error" -> listOf(AgentHost.Entry.Finished(o.optString("message").ifEmpty { "error" }))
else -> emptyList()
}
return if (entries.isEmpty()) null else AgentEvent(entries = entries)
}
private fun todoEntry(id: String, item: JSONObject): AgentHost.Entry.Used {
val items = item.optJSONArray("items") ?: JSONArray()
val text = (0 until items.length()).mapNotNull { i ->
val todo = items.optJSONObject(i) ?: return@mapNotNull null
"${if (todo.optBoolean("completed")) "[x]" else "[ ]"} ${todo.optString("text")}"
}.joinToString("\n")
return AgentHost.Entry.Used("$id-todo", "TodoWrite", "", text, output = "")
}
private fun changedFiles(item: JSONObject): String {
val changes = item.optJSONArray("changes") ?: return ""
return (0 until changes.length()).mapNotNull { changes.optJSONObject(it)?.optString("path") }.joinToString(", ").take(400)
}
}
@VisibleForTesting
internal fun firstLine(text: String) = text.lineSequence().first().take(160)
private val SHELL_WRAPPER = Regex("^/bin/(?:ba)?sh -l?c '(.*)'$", RegexOption.DOT_MATCHES_ALL)
/** Codex runs each command as `/bin/bash -lc '<command>'`; the command is what the user wants to read. */
@VisibleForTesting
internal fun shellCommand(command: String) = SHELL_WRAPPER.find(command)?.groupValues?.get(1)?.replace("'\''", "'") ?: command
@@ -0,0 +1,487 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.nio.file.Files
/**
* The launcher's agent: a coding agent CLI ([AgentHarness]) from the Linux userland, run headless
* one process per turn, outside any game or desktop session. Conversations are kept on disk
* ([AgentChats]), so a restart (a Lab build, a crash) continues the same one.
*/
object AgentHost {
sealed interface Entry {
data class Asked(val text: String, val files: List<String> = emptyList()) : Entry
data class Said(val text: String) : Entry
data class Thought(val text: String) : Entry
/** A tool call; [output] stays null until its result arrives. */
data class Used(val id: String, val tool: String, val detail: String, val input: String = "", val output: String? = null, val failed: Boolean = false) : Entry
data class Result(val id: String, val output: String, val failed: Boolean) : Entry
data class Finished(val error: String?, val stopped: Boolean = false, val usage: Usage? = null) : Entry
}
data class Usage(val durationMs: Long, val costUsd: Double, val inputTokens: Long, val outputTokens: Long)
/** A message typed while the agent was working, sent when the turn ends. */
data class Queued(val text: String, val files: List<String>)
enum class State { MISSING, IDLE, WORKING }
private const val TAG = "AgentHost"
private const val WORKSPACE = "/root/DroidDeck"
private const val TICK_MS = 1_000L
private const val IDLE_TICK_MS = 5_000L
/** Where the agent runs and what the user sees, which no file in the checkout tells it. */
@VisibleForTesting
internal const val BRIEF = "You run inside DroidDeck on the user's handheld, in its Linux userland (Ubuntu under proot, aarch64), " +
"and the user talks to you from DroidDeck's Agent tab, often on a small screen, so keep replies short. " +
"droiddeckctl (in the /root/DroidDeck checkout, tools/droiddeckctl) inspects and drives the app over loopback adb (\$ADB_SERIAL). " +
"dd-lab-build builds the checkout as a Lab build and restarts DroidDeck on it (docs/lab-builds.md). DroidDeck runs the build in a " +
"process of its own and shows the user its progress and time left; dd-lab-build follows that build's log until it is done. " +
"Run it with run_in_background and keep checking its output, so the user can talk to you meanwhile. " +
"The restart ends this process, and this conversation continues on the new build. " +
"Files the user attaches are listed at the end of their message, under /root/.cache/droiddeck/attachments."
val entries = mutableStateListOf<Entry>()
val queue = mutableStateListOf<Queued>()
var state by mutableStateOf(State.IDLE)
private set
var installed by mutableStateOf(false)
private set
/** The Linux runtime is there, so the agent can be set up. */
var available by mutableStateOf(false)
private set
var harness by mutableStateOf<AgentHarness>(ClaudeCode)
private set
/** The chosen model for [harness]; null is the CLI's default. */
var model by mutableStateOf<String?>(null)
private set
var plan by mutableStateOf(false)
private set
var chat by mutableStateOf<String?>(null)
private set
/** A reply finished since the conversation was last on screen. */
var unseen by mutableStateOf(false)
private set
var failed by mutableStateOf(false)
private set
/** Text and thinking of the reply being written. */
var live by mutableStateOf("")
private set
var liveThinking by mutableStateOf("")
private set
/** When the current turn started, on [SystemClock.elapsedRealtime]. */
var turnStarted by mutableLongStateOf(0L)
private set
var now by mutableLongStateOf(0L)
private set
/** Tokens in the conversation's context after its last step. */
var context by mutableLongStateOf(0L)
private set
var lab by mutableStateOf<LabProgress?>(null)
private set
private class Turn(val process: Process, val pid: Int)
private val main = Handler(Looper.getMainLooper())
private var turn: Turn? = null
/** Counts turns, so a process that starts after its turn was stopped is not adopted by the next one. */
private var generation = 0
private var labProcess: Process? = null
/** The turn now running resends a message that got no reply, so it is not resent again. */
private var retrying = false
/** The running Lab build was asked for by the current turn, so stopping the turn stops it. */
private var labFromTurn = false
private var conversation: String? = null
private var loaded = false
private var appContext: Context? = null
val turnPid: Int get() = turn?.pid ?: 0
fun seen() {
unseen = false
}
fun refresh(context: Context) {
val app = context.applicationContext
appContext = app
harness = AgentHarness.byId(prefs(app).getString(HARNESS_KEY, null))
model = prefs(app).getString(modelKey(harness), null)
plan = prefs(app).getBoolean(PLAN_KEY, false)
available = LinuxRuntime.isInstalled(app)
installed = harness.files.all { guestFileExists(app, it) }
if (!installed) state = State.MISSING else if (state == State.MISSING) state = State.IDLE
if (!loaded) {
loaded = true
val migrated = AgentChats.migrate(app, harness.id, prefs(app).getString(LEGACY_CONVERSATION_KEY, null))
if (migrated != null) prefs(app).edit().remove(LEGACY_CONVERSATION_KEY).putString(CHAT_KEY, migrated).apply()
prefs(app).getString(CHAT_KEY, null)?.let { load(app, it) }
resumeAfterRestart(app)
tick()
}
}
private fun load(app: Context, id: String) {
val file = AgentChats.file(app, id)
if (!file.isFile) return
val loaded = AgentChats.read(file)
chat = id
conversation = loaded.conversation
entries.clear()
entries.addAll(loaded.entries)
context = 0
}
/** Opens a kept conversation, switching to the CLI it belongs to. */
fun open(context: Context, id: String) {
val app = context.applicationContext
if (state == State.WORKING || id == chat) return
val harnessId = AgentChats.read(AgentChats.file(app, id)).harness
if (harnessId != null && harnessId != harness.id) {
prefs(app).edit().putString(HARNESS_KEY, harnessId).apply()
AgentSetup.forget()
refresh(app)
}
load(app, id)
prefs(app).edit().putString(CHAT_KEY, id).apply()
}
fun delete(context: Context, id: String) {
if (id == chat) {
if (state == State.WORKING) return
forget(context)
}
AgentChats.delete(context.applicationContext, id)
}
/**
* A Lab build restarts the app, which ends the agent's process mid-turn. dd-lab-build leaves
* [RESUME] when the app's own agent ran it, so the same conversation picks up on the new build.
*/
private fun resumeAfterRestart(app: Context) {
val marker = File(app.filesDir, RESUME)
val claimed = File(app.filesDir, "$RESUME.claimed")
if (!marker.renameTo(claimed)) return
claimed.delete()
val asked = prefs(app).getString(LAB_CHAT_KEY, null)
prefs(app).edit().remove(LAB_CHAT_KEY).apply()
if (!installed) return
// The conversation that asked for the build hears about it, even if its turn had already ended.
if (asked != null && asked != chat && AgentChats.file(app, asked).isFile) {
load(app, asked)
prefs(app).edit().putString(CHAT_KEY, asked).apply()
}
if (asked != null || interrupted(entries)) send(app, app.getString(R.string.agent_resumed))
}
/** True when DroidDeck restarted in the middle of one of the agent's turns. */
fun resuming(context: Context) = File(context.applicationContext.filesDir, RESUME).isFile
@VisibleForTesting
internal fun interrupted(entries: List<Entry>) = entries.isNotEmpty() && entries.last() !is Entry.Finished
/** Switches the agent CLI; a conversation belongs to one CLI, so a new one starts. */
fun choose(context: Context, choice: AgentHarness) {
if (choice == harness || state == State.WORKING) return
forget(context)
AgentSetup.forget()
prefs(context).edit().putString(HARNESS_KEY, choice.id).apply()
refresh(context)
}
fun chooseModel(context: Context, id: String?) {
model = id
prefs(context).edit().putString(modelKey(harness), id).apply()
}
fun choosePlan(context: Context, on: Boolean) {
plan = on
prefs(context).edit().putBoolean(PLAN_KEY, on).apply()
}
fun models(context: Context) = harness.models(LinuxRuntime.rootDir(context.applicationContext))
/** Sends [text] now, or after the current turn when the agent is working. */
fun send(context: Context, text: String, files: List<String> = emptyList()) {
val app = context.applicationContext
val message = text.trim()
if (message.isEmpty() && files.isEmpty()) return
if (state == State.WORKING) {
queue += Queued(message, files)
return
}
if (state != State.IDLE) return
val id = chat ?: AgentChats.newId(app).also { id ->
AgentChats.start(AgentChats.file(app, id), harness.id)
chat = id
conversation = null
prefs(app).edit().putString(CHAT_KEY, id).apply()
}
record(app, Entry.Asked(message, files))
state = State.WORKING
val gen = ++generation
live = ""
liveThinking = ""
turnStarted = SystemClock.elapsedRealtime()
AgentHostService.start(app)
val current = harness
val prompt = AgentAttachments.prompt(message, files)
val command = current.turn(prompt, conversation, AgentHarness.Options(model, plan && current.plans, BRIEF))
val workdir = workspace(app)
Thread({
val child = try {
guest(app, command, workdir).start()
} catch (e: Exception) {
Log.w(TAG, "could not start ${current.name}", e)
main.post { if (generation == gen) finish(app, e.message ?: "could not start") }
return@Thread
}
val started = Turn(child, AgentProcesses.pidOf(child))
main.post { if (generation == gen && state == State.WORKING && turn == null) turn = started else AgentProcesses.killTree(started.pid) }
child.outputStream.close()
var ended = false
val raw = runCatching {
val log = File(app.filesDir, TURN_LOG).apply { parentFile?.mkdirs() }
log.renameTo(File(app.filesDir, "$TURN_LOG.1"))
log.bufferedWriter()
}.getOrNull()
runCatching {
child.inputStream.bufferedReader().forEachLine { line ->
runCatching { raw?.apply { write(line); newLine(); flush() } }
val event = current.parse(line) ?: return@forEachLine
if (event.entries.any { it is Entry.Finished }) ended = true
main.post { if (turn === started) apply(app, event) }
}
}
runCatching { raw?.close() }
val status = runCatching { child.waitFor() }.getOrDefault(-1)
main.post {
if (turn === started) {
turn = null
if (state == State.WORKING && !ended) finish(app, app.getString(R.string.agent_exited, current.name, status))
}
}
}, "agent-output").start()
}
/** Ends the turn now: its process and everything it started, a build included. */
fun stop(context: Context) {
val app = context.applicationContext
val running = turn
turn = null
queue.clear()
if (labFromTurn && lab?.running == true) cancelLabBuild(app)
if (running != null) {
AgentProcesses.killTree(running.pid)
running.process.destroy()
}
if (state == State.WORKING) finish(app, null, stopped = true)
}
fun unqueue(item: Queued) {
queue.remove(item)
}
/** Starts a new conversation; the current one stays in the history. */
fun forget(context: Context) {
stop(context)
entries.clear()
chat = null
conversation = null
this.context = 0
prefs(context).edit().remove(CHAT_KEY).apply()
state = if (installed) State.IDLE else State.MISSING
}
/**
* A process for [command] in the Linux userland, run the way the store helpers run theirs:
* proot over the installed rootfs, without a session. Loopback adb is connected first so
* droiddeckctl reaches the app.
*/
fun guest(app: Context, command: List<String>, workdir: String = "/root", connectAdb: Boolean = true): ProcessBuilder {
val adb = AgentAdb.prepare(app)
val env = listOf(
"/usr/bin/env", "HOME=/root", "PATH=/root/.local/bin:/usr/local/bin:/usr/bin:/bin", "LANG=C.UTF-8",
"ADB_SERIAL=${adb.serial}", "ADB_VENDOR_KEYS=${adb.vendorKeys}",
"DD_AGENT=1", "BROWSER=/root/.local/bin/android-open", "DD_BROWSER_DISPLAY=${AgentAdb.browserDisplay}",
)
val shell = listOf(
"/bin/sh", "-c",
// proot ends the adb server with the command, so each command that needs adb connects it.
(if (connectAdb) "adb start-server >/dev/null 2>&1; adb connect \"\$ADB_SERIAL\" >/dev/null 2>&1; " else "") +
"cd \"$1\" 2>/dev/null; shift; exec \"$@\"", "sh", workdir,
)
val proot = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), env + shell + command)
return ProcessBuilder(proot).redirectErrorStream(true).apply {
environment().apply {
remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path)
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path)
if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
}
}
fun guestFileExists(context: Context, path: String): Boolean {
val file = File(LinuxRuntime.rootDir(context), path.removePrefix("/"))
return file.exists() || Files.isSymbolicLink(file.toPath())
}
private fun workspace(app: Context) = if (guestFileExists(app, WORKSPACE)) WORKSPACE else "/root"
private fun apply(app: Context, event: AgentEvent) {
event.conversation?.takeIf { it != conversation }?.let { id ->
conversation = id
chat?.let { AgentChats.appendConversation(AgentChats.file(app, it), id) }
}
event.text?.let { live += it }
event.thinking?.let { liveThinking += it }
event.context?.let { context = it }
for (entry in event.entries) when (entry) {
is Entry.Finished -> finish(app, entry.error, usage = event.usage)
else -> {
if (entry is Entry.Said) live = ""
if (entry is Entry.Thought) liveThinking = ""
record(app, entry)
}
}
AgentHostService.update(app)
}
private fun finish(app: Context, error: String?, stopped: Boolean = false, usage: Usage? = null) {
if (state != State.WORKING) return
turn = null
if (!stopped) labFromTurn = false
live = ""
liveThinking = ""
record(app, Entry.Finished(error, stopped, usage))
state = State.IDLE
unseen = true
failed = error != null
AgentHostService.stop(app)
// Claude Code now and then ends a resumed turn at once without reading the message; nothing
// happened, so the message is sent once more.
val asked = entries.lastOrNull { it is Entry.Asked } as? Entry.Asked
if (!stopped && error == null && asked != null && answerless(entries) && !retrying) {
retrying = true
main.postDelayed({ if (state == State.IDLE) send(app, asked.text, asked.files) }, RETRY_MS)
return
}
retrying = false
if (!stopped && queue.isNotEmpty()) {
val next = queue.removeAt(0)
send(app, next.text, next.files)
}
}
private fun record(app: Context, entry: Entry) {
if (!AgentChats.merge(entries, entry)) return
chat?.let { AgentChats.append(AgentChats.file(app, it), entry) }
}
/** The last turn ended without saying or doing anything. */
fun answerless(entries: List<Entry>): Boolean {
val start = entries.indexOfLast { it is Entry.Asked }
if (start < 0) return false
val turn = entries.subList(start + 1, entries.size)
return turn.lastOrNull() is Entry.Finished && turn.none { it is Entry.Said || it is Entry.Used || it is Entry.Thought } &&
(turn.last() as Entry.Finished).let { it.error == null && !it.stopped }
}
/** Sends the last message again, after a turn that ended without a reply. */
fun retry(context: Context) {
val asked = entries.lastOrNull { it is Entry.Asked } as? Entry.Asked ?: return
send(context, asked.text, asked.files)
}
/** The tool call still running in this turn, if any. */
fun running(): Entry.Used? {
if (state != State.WORKING) return null
val start = entries.indexOfLast { it is Entry.Asked }
return entries.withIndex().lastOrNull { (i, e) -> i > start && e is Entry.Used && e.output == null }?.value as? Entry.Used
}
/** The latest todo list the agent wrote in this conversation. */
fun todos(): String? = entries.lastOrNull { it is Entry.Used && it.tool == "TodoWrite" }?.let { (it as Entry.Used).input }
private fun tick() {
now = SystemClock.elapsedRealtime()
appContext?.let { app ->
claimLabRequest(app)
lab = LabProgress.read(LinuxRuntime.rootDir(app))
AgentHostService.update(app)
}
main.postDelayed(::tick, if (state == State.WORKING || lab != null) TICK_MS else IDLE_TICK_MS)
}
/**
* dd-lab-build run by the agent hands its build to the app: the turn's processes end with the
* turn, and a build must outlive a turn that ends early or is stopped.
*/
private fun claimLabRequest(app: Context) {
val request = File(app.filesDir, LAB_REQUEST)
if (!request.isFile) return
val claimed = File(app.filesDir, "$LAB_REQUEST.claimed")
if (!request.renameTo(claimed)) return
val o = runCatching { org.json.JSONObject(claimed.readText()) }.getOrNull()
claimed.delete()
if (o == null || labProcess?.isAlive == true) return
val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()
val cwd = o.optString("cwd").ifEmpty { WORKSPACE }
labFromTurn = state == State.WORKING
chat?.let { prefs(app).edit().putString(LAB_CHAT_KEY, it).apply() }
AgentHostService.setupStarted(app)
Thread({
runCatching {
val child = guest(app, listOf("sh", "-c",
"mkdir -p /root/.cache; DD_LAB_DETACHED=1 exec dd-lab-build \"\$@\" > /root/.cache/dd-lab-build.log 2>&1", "dd-lab-build") + args, cwd).start()
main.post { labProcess = child }
child.outputStream.close()
child.inputStream.readBytes()
child.waitFor()
}.onFailure { Log.w(TAG, "could not run a Lab build", it) }
main.post {
labProcess = null
labFromTurn = false
AgentHostService.setupEnded(app)
}
}, "agent-lab-build").start()
}
fun cancelLabBuild(context: Context) {
val progress = lab ?: return
LabProgress.cancel(LinuxRuntime.rootDir(context.applicationContext), progress)
lab = LabProgress.read(LinuxRuntime.rootDir(context.applicationContext))
}
private const val LEGACY_CONVERSATION_KEY = "session_id"
private const val HARNESS_KEY = "harness"
private const val CHAT_KEY = "chat"
private const val PLAN_KEY = "plan"
private const val RESUME = "agent/resume"
private const val LAB_REQUEST = "agent/lab-request"
private const val LAB_CHAT_KEY = "lab_chat"
/** The raw output of the latest turn, for working out what a CLI did when the chat shows nothing. */
private const val TURN_LOG = "agent/turn.log"
private const val RETRY_MS = 3_000L
private fun modelKey(harness: AgentHarness) = "model_${harness.id}"
internal fun prefs(context: Context) = context.getSharedPreferences("agent", Context.MODE_PRIVATE)
}
@@ -0,0 +1,110 @@
package com.droiddeck.launcher.agent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.droiddeck.launcher.FrontEndActivity
import com.droiddeck.launcher.R
/** Keeps the app process alive while the agent works, and says what it is doing, with a way to stop it. */
class AgentHostService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
if (AgentHost.lab?.running == true) AgentHost.cancelLabBuild(this)
AgentHost.stop(this)
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, build(this))
return START_NOT_STICKY
}
companion object {
private const val CHANNEL_ID = "agent"
private const val NOTIFICATION_ID = 4107
private const val ACTION_STOP = "com.droiddeck.launcher.agent.STOP"
private var lastText: String? = null
@Volatile private var started = false
/** Setup steps that keep the app alive while they run, besides the agent's turn. */
private var setupSteps = 0
fun start(context: Context) {
lastText = null
started = true
runCatching { context.startForegroundService(Intent(context, AgentHostService::class.java)) }
}
fun stop(context: Context) {
if (setupSteps > 0) return
started = false
context.stopService(Intent(context, AgentHostService::class.java))
}
fun setupStarted(context: Context) {
setupSteps++
start(context)
}
fun setupEnded(context: Context) {
setupSteps = (setupSteps - 1).coerceAtLeast(0)
if (AgentHost.state != AgentHost.State.WORKING) stop(context)
}
/** Refreshes the notification with the current step; unchanged text is not re-posted. */
fun update(context: Context) {
if (!started) return
val text = describe(context)
if (text == lastText) return
lastText = text
context.getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, build(context))
}
private fun describe(context: Context): String {
val lab = AgentHost.lab?.takeIf { it.running }
if (lab != null) return context.getString(R.string.agent_lab_progress, lab.permille / 10, minutes(lab.etaSeconds.toLong().coerceAtLeast(0)))
if (AgentHost.state != AgentHost.State.WORKING) return context.getString(R.string.agent_setting_up)
val tool = AgentHost.running()
val elapsed = minutes((SystemClock.elapsedRealtime() - AgentHost.turnStarted) / 1000)
return if (tool != null) "${tool.tool} · ${tool.detail}".take(120) + " · $elapsed" else context.getString(R.string.agent_thinking_for, elapsed)
}
private fun minutes(seconds: Long) = if (seconds < 60) "${seconds.coerceAtLeast(0)}s" else "${seconds / 60}m ${seconds % 60}s"
private fun build(context: Context): Notification {
val manager = context.getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, context.getString(R.string.agent_channel_name),
NotificationManager.IMPORTANCE_LOW).apply { setShowBadge(false) })
val open = PendingIntent.getActivity(context, 0,
Intent(context, FrontEndActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent"),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val stop = PendingIntent.getService(context, 1, Intent(context, AgentHostService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val lab = AgentHost.lab?.takeIf { it.running }
return Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(context.getString(when {
lab?.prepare == true -> R.string.agent_lab_preparing
lab != null -> R.string.agent_lab_building
else -> R.string.agent_working
}))
.setContentText(describe(context))
.setContentIntent(open)
.addAction(Notification.Action.Builder(null, context.getString(R.string.agent_stop), stop).build())
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.apply { if (lab != null) setProgress(1000, lab.permille, lab.stage != "building") }
.setCategory(Notification.CATEGORY_PROGRESS)
.build()
}
}
}
@@ -0,0 +1,105 @@
package com.droiddeck.launcher.agent
import android.os.Process
import android.os.SystemClock
import androidx.annotation.VisibleForTesting
import java.io.File
/**
* Linux userland processes, read from /proc: proot runs them as real processes under the app's
* own uid, so the app can list and end them without a shell.
*/
object AgentProcesses {
data class Proc(
val pid: Int, val ppid: Int, val name: String, val args: String,
val seconds: Long, val rssKb: Long, val cpuPercent: Int, val agent: Boolean,
)
private const val HZ = 100L
private var lastTicks = emptyMap<Int, Long>()
private var lastAt = 0L
@VisibleForTesting
internal class Stat(val pid: Int, val ppid: Int, val ticks: Long, val startTicks: Long, val rssPages: Long)
/** Guest processes, those under [turnPid] (the agent's current turn) first. */
@Synchronized
fun snapshot(turnPid: Int): List<Proc> {
val me = Process.myPid()
val uid = Process.myUid()
val stats = HashMap<Int, Stat>()
val args = HashMap<Int, String>()
for (dir in File("/proc").listFiles().orEmpty()) {
val pid = dir.name.toIntOrNull() ?: continue
if (pid == me) continue
val stat = runCatching { parseStat(File(dir, "stat").readText()) }.getOrNull() ?: continue
if (!owned(dir, uid)) continue
stats[pid] = stat
args[pid] = runCatching { File(dir, "cmdline").readBytes().toString(Charsets.UTF_8).trimEnd('\u0000').replace('\u0000', ' ') }.getOrDefault("")
}
val now = SystemClock.elapsedRealtime()
val uptimeTicks = now * HZ / 1000
val elapsed = (now - lastAt).coerceAtLeast(1)
fun isProot(pid: Int) = args[pid].orEmpty().substringBefore(' ').endsWith("proot.so") || args[pid].orEmpty().substringBefore(' ').endsWith("/proot")
fun ancestors(pid: Int) = generateSequence(stats[pid]?.ppid) { stats[it]?.ppid }.takeWhile { it > 1 }.take(64)
val out = stats.values.filter { s ->
!isProot(s.pid) && args[s.pid].orEmpty().isNotEmpty() && ancestors(s.pid).any { isProot(it) }
}.map { s ->
val full = args[s.pid].orEmpty()
val cpu = lastTicks[s.pid]?.let { ((s.ticks - it) * 1000 * 100 / HZ / elapsed).toInt().coerceIn(0, 800) } ?: 0
Proc(
s.pid, s.ppid, full.substringBefore(' ').substringAfterLast('/'), full,
((uptimeTicks - s.startTicks) / HZ).coerceAtLeast(0), s.rssPages * 4, cpu,
turnPid > 0 && ancestors(s.pid).any { it == turnPid },
)
}
lastTicks = stats.mapValues { it.value.ticks }
lastAt = now
return out.sortedWith(compareByDescending<Proc> { it.agent }.thenBy { it.seconds })
}
/** Ends [pid] and everything it started, children first so none is left to be re-parented. */
fun killTree(pid: Int) {
if (pid <= 0) return
val children = HashMap<Int, MutableList<Int>>()
for (dir in File("/proc").listFiles().orEmpty()) {
val child = dir.name.toIntOrNull() ?: continue
val stat = runCatching { parseStat(File(dir, "stat").readText()) }.getOrNull() ?: continue
children.getOrPut(stat.ppid) { mutableListOf() } += child
}
val order = mutableListOf<Int>()
fun walk(p: Int) {
children[p].orEmpty().forEach(::walk)
order += p
}
walk(pid)
order.forEach { runCatching { Process.sendSignal(it, Process.SIGNAL_KILL) } }
}
fun alive(pid: Int) = pid > 0 && File("/proc/$pid").exists()
/** Processes whose command line contains [text], for ending a build the app did not start. */
fun find(text: String): List<Int> = File("/proc").listFiles().orEmpty().mapNotNull { dir ->
val pid = dir.name.toIntOrNull() ?: return@mapNotNull null
if (pid == Process.myPid()) return@mapNotNull null
val args = runCatching { File(dir, "cmdline").readBytes().toString(Charsets.UTF_8).replace('\u0000', ' ') }.getOrDefault("")
pid.takeIf { args.contains(text) && owned(dir, Process.myUid()) }
}
private fun owned(dir: File, uid: Int) = runCatching {
File(dir, "status").useLines { lines -> lines.first { it.startsWith("Uid:") }.split(Regex("\\s+"))[1].toInt() == uid }
}.getOrDefault(false)
/** `/proc/<pid>/stat`: the name in parentheses may hold spaces, so fields count from its end. */
@VisibleForTesting
internal fun parseStat(text: String): Stat {
val pid = text.substringBefore(' ').toInt()
val f = text.substring(text.lastIndexOf(')') + 2).split(' ')
return Stat(pid, f[1].toInt(), f[11].toLong() + f[12].toLong(), f[19].toLong(), f[21].toLong())
}
/** The pid of a [java.lang.Process] the app started. */
fun pidOf(process: java.lang.Process): Int =
runCatching { process.javaClass.getDeclaredField("pid").apply { isAccessible = true }.getInt(process) }.getOrNull()
?: Regex("pid=(\\d+)").find(process.toString())?.groupValues?.get(1)?.toInt() ?: 0
}
@@ -0,0 +1,149 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.util.concurrent.TimeUnit
/**
* First-run setup for the agent, as steps with a check and an action each. The first three are
* needed to talk to the agent; GitHub and the build tools are only needed to open pull requests
* and to make Lab builds on the device.
*/
object AgentSetup {
enum class Step(val required: Boolean) { CONNECTION(true), INSTALL(true), SIGN_IN(true), GITHUB(false), BUILD_TOOLS(false), PREPARE(false) }
enum class Status { UNKNOWN, DONE, TODO, WORKING, FAILED }
val status = mutableStateMapOf<Step, Status>()
/** The last line of output from the step that is running or just failed. */
val detail = mutableStateMapOf<Step, String>()
var runtimeInstalled by mutableStateOf(true)
private set
val ready: Boolean get() = Step.entries.filter { it.required }.all { status[it] == Status.DONE }
/** Set once the required steps have all passed, so the chat opens without waiting for the checks. */
fun completed(context: Context) = AgentHost.prefs(context).getBoolean(completedKey(), false)
private fun completedKey() = "setup_completed_${AgentHost.harness.id}"
private val main = Handler(Looper.getMainLooper())
private const val REPO = "https://github.com/Droid-Deck/DroidDeck.git"
private const val CHECKOUT = "/root/DroidDeck"
private val HELPERS = listOf("android-open", "dd-gh-login")
fun check(context: Context) {
val app = context.applicationContext
runtimeInstalled = LinuxRuntime.isInstalled(app)
if (!runtimeInstalled) return
AgentHost.refresh(app)
val harness = AgentHost.harness
val probes = mutableListOf<Pair<Step, List<String>>>()
for (step in Step.entries) if (status[step] != Status.WORKING) {
when (step) {
Step.INSTALL -> set(step, if (harness.files.all { AgentHost.guestFileExists(app, it) }) Status.DONE else Status.TODO)
Step.BUILD_TOOLS -> set(step, if (buildToolsInstalled(app)) Status.DONE else Status.TODO)
Step.PREPARE -> set(step, if (prepared(app)) Status.DONE else Status.TODO)
Step.CONNECTION -> probes += step to listOf("sh", "-c",
"for i in 1 2 3; do adb connect \"\$ADB_SERIAL\" >/dev/null 2>&1; " +
"adb -s \"\$ADB_SERIAL\" get-state 2>&1 | tee /dev/stderr | grep -q device && exit 0; sleep 1; done; exit 1")
Step.SIGN_IN -> if (AgentHost.guestFileExists(app, harness.binary)) probes += step to harness.signedIn else set(step, Status.TODO)
Step.GITHUB -> probes += step to listOf("gh", "auth", "status")
}
}
Thread({ for ((step, command) in probes) probe(app, step, command) }, "agent-checks").start()
}
/** Runs [step]'s action. [CONNECTION] is the app's own pairing flow, so the caller opens it. */
fun run(context: Context, step: Step) {
val app = context.applicationContext
val harness = AgentHost.harness
val command = when (step) {
Step.CONNECTION -> return
Step.INSTALL -> {
listOf("sh", "-c", "set -e; export DEBIAN_FRONTEND=noninteractive; " +
"apt-get install -y --no-install-recommends git gh adb curl ca-certificates python3; ${harness.install}")
}
Step.SIGN_IN -> harness.signIn
Step.GITHUB -> listOf("dd-gh-login")
Step.BUILD_TOOLS -> listOf("sh", "-c", "set -e; [ -d $CHECKOUT/.git ] || git clone --filter=blob:none $REPO $CHECKOUT; " +
"cd $CHECKOUT; [ -x tools/agent/setup ] || { echo 'This DroidDeck version has no tools/agent/setup yet'; exit 1; }; tools/agent/setup")
Step.PREPARE -> listOf("sh", "-c", "cd $CHECKOUT && DD_LAB_DETACHED=1 dd-lab-build --prepare")
}
set(step, Status.WORKING)
detail.remove(step)
AgentHostService.setupStarted(app)
Thread({
val result = runCatching {
if (step == Step.INSTALL) installHelpers(app)
val child = AgentHost.guest(app, command).start()
child.outputStream.close()
child.inputStream.bufferedReader().forEachLine { line ->
val text = line.trim()
if (text.isNotEmpty()) main.post { detail[step] = text.take(160) }
}
child.waitFor()
}
main.post {
AgentHostService.setupEnded(app)
if (result.getOrNull() == 0) {
detail.remove(step)
status[step] = Status.UNKNOWN
check(app)
} else {
status[step] = Status.FAILED
if (result.isFailure) detail[step] = result.exceptionOrNull()?.message ?: "failed"
}
}
}, "agent-setup-${step.name.lowercase()}").start()
}
fun buildToolsInstalled(app: Context) = AgentHost.guestFileExists(app, "/root/aapt2/aapt2") && AgentHost.guestFileExists(app, "$CHECKOUT/.git")
/** dd-lab-build compiled this install's commit, so a Lab build compiles only what changed. */
private fun prepared(app: Context) = runCatching {
File(LinuxRuntime.rootDir(app), "root/.cache/dd-lab-build-base").readText().trim()
.startsWith(com.droiddeck.launcher.lab.LabOverlay.apkCommit.take(7))
}.getOrDefault(false)
/** Drops what the checks found, which belonged to the previous harness. */
fun forget() {
status.clear()
detail.clear()
}
private fun probe(app: Context, step: Step, command: List<String>) {
var output = ""
val ok = runCatching {
val child = AgentHost.guest(app, command, connectAdb = step == Step.CONNECTION).start()
child.outputStream.close()
output = child.inputStream.bufferedReader().readText()
child.waitFor(60, TimeUnit.SECONDS) && child.exitValue() == 0
}.onFailure { output = it.message.orEmpty() }.getOrDefault(false)
main.post {
if (status[step] != Status.WORKING) status[step] = if (ok) Status.DONE else Status.TODO
val last = output.lines().lastOrNull { it.isNotBlank() }?.trim()?.take(160)
if (!ok && step.required && last != null) detail[step] = last else if (ok) detail.remove(step)
if (ready) AgentHost.prefs(app).edit().putBoolean(completedKey(), true).apply()
}
}
private fun set(step: Step, value: Status) {
if (Looper.myLooper() == Looper.getMainLooper()) status[step] = value else main.post { status[step] = value }
}
private fun installHelpers(app: Context) {
val bin = File(LinuxRuntime.rootDir(app), "root/.local/bin").apply { mkdirs() }
for (name in HELPERS) {
val target = File(bin, name)
app.assets.open("agent/$name").use { input -> target.outputStream().use { input.copyTo(it) } }
target.setExecutable(true, false)
}
}
}
@@ -0,0 +1,95 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.mutableStateMapOf
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
/**
* What the agent adds to the Linux userland, measured and removable piece by piece. Sign-ins
* (~/.claude, ~/.codex, gh's config) are left alone, so removing a CLI and installing it again
* does not sign out.
*/
object AgentStorage {
enum class Item(val paths: List<String>) {
CLAUDE_CODE(listOf("/root/.local/share/claude", "/root/.local/bin/claude", "/root/cc")),
CODEX(listOf("/root/.local/bin/codex", "/root/.local/bin/codex-code-mode-host")),
BUILD_TOOLS(listOf("/root/android-sdk", "/root/aapt2", "/root/x86root", "/root/.gradle")),
SOURCE(listOf("/root/DroidDeck")),
CACHES(listOf("/root/.cache", "/root/.npm")),
}
/** Bytes per item; missing while it is being measured. */
val sizes = mutableStateMapOf<Item, Long>()
val removing = mutableStateMapOf<Item, Boolean>()
private val main = Handler(Looper.getMainLooper())
fun measure(context: Context) {
val root = LinuxRuntime.rootDir(context.applicationContext)
Thread({
for (item in Item.entries) {
val bytes = item.paths.sumOf { size(File(root, it.removePrefix("/")).toPath()) }
main.post { sizes[item] = bytes }
}
}, "agent-storage").start()
}
fun remove(context: Context, item: Item) {
val app = context.applicationContext
val root = LinuxRuntime.rootDir(app)
removing[item] = true
Thread({
for (path in item.paths) runCatching { delete(File(root, path.removePrefix("/")).toPath()) }
main.post {
removing.remove(item)
AgentHost.refresh(app)
AgentSetup.check(app)
measure(app)
}
}, "agent-storage-remove").start()
}
/** Walks without following links, so a link out of the rootfs (into /sdcard, say) counts and removes only itself. */
@VisibleForTesting
internal fun size(path: Path): Long {
if (!Files.exists(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return 0
var total = 0L
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
total += attrs.size()
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(file: Path, exc: IOException) = FileVisitResult.CONTINUE
})
return total
}
@VisibleForTesting
internal fun delete(path: Path) {
if (!Files.exists(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.deleteIfExists(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
Files.deleteIfExists(dir)
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
runCatching { Files.deleteIfExists(file) }
return FileVisitResult.CONTINUE
}
})
}
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.agent
import androidx.annotation.VisibleForTesting
import org.json.JSONObject
import java.io.File
/** A Lab build in progress, as dd-lab-build reports it in `~/.cache/dd-lab-build.json`. */
data class LabProgress(
val stage: String, val prepare: Boolean, val permille: Int, val task: String,
val done: Int, val total: Int, val etaSeconds: Int, val elapsedSeconds: Int, val pid: Int,
) {
val running get() = stage in RUNNING
val failed get() = stage == "failed"
companion object {
private val RUNNING = setOf("checking", "fetching", "sdk", "planning", "building", "packaging", "restarting")
private const val PATH = "root/.cache/dd-lab-build.json"
/** A finished build stays on screen this long. */
private const val SHOW_DONE_MS = 60_000L
/** The build, while it runs and shortly after; null when there is none to show. */
fun read(rootfs: File): LabProgress? {
val file = File(rootfs, PATH)
if (!file.isFile) return null
val parsed = parse(runCatching { file.readText() }.getOrNull() ?: return null) ?: return null
val progress = when {
!parsed.running || AgentProcesses.alive(parsed.pid) -> parsed
// The restart ends the app and the script with it, so a restart that got that far succeeded.
parsed.stage == "restarting" -> parsed.copy(stage = "ready")
else -> parsed.copy(stage = "failed", task = "stopped")
}
return progress.takeIf { it.running || System.currentTimeMillis() - file.lastModified() < SHOW_DONE_MS }
}
/** Ends the build and Gradle's daemon, which keeps compiling after its client is gone. */
fun cancel(rootfs: File, progress: LabProgress) {
AgentProcesses.killTree(progress.pid)
AgentProcesses.find("GradleDaemon").forEach(AgentProcesses::killTree)
runCatching {
File(rootfs, PATH).writeText(JSONObject().put("stage", "failed").put("task", "stopped").put("pid", 0).toString())
}
}
@VisibleForTesting
internal fun parse(json: String): LabProgress? = runCatching {
val o = JSONObject(json)
LabProgress(
o.getString("stage"), o.optString("mode") == "prepare", o.optInt("permille"), o.optString("task"),
o.optInt("done"), o.optInt("total"), o.optInt("eta", -1), o.optInt("elapsed"), o.optInt("pid"),
)
}.getOrNull()
}
}
@@ -18,9 +18,10 @@ object Library {
val source: String = SOURCE_STEAM,
val storeId: String? = null,
val logo: File? = null,
val installedAt: Long = 0L,
) {
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo, installedAt)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
@@ -31,6 +32,7 @@ object Library {
}
}
const val ADDED = "added"
private const val PLAYED = "added_games_played"
const val EPIC_LAUNCHER = "epic-launcher"
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
const val BATTLENET_LAUNCHER = "battlenet-launcher"
@@ -221,6 +223,18 @@ object Library {
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
}
fun recordPlayed(context: Context, game: SteamGame) {
if (game.library == ADDED) recordPlayed(context, game.gameIdString)
}
fun recordPlayed(context: Context, gameId: String) {
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).edit()
.putLong(gameId, System.currentTimeMillis() / 1000).apply()
}
internal fun recordedPlay(context: Context, gameId: Long): Long =
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).getLong(java.lang.Long.toUnsignedString(gameId), 0L)
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
val config = AddedGames.accountConfig(root) ?: return emptyMap()
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
@@ -249,12 +263,13 @@ object Library {
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder,
hero = art.hero ?: art.header, lastPlayed = maxOf(lastPlayed, recordedPlay(context, g.gameId)), gameFiles = g.folder,
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
logo = art.logo,
installedAt = g.folder.lastModified(),
)
}
@@ -27,6 +27,7 @@ object LibraryCache {
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
storeId = o.optString("storeId").ifEmpty { null },
logo = o.file("logo"),
installedAt = o.optLong("installedAt"),
)
} to x64
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
@@ -44,7 +45,7 @@ object LibraryCache {
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo)
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
.put("source", g.source).put("installedAt", g.installedAt).apply { g.storeId?.let { put("storeId", it) } },
)
val f = file(context)
val tmp = File(f.path + ".tmp")
@@ -159,6 +159,12 @@ object DriverBundle {
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
}
fun selectDisplay(context: Context, id: String) {
val left = active(context)
SessionPrefs.setAndroidDriver(context, id)
if (left != null && left.androidId != id) SessionPrefs.setLinuxDriver(context, "")
}
fun remove(context: Context, bundle: Bundle) {
TurnipDriver(context).remove(bundle.androidId)
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
@@ -191,6 +191,7 @@ public final class SystemVulkanDriver {
/** Collect abandoned stages, and obsolete versions once older guests can no longer load them. */
public static synchronized void pruneUnused(Context context, boolean guestsStopped) throws IOException {
if (!AddonPack.POWERVR.installed(context)) return;
pruneUnused(runtimeDirectory(context), guestsStopped);
}
@@ -6,6 +6,7 @@ import kotlin.math.roundToInt
internal class FrameStats {
private val graph = FloatArray(GRAPH_FRAMES)
private var graphHead = 0
private var graphFrames = 0L
private var lastPresentNs = 0L
private var lastFpsUpdateNs = 0L
private var framesSinceUpdate = 0
@@ -38,6 +39,7 @@ internal class FrameStats {
if (lastPresentNs != 0L) {
graph[graphHead] = frametimeNs / 1e6f
graphHead = (graphHead + 1) % GRAPH_FRAMES
graphFrames++
}
val elapsed = timeNs - lastFpsUpdateNs
if (elapsed >= FPS_PERIOD_NS) {
@@ -68,6 +70,7 @@ internal class FrameStats {
fun reset() {
graph.fill(0f)
graphHead = 0
graphFrames = 0L
lastPresentNs = 0L
lastFpsUpdateNs = 0L
framesSinceUpdate = 0
@@ -86,8 +89,9 @@ internal class FrameStats {
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
@Synchronized
fun graph(out: FloatArray) {
fun graph(out: FloatArray): Long {
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
return graphFrames
}
@Synchronized
@@ -9,6 +9,7 @@ import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.Log
import android.view.Display
import android.view.accessibility.AccessibilityEvent
@@ -33,6 +34,8 @@ object BottomScreenGuests {
else -> null
}
internal fun current(arrivedAt: Long, ownShownAt: Long?): Boolean = ownShownAt == null || arrivedAt >= ownShownAt
internal fun ignorable(type: Int): Boolean = type == AccessibilityWindowInfo.TYPE_INPUT_METHOD ||
type == AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY ||
type == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER ||
@@ -43,7 +46,7 @@ object BottomScreenGuests {
}
class BottomScreenWatch(private val service: AccessibilityService) {
private class Arrival(val pkg: String)
private class Arrival(val pkg: String, val at: Long)
private val thread = HandlerThread("bottom-screen").apply { start() }
private val handler = Handler(thread.looper)
@@ -52,6 +55,8 @@ class BottomScreenWatch(private val service: AccessibilityService) {
private var homes: Set<String> = emptySet()
private var keyboards: Set<String> = emptySet()
private var holders: Map<Int, String?> = emptyMap()
private var ownWindows: Map<Int, Set<Int>> = emptyMap()
private val ownShownAt = mutableMapOf<Int, Long>()
private val check = Runnable { evaluate() }
fun start() {
@@ -72,7 +77,7 @@ class BottomScreenWatch(private val service: AccessibilityService) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
val pkg = event.packageName?.toString() ?: return
if (event.displayId == Display.DEFAULT_DISPLAY || pkg == own || pkg == SYSTEM_UI) return
arrivals[event.displayId] = Arrival(pkg)
arrivals[event.displayId] = Arrival(pkg, event.eventTime)
}
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
else -> return
@@ -97,19 +102,25 @@ class BottomScreenWatch(private val service: AccessibilityService) {
val all = runCatching { service.windowsOnAllDisplays }.getOrNull() ?: return
val displays = service.getSystemService(DisplayManager::class.java) ?: return
val taken = mutableMapOf<Int, String?>()
val shown = mutableMapOf<Int, Set<Int>>()
val now = SystemClock.uptimeMillis()
for (i in 0 until all.size()) {
val id = all.keyAt(i)
if (id == Display.DEFAULT_DISPLAY) continue
val display = displays.getDisplay(id)?.takeIf(DualScreen::builtIn) ?: continue
val windows = all.valueAt(i)
val topPkg = windows.asSequence()
.filter { !BottomScreenGuests.ignorable(it.type) && cover(it, display) >= BottomScreenGuests.MIN_COVER }
.sortedByDescending { it.layer }
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
val arrival = seen[id]
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
.map { it to packageOf(it) }
val ours = windows.filter { it.second == own }.map { it.first.id }.toSet()
if (!ownWindows[id].orEmpty().containsAll(ours)) ownShownAt[id] = now
shown[id] = ours
val topPkg = windows.firstNotNullOfOrNull { it.second?.takeUnless { pkg -> pkg == own } }
val arrival = seen[id]?.takeIf { BottomScreenGuests.current(it.at, ownShownAt[id]) }
seen[id]?.takeIf { topPkg != null || arrival == null }?.let { arrivals.remove(id, it) }
BottomScreenGuests.holder(topPkg, arrival?.pkg?.takeUnless { it in keyboards }, homes)?.let { taken[id] = it }
}
ownWindows = shown
if (current !== this) return
if (taken != holders) Log.i(TAG, "bottom screen holders: $taken")
holders = taken
@@ -73,7 +73,7 @@ object ControllerPrefs {
keyboardButton = p.getBoolean("keyboardButton", true),
selectSteam = p.getBoolean("selectSteam", false),
joyConVr = joyConVr(context),
snap = p.getBoolean("snap", true),
snap = snap(context),
customLayout = p.all.keys.any { it.startsWith("layout.") },
mapping = mappable.associate { (id, _) -> id to target(context, id) },
deadZone = deadZone(context),
@@ -87,11 +87,6 @@ object ControllerPrefs {
prefs(context).edit().putString("map.$id", target).apply()
}
fun resetMapping(context: Context) {
val p = prefs(context)
p.edit().apply { p.all.keys.filter { it.startsWith("map.") }.forEach { remove(it) } }.apply()
}
fun setTint(context: Context, tint: Int) {
prefs(context).edit().putInt("tint", tint).apply()
}
@@ -129,10 +124,6 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("steamButton", on).apply()
}
fun setQamButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("qamButton", on).apply()
}
fun setKeyboardButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("keyboardButton", on).apply()
}
@@ -147,6 +138,8 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("joyConVr", on).apply()
}
fun snap(context: Context): Boolean = prefs(context).getBoolean("snap", true)
fun setSnap(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("snap", on).apply()
}
@@ -172,9 +165,4 @@ object ControllerPrefs {
fun resetAll(context: Context) {
prefs(context).edit().clear().putBoolean("rumble", true).apply()
}
fun resetAllLayouts(context: Context) {
val p = prefs(context)
p.edit().apply { p.all.keys.filter { it.startsWith("layout.") }.forEach { remove(it) } }.apply()
}
}
@@ -70,6 +70,14 @@ class DirectTouchHold(
handler.removeCallbacks(hold)
}
fun cancelTouch() {
handler.removeCallbacks(hold)
handler.removeCallbacks(lift)
liftPending = false
if (forwarded) touch.cancel()
forwarded = false
}
fun onTouch(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
if (liftPending) {
@@ -4,12 +4,13 @@ import android.content.Context
import android.hardware.display.DisplayManager
import android.util.DisplayMetrics
import android.view.Display
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
object InputLibraries {
private const val VERSION = 12
private const val VERSION = 14
private const val KEY = "library"
private const val RECOVERY = "library-recovery."
const val OVERLAY = "builtin.overlay"
@@ -30,6 +31,8 @@ object InputLibraries {
if (version < 11) library = neutralStockLayouts(library)
if (version < 10) library = restyleGamepads(context, library)
if (version < 12) library = barStockLayouts(library)
if (version < 13) library = LegacyScreenKeyMigration.pin(library)
if (version < 14) library = deckGamepadSurfaces(context, library)
if (version < 6 || !p.contains(LegacyScreenKeyMigration.BACKUP)) {
val legacy = LegacyScreenKeyMigration.snapshot(context)
if (legacy != null) {
@@ -263,6 +266,15 @@ object InputLibraries {
return addStockLayouts(result)
}
private fun deckGamepadSurfaces(context: Context, library: InputLibrary): InputLibrary {
val leftPad = SessionPrefs.gamepadSurface(context, right = false) == SessionPrefs.SURFACE_TRACKPAD
val rightStick = SessionPrefs.gamepadSurface(context, right = true) == SessionPrefs.SURFACE_STICK
if (!leftPad && !rightStick) return library
val saved = library.layouts.firstOrNull { it.id == DeckPanel.GAMEPAD.layout } ?: return library
if (saved.elements != InputStockLayouts.gamepad(leftPad = false, rightStick = false)) return library
return library.withLayout(saved.copy(elements = InputStockLayouts.gamepad(leftPad, rightStick))).validate()
}
private fun migrationBindingId(library: InputLibrary, binding: String, layout: String): String {
val seed = JSONArray(listOf(binding, layout)).toString().toByteArray(Charsets.UTF_8)
val base = "migrated.bindings." + UUID.nameUUIDFromBytes(seed)
@@ -292,7 +304,7 @@ object InputLibraries {
for (e in layout.elements) elements.put(JSONObject().put("id", e.id).put("kind", e.kind.name)
.put("control", e.control.id).put("name", e.name).put("x", e.x.toDouble()).put("y", e.y.toDouble())
.put("width", e.width.toDouble()).put("height", e.height.toDouble()).put("floating", e.floating).put("hideWhenIdle", e.hideWhenIdle).put("mouseGestures", e.mouseGestures).put("toggle", e.toggle).put("icon", e.icon).put("image", e.image)
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel))
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel).put("alwaysShown", e.alwaysShown))
layouts.put(JSONObject().put("id", layout.id).put("name", layout.name).put("template", layout.template.name).put("elements", elements)
.put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name).put("aspect", layout.aspect.toDouble()).put("scale", layout.scale.toDouble())
.put("hidden", JSONArray(layout.style.hidden.map { it.name }.sorted())).put("origin", layout.origin ?: JSONObject.NULL))
@@ -333,7 +345,7 @@ object InputLibraries {
e.getDouble("y").toFloat(), e.getDouble("width").toFloat(), e.getDouble("height").toFloat(),
e.getString("id"), InputElementKind.valueOf(e.getString("kind")), e.getString("name"),
e.optBoolean("floating", true), e.optBoolean("hideWhenIdle", false), e.optBoolean("mouseGestures", false), e.optBoolean("toggle", false), e.optInt("icon", 0), e.optString("image", ""),
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true))
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true), e.optBoolean("alwaysShown", false))
}
SavedInputLayout(layout.getString("id"), layout.getString("name"), elements, InputLayoutTemplate.valueOf(layout.getString("template")),
InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100),
@@ -45,6 +45,14 @@ internal object InputStockLayouts {
InputSurfaceElement(InputControl.SELECT, .42f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL),
InputSurfaceElement(InputControl.START, .51f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL))
}
fun gamepad(leftPad: Boolean, rightStick: Boolean) = gamepad().map {
when {
leftPad && it.control == InputControl.LS -> InputSurfaceElement(InputControl.LEFT_PAD, it.x, it.y, it.width, it.height)
rightStick && it.control == InputControl.RIGHT_PAD -> InputSurfaceElement(InputControl.RS, it.x, it.y, it.width, it.height)
else -> it
}
}
fun layouts() = listOf(
SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", listOf(
@@ -34,6 +34,7 @@ data class InputSurfaceElement(
val opacity: Float = 1f,
val buttonShape: InputButtonShape = InputButtonShape.ROUNDED,
val showLabel: Boolean = true,
val alwaysShown: Boolean = false,
) {
val hasClick get() = kind == InputElementKind.STICK || kind == InputElementKind.TRACKPAD
@@ -11,6 +11,7 @@ class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole
private val gestureRight = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(273)) else null
private val gestureMiddle = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(274)) else null
val mouseGestures get() = element.mouseGestures && route?.action is InputAction.Pointer
val directions get() = route?.action as? InputAction.Directions
fun gestureContact(touching: Boolean) { if (!closed) route?.update(0f, 0f, touching) }
fun pointerDelta(dx: Float, dy: Float) {
@@ -225,7 +225,8 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
)
}
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean): List<InputSurfaceElement> {
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean, left: InputControl = InputControl.LS,
right: InputControl = InputControl.RS): List<InputSurfaceElement> {
val boxes = GamepadGeometry.layout(width.toFloat(), height.toFloat(), resources.displayMetrics.density, portrait)
val placed = mutableListOf<InputSurfaceElement>()
fun box(control: InputControl, slot: GamepadGeometry.Slot, shape: InputButtonShape) {
@@ -243,9 +244,9 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
}
}
box(InputControl.LS, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
box(InputControl.RS, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
box(right, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
cross(FACE_ORDER, GamepadGeometry.Slot.RIGHT_SECOND)
box(InputControl.LT, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER, InputButtonShape.PILL)
box(InputControl.LB, GamepadGeometry.Slot.LEFT_INNER_SHOULDER, InputButtonShape.PILL)
@@ -254,7 +255,8 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
box(InputControl.SELECT, GamepadGeometry.Slot.VIEW, InputButtonShape.PILL)
box(InputControl.START, GamepadGeometry.Slot.MENU, InputButtonShape.PILL)
if (grips) GRIPS.forEach { (control, slot) -> box(control, slot, InputButtonShape.ROUNDED) }
return withGeometry(stored, placed) + placed.filter { p -> GRIPS.any { it.first == p.control } && stored.none { it.id == p.id } }
val surfaces = listOf(left, right).filter { it.isTrackpad }
return withGeometry(stored, placed) + placed.filter { p -> (GRIPS.any { it.first == p.control } || p.control in surfaces) && stored.none { it.id == p.id } }
}
private fun circle(e: InputSurfaceElement, cx: Float, cy: Float, radius: Float) =
@@ -40,7 +40,7 @@ internal object LegacyScreenKeyMigration {
if (result.profiles.any { it.id == id }) continue
val converted = convert(context, set.second, metrics.widthPixels, metrics.heightPixels, metrics.density)
if (converted.isEmpty()) continue
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first }, style = style, screen = InputLayoutScreen.MAIN,
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first.copy(alwaysShown = true) }, style = style, screen = InputLayoutScreen.MAIN,
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
val bindings = SavedInputBindings("$id.bindings", set.first, false, layout.id, elements = converted.associate { it.first.id to it.second })
val base = library.profile(library.steam)
@@ -68,6 +68,17 @@ internal object LegacyScreenKeyMigration {
return result.validate()
}
fun pin(library: InputLibrary): InputLibrary {
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" }?.elements.orEmpty()
return library.copy(layouts = library.layouts.map { layout ->
val imported = layout.id.startsWith("migrated.screenkeys.")
layout.copy(elements = layout.elements.map { e ->
val key = e.id.startsWith("imported:") && (imported || keys.any { it.id == e.id && it.kind == e.kind && it.name == e.name })
if (key) e.copy(alwaysShown = true) else e
})
})
}
internal fun convert(context: Context, raw: String, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
convert(context, parse(raw), width, height, density)
@@ -29,6 +29,9 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
private set
var isQuickHidden = false
private set
var isPinnedOnly = false
private set
val hasPinned get() = layout?.elements?.any { it.alwaysShown } == true
var onTouchMiss: ((MotionEvent) -> Unit)?
get() = surface.onTouchMiss
set(value) { surface.onTouchMiss = value }
@@ -61,6 +64,13 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
render()
}
fun setPinnedOnly(enabled: Boolean) {
if (isPinnedOnly == enabled) return
releaseAll()
isPinnedOnly = enabled
render()
}
private data class Placement(val layout: SavedInputLayout?, val width: Int, val height: Int, val safe: Rect, val buttonsOnly: Boolean)
private fun placed(): List<InputSurfaceElement> {
@@ -72,12 +82,16 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
private fun render() {
val elements = placed()
surface.updateCustomLayout(if (isQuickHidden) elements.filter { it.id == "builtin:keyboard" } else elements)
surface.updateCustomLayout(when {
isPinnedOnly -> elements.filter { it.alwaysShown }
isQuickHidden -> elements.filter { it.id == "builtin:keyboard" || it.alwaysShown }
else -> elements
})
val style = layout?.style ?: InputSurfaceStyle()
surface.updateStyle(style)
quickToggle.tint = style.tint
quickToggle.alpha = style.opacity / 100f
quickToggle.visibility = if (isButtonsOnly || layout == null || dock != null) GONE else VISIBLE
quickToggle.visibility = if (isButtonsOnly || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
quickToggle.invalidate()
}
@@ -7,7 +7,18 @@ import kotlin.math.max
import kotlin.math.min
object OnScreenLayout {
data class Dock(val portrait: Boolean, val grips: Boolean)
data class Dock(val portrait: Boolean, val grips: Boolean, val left: InputControl = InputControl.LS, val right: InputControl = InputControl.RS) {
companion object {
fun deck(portrait: Boolean, gamepad: SavedInputLayout?): Dock {
fun side(right: Boolean) = gamepad?.elements?.firstOrNull {
(it.kind == InputElementKind.STICK || it.kind == InputElementKind.TRACKPAD) && (it.x + it.width / 2f >= .5f) == right
}?.kind
val left = if (side(false) == InputElementKind.TRACKPAD) InputControl.LEFT_PAD else InputControl.LS
val right = if (side(true) == InputElementKind.STICK) InputControl.RS else InputControl.RIGHT_PAD
return Dock(portrait, true, left, right)
}
}
}
private const val TOLERANCE = .01f
private const val KEYBOARD_ID = "builtin:keyboard"
@@ -17,13 +28,13 @@ object OnScreenLayout {
val stored = layout?.elements ?: return emptyList()
if (width <= 0 || height <= 0) return stored
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored)
val (reference, generated) = reference(context, layout, width, height)
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored) + fit(stored.filter { it.alwaysShown }, reference.first, reference.second, width, height, safe)
val gamepad = if (generated) stored.filter(::isGamepad) else emptyList()
val others = fit(stored.filterNot { generated && isGamepad(it) }, reference.first, reference.second, width, height, safe)
val others = fit(stored.filterNot { generated && isGamepad(it) || dock != null && it.alwaysShown }, reference.first, reference.second, width, height, safe)
val placed = when {
gamepad.isEmpty() -> emptyList()
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips)
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips, dock.left, dock.right)
else -> generator.regenerate(gamepad)
}
val byId = (placed + others).associateBy { it.id }
@@ -52,4 +52,7 @@ object SecondScreenDisplays {
)
}
.toList()
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
(listOf(selected) + available).distinct().filterNot(taken)
}
@@ -213,6 +213,9 @@ data class TouchGestureConfig(
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
fun gamescopeRelativeMouse(mode: Int, mouseAttached: Boolean, touchpad: Boolean): Int =
if (mode == RELATIVE_MOUSE_AUTO && !mouseAttached && !touchpad) RELATIVE_MOUSE_OFF else mode
private fun normalizeRelativeMouseMode(mode: Int): Int {
return if (mode in RELATIVE_MOUSE_AUTO..RELATIVE_MOUSE_OFF) mode else RELATIVE_MOUSE_AUTO
}
@@ -0,0 +1,60 @@
package com.droiddeck.launcher.lab
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import com.droiddeck.launcher.FrontEndActivity
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.download.StoreDownloadService
/**
* Says what happened to a Lab build when the app starts, so a restart onto a new build does not
* look like a crash, and a build Android or the safety checks turned off is not lost silently.
*/
object LabNotice {
private const val CHANNEL_ID = "lab"
private const val NOTIFICATION_ID = 4120
/** Where a start that just moved a Lab build into use opens; the agent that built it sets it. */
@Volatile var openOnInstall: String = "updates"
fun started(context: Context) {
val app = context.applicationContext
val active = LabOverlay.active
val skipped = LabOverlay.skipped
val current = LabOverlay.current(app)
when {
LabOverlay.promoted && active != null ->
post(app, app.getString(R.string.lab_installed_title), app.getString(R.string.lab_installed_text, active.label), openOnInstall)
skipped == LabOverlay.SKIPPED_APP_IMAGE && current != null ->
post(app, app.getString(R.string.lab_paused_title), app.getString(R.string.lab_paused_text, current.label), "updates")
skipped == LabOverlay.Reason.OTHER_INSTALL.name || skipped == LabOverlay.Reason.DID_NOT_START.name -> {
val reason = if (skipped == LabOverlay.Reason.OTHER_INSTALL.name) R.string.lab_off_other_install else R.string.lab_off_did_not_start
post(app, app.getString(R.string.lab_off_title), app.getString(reason), "updates")
}
}
}
private fun post(app: Context, title: String, text: String, nav: String) {
val manager = app.getSystemService(NotificationManager::class.java) ?: return
manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, app.getString(R.string.upd_lab), NotificationManager.IMPORTANCE_DEFAULT))
val open = PendingIntent.getActivity(
app, NOTIFICATION_ID,
Intent(app, FrontEndActivity::class.java).putExtra(StoreDownloadService.EXTRA_NAV, nav)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = Notification.Builder(app, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(title)
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setContentIntent(open)
.setAutoCancel(true)
.build()
runCatching { manager.notify(NOTIFICATION_ID, notification) }
}
}
@@ -0,0 +1,267 @@
package com.droiddeck.launcher.lab
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.content.res.loader.ResourcesLoader
import android.content.res.loader.ResourcesProvider
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.BuildConfig
import org.json.JSONObject
import java.io.File
/**
* A Lab build: app code and resources an on-device agent built from this install's own commit,
* loaded over the installed APK at startup so it can be tried without reinstalling or re-signing.
*
* `files/lab/next` holds a build waiting for the next start and `files/lab/active` the one in use,
* each with `overlay.apk` (the build's dex files and linked resources) and `overlay.json`. A build
* that is replaced, turned off or refused moves to `files/lab/builds`, so it can be used again.
*/
object LabOverlay {
data class Info(val label: String, val baseCommit: String, val baseApk: String, val built: Long = 0)
enum class Reason { REPLACED, TURNED_OFF, OTHER_INSTALL, DID_NOT_START, UNREADABLE }
/** A build kept in `files/lab/builds`; [usable] when it was made for this install. */
data class Saved(val dir: File, val info: Info?, val reason: Reason?, val usable: Boolean)
private const val TAG = "LabOverlay"
private const val MAX_FAILED_STARTS = 2
private const val STABLE_AFTER_MS = 10_000L
private const val KEEP_BUILDS = 12
const val SKIPPED_APP_IMAGE = "app image present"
/** The installed APK's commit; this class always comes from the APK, never from a Lab build. */
val apkCommit: String = BuildConfig.SOURCE_COMMIT
@Volatile var active: Info? = null
private set
@Volatile var skipped: String? = null
private set
/** This start moved a newly staged build into use. */
@Volatile var promoted = false
private set
private var loader: Any? = null
@VisibleForTesting
internal var injectDex: (ClassLoader, File) -> Unit = ::prependDex
@VisibleForTesting
internal var openResources: (File) -> Any? = { if (Build.VERSION.SDK_INT >= 30) createLoader(it) else null }
@VisibleForTesting
internal var appImage: (Context) -> Boolean = ::hasAppImage
fun root(context: Context) = File(context.filesDir, "lab")
/** Called from [Application.attachBaseContext], before any class the build may change is loaded. */
fun load(app: Application, mainProcess: Boolean) {
active = null
skipped = null
promoted = false
try {
val root = root(app)
if (mainProcess) promoted = promote(root)
val dir = File(root, "active")
val apk = File(dir, "overlay.apk")
val meta = File(dir, "overlay.json")
if (!apk.isFile || !meta.isFile) return
val info = parse(meta.readText()) ?: return refuse(root, mainProcess, Reason.UNREADABLE)
if (!madeFor(app, info)) return refuse(root, mainProcess, Reason.OTHER_INSTALL)
if (Build.VERSION.SDK_INT < 30) return skip("needs Android 11")
// Classes in an app image are loaded before attachBaseContext and would ignore the overlay;
// dd-lab-build recompiles the app without one (cmd package compile -m verify).
if (appImage(app)) return skip(SKIPPED_APP_IMAGE)
if (mainProcess) {
val starts = failedStarts(root) + 1
if (starts > MAX_FAILED_STARTS) return refuse(root, true, Reason.DID_NOT_START)
File(root, "starts").writeText(starts.toString())
}
apk.setReadOnly()
loader = openResources(apk)
injectDex(app.classLoader, apk)
active = info
addLoader(app.resources)
app.registerActivityLifecycleCallbacks(Callbacks(root, mainProcess))
} catch (e: Throwable) {
Log.w(TAG, "Lab build not loaded", e)
skipped = e.message ?: e.javaClass.simpleName
}
}
/** The APK's path changes with every install and update of the app, so it pins a build to one install. */
fun madeFor(context: Context, info: Info) = info.baseCommit == apkCommit && info.baseApk == context.applicationInfo.sourceDir
/** The build in `active`, whether or not this start loaded it. */
fun current(context: Context): Info? = read(File(root(context), "active"))
/** A build staged for the next start. */
fun pending(context: Context): Info? = read(File(root(context), "next"))
/** Kept builds, newest first. */
fun saved(context: Context): List<Saved> = File(root(context), "builds").listFiles().orEmpty()
.filter { File(it, "overlay.apk").isFile }
.sortedByDescending { it.name.toLongOrNull() ?: 0L }
.map { dir ->
val info = read(dir)
val reason = File(dir, "reason").takeIf { it.isFile }?.readText()?.trim()?.let { r -> Reason.entries.firstOrNull { it.name == r } }
Saved(dir, info, reason, info != null && madeFor(context, info))
}
/** Leaves the Lab build and restarts the app on the installed APK. */
fun turnOff(context: Context) {
val root = root(context)
archive(root, File(root, "active"), Reason.TURNED_OFF)
File(root, "next").deleteRecursively()
File(root, "starts").delete()
restart(context)
}
/** Stages [build] for the next start; the caller restarts the app. */
fun stage(context: Context, build: Saved) {
val root = root(context)
File(root, "next").deleteRecursively()
File(build.dir, "reason").delete()
if (!build.dir.renameTo(File(root, "next"))) error("could not stage ${build.dir.name}")
}
fun delete(build: Saved) {
build.dir.deleteRecursively()
}
/**
* Android's background optimization can give the APK an app image again, which keeps a Lab
* build from loading. Recompiling without one needs a shell, which the caller provides.
*/
fun needsRecompile(context: Context) = appImage(context)
fun recompileCommand(context: Context) = "cmd package compile -m verify -f ${context.packageName}"
fun restart(context: Context) {
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (launch != null) context.startActivity(launch)
Runtime.getRuntime().exit(0)
}
@VisibleForTesting
internal fun parse(json: String): Info? = runCatching {
val o = JSONObject(json)
Info(o.getString("label"), o.getString("baseCommit"), o.getString("baseApk"), o.optLong("built"))
}.getOrNull()
private fun read(dir: File): Info? = File(dir, "overlay.json").takeIf { it.isFile && File(dir, "overlay.apk").isFile }?.let { parse(it.readText()) }
/** Moves `next` into use and keeps the build it replaces. Returns whether there was one to move. */
@VisibleForTesting
internal fun promote(root: File): Boolean {
val next = File(root, "next")
if (!File(next, "overlay.apk").isFile || !File(next, "overlay.json").isFile) return false
val active = File(root, "active")
archive(root, active, Reason.REPLACED)
active.deleteRecursively()
if (!next.renameTo(active)) return false
File(root, "starts").delete()
return true
}
@VisibleForTesting
internal fun archive(root: File, dir: File, reason: Reason) {
if (!File(dir, "overlay.apk").isFile) return
val builds = File(root, "builds").apply { mkdirs() }
var stamp = System.currentTimeMillis()
while (File(builds, stamp.toString()).exists()) stamp++
val target = File(builds, stamp.toString())
if (!dir.renameTo(target)) return
File(target, "reason").writeText(reason.name)
builds.listFiles().orEmpty().sortedByDescending { it.name.toLongOrNull() ?: 0L }.drop(KEEP_BUILDS).forEach { it.deleteRecursively() }
}
@VisibleForTesting
internal fun failedStarts(root: File) = File(root, "starts").takeIf { it.isFile }?.readText()?.trim()?.toIntOrNull() ?: 0
private fun refuse(root: File, mainProcess: Boolean, reason: Reason) {
skipped = reason.name
Log.w(TAG, "Lab build turned off: $reason")
if (!mainProcess) return
archive(root, File(root, "active"), reason)
File(root, "starts").delete()
File(root, "last-off-reason").writeText(reason.name)
}
private fun skip(reason: String) {
skipped = reason
Log.w(TAG, "Lab build not loaded: $reason")
}
private fun hasAppImage(context: Context): Boolean {
val apk = File(context.applicationInfo.sourceDir)
val oat = File(apk.parentFile, "oat")
return oat.listFiles().orEmpty().any { File(it, "${apk.nameWithoutExtension}.art").isFile }
}
@RequiresApi(30)
private fun createLoader(apk: File): ResourcesLoader =
ParcelFileDescriptor.open(apk, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
ResourcesLoader().apply { addProvider(ResourcesProvider.loadFromApk(fd)) }
}
/** Gives [resources] the Lab build's resources; contexts made from them inherit it. */
fun addLoader(resources: Resources) {
if (Build.VERSION.SDK_INT < 30) return
val current = loader as? ResourcesLoader ?: return
resources.addLoaders(current)
}
private fun prependDex(classLoader: ClassLoader, apk: File) {
val pathList = field(Class.forName("dalvik.system.BaseDexClassLoader"), "pathList").get(classLoader)!!
val elementsField = field(pathList.javaClass, "dexElements")
@Suppress("UNCHECKED_CAST") val installed = elementsField.get(pathList) as Array<Any?>
val errors = ArrayList<java.io.IOException>()
val made = try {
pathList.javaClass.getDeclaredMethod("makeDexElements", List::class.java, File::class.java, List::class.java, ClassLoader::class.java)
.apply { isAccessible = true }.invoke(null, listOf(apk), null, errors, classLoader)
} catch (_: NoSuchMethodException) {
pathList.javaClass.getDeclaredMethod(
"makeDexElements", List::class.java, File::class.java, List::class.java, ClassLoader::class.java, Boolean::class.javaPrimitiveType,
).apply { isAccessible = true }.invoke(null, listOf(apk), null, errors, classLoader, false)
}
errors.firstOrNull()?.let { throw it }
@Suppress("UNCHECKED_CAST") val added = made as Array<Any?>
if (added.isEmpty()) error("no dex in ${apk.name}")
val merged = java.lang.reflect.Array.newInstance(installed.javaClass.componentType!!, added.size + installed.size)
System.arraycopy(added, 0, merged, 0, added.size)
System.arraycopy(installed, 0, merged, added.size, installed.size)
elementsField.set(pathList, merged)
}
private fun field(type: Class<*>, name: String) = type.getDeclaredField(name).apply { isAccessible = true }
private class Callbacks(private val root: File, private val mainProcess: Boolean) : Application.ActivityLifecycleCallbacks {
private var settled = false
override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) = addLoader(activity.resources)
override fun onActivityResumed(activity: Activity) {
if (settled || !mainProcess) return
settled = true
Handler(Looper.getMainLooper()).postDelayed({ File(root, "starts").delete() }, STABLE_AFTER_MS)
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
}
@@ -15,6 +15,7 @@ object DesktopCatalog {
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
const val PROTON_SEED_ID = "proton-arm64"
const val APPIMAGE_EXEC = "/usr/local/bin/droiddeck-appimage-exec"
class Entry(
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
@@ -216,7 +217,7 @@ object DesktopCatalog {
target.setExecutable(true, false)
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
"Exec=$APPIMAGE_EXEC /opt/appimages/${entry.id}.AppImage\n" +
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> {
@@ -21,9 +21,11 @@ object LegacyNames {
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
)
private const val MENU_DIR = "usr/local/share/applications"
private const val OLD_BIN = "/usr/local/bin/bannerlator-"
private const val NEW_BIN = "/usr/local/bin/droiddeck-"
private val MENU_REWRITES = listOf(
Triple("usr/local/share/applications", "/usr/local/bin/bannerlator-", "/usr/local/bin/droiddeck-"),
Triple("usr/share/applications", "Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/",
"Exec=${DesktopCatalog.APPIMAGE_EXEC} /opt/appimages/"),
)
@JvmStatic
@Synchronized
@@ -95,14 +97,16 @@ object LegacyNames {
}
private fun rewriteMenuEntries(root: File) {
val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return
for (entry in entries) {
val text = FileUtils.readString(entry) ?: continue
if (!text.contains(OLD_BIN)) continue
val staged = File(entry.parentFile, entry.name + ".staged")
if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) {
staged.delete()
Log.w(TAG, "could not rewrite $entry")
for ((dir, old, new) in MENU_REWRITES) {
val entries = File(root, dir).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: continue
for (entry in entries) {
val text = FileUtils.readString(entry) ?: continue
if (!text.contains(old)) continue
val staged = File(entry.parentFile, entry.name + ".staged")
if (!FileUtils.writeString(staged, text.replace(old, new)) || !staged.renameTo(entry)) {
staged.delete()
Log.w(TAG, "could not rewrite $entry")
}
}
}
}
@@ -82,8 +82,12 @@ public final class LinuxRuntimeInstaller {
private LinuxRuntimeInstaller() {}
public static String installedVersion(Context context) {
return LinuxRuntime.isInstalled(context) ? rootfsVersion(context) : null;
}
private static String rootfsVersion(Context context) {
File marker = new File(LinuxRuntime.rootDir(context), VERSION_FILE);
if (!marker.isFile() || !LinuxRuntime.isInstalled(context)) return null;
if (!marker.isFile()) return null;
String v = FileUtils.readString(marker);
return v == null ? null : v.trim();
}
@@ -144,6 +148,10 @@ public final class LinuxRuntimeInstaller {
}
};
boolean desktop = DesktopCatalog.INSTANCE.desktopInstalled(app);
if (packsOnly(app, release, progress)) {
job.ok = true;
return true;
}
job.ok = installOnce(app, release, progress);
if (job.ok && desktop) keepDesktop(app, progress);
return job.ok;
@@ -283,6 +291,31 @@ public final class LinuxRuntimeInstaller {
for (File file : files) file.delete();
}
private static kotlin.jvm.functions.Function1<AddonPack.State, kotlin.Unit> packProgress(ProgressListener listener, String stage) {
return state -> {
if (listener != null && state instanceof AddonPack.State.Downloading) {
listener.onProgress(Step.DOWNLOADING, stage, Math.round(((AddonPack.State.Downloading) state).getProgress() * 100f));
}
return kotlin.Unit.INSTANCE;
};
}
private static boolean packsOnly(Context context, Release release, ProgressListener listener) {
if (!LinuxRuntime.hasRootfs(context) || LinuxRuntime.isInstalled(context) || !release.version.equals(rootfsVersion(context))) return false;
File root = LinuxRuntime.rootDir(context);
try {
recoverInterruptedSwap(root, new File(root.getParentFile(), LinuxRuntime.DIR + ".new"), new File(root.getParentFile(), LinuxRuntime.DIR + ".old"));
} catch (IOException e) {
Log.w(TAG, "packs only: " + e);
return false;
}
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
return AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
&& AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))
&& LinuxRuntime.isInstalled(context);
}
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), archiveName(release));
boolean keepPartial = false;
@@ -294,10 +327,10 @@ public final class LinuxRuntimeInstaller {
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context)
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
&& !SessionPrefs.allowStockProot(context))
throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
if (!AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))) throw new IOException("Cannot download DirectAudio");
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
if (listener != null) {
listener.onProgress(Step.DOWNLOADING, downloading,
@@ -35,6 +35,7 @@ object UserApps {
private const val GITHUB_REPO = "github-repo"
private const val GITHUB_TAG = "github-tag"
private const val GITHUB_PUBLISHED = "github-published"
private const val GITHUB_ASSET = "github-asset"
enum class Kind { SCRIPT, APPIMAGE, FLATPAK }
@@ -56,7 +57,7 @@ object UserApps {
sealed class Source {
class Script(val file: File) : Source()
class AppImage(val file: File) : Source()
class GitHub(val repo: String) : Source()
class GitHub(val repo: String, val asset: String? = null) : Source()
class Flatpak(val id: String) : Source()
class FlatpakBundle(val file: File) : Source()
}
@@ -113,7 +114,7 @@ object UserApps {
when (val s = request.source) {
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
is Source.GitHub -> addFromGitHub(context, s.repo, s.asset, name, icon, onProgress)
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
}
@@ -305,14 +306,26 @@ object UserApps {
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
}
internal fun pickAsset(names: List<String>): String? {
fun githubAsset(input: String): String? =
Regex("github\\.com/[\\w.-]+/[\\w.-]+/releases/(?:download/[^/]+|latest/download)/([^/?#]+\\.appimage)(?:[?#].*)?$", RegexOption.IGNORE_CASE)
.find(input.trim())?.groupValues?.get(1)?.let { runCatching { java.net.URLDecoder.decode(it, "UTF-8") }.getOrNull() }
private fun assetVariant(name: String): Set<String> =
name.lowercase().replace(Regex("x86[_-]64|amd64"), "x64").replace(Regex("x86[_-]32|i[36]86"), "x32")
.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() && !Regex("[0-9a-f]{7,}|v?[0-9]+").matches(it) }.toSet()
internal fun pickAsset(names: List<String>, wanted: String? = null): String? {
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
if (wanted != null) {
images.firstOrNull { it == wanted }?.let { return it }
images.firstOrNull { assetVariant(it) == assetVariant(wanted) }?.let { return it }
}
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
}
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
private fun latestRelease(context: Context, repo: String, wanted: String? = null): Pair<Release?, String?> {
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
?: return null to context.getString(R.string.user_apps_github_unreachable, repo)
@@ -321,7 +334,7 @@ object UserApps {
if (r.optBoolean("draft")) return@mapNotNull null
val assets = r.optJSONArray("assets") ?: return@mapNotNull null
val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } }
pickAsset(byName.keys.toList())?.let { asset ->
pickAsset(byName.keys.toList(), wanted)?.let { asset ->
r.optBoolean("prerelease") to byName.getValue(asset).let { a ->
Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"),
Hashes.githubSha256(a.optString("digest")))
@@ -344,11 +357,11 @@ object UserApps {
}
private fun githubMeta(repo: String, release: Release) =
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published, GITHUB_ASSET to release.asset)
private fun addFromGitHub(context: Context, repo: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
private fun addFromGitHub(context: Context, repo: String, asset: String?, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
onProgress(context.getString(R.string.user_apps_github_checking, repo), -1)
val (release, problem) = latestRelease(context, repo)
val (release, problem) = latestRelease(context, repo, asset)
if (release == null) return problem
return withDownload(context, release, onProgress) { file ->
AppImageManager.import(context, file, name ?: repo.substringAfter('/'), icon, githubMeta(repo, release)) { onProgress(it, -1) }
@@ -381,9 +394,10 @@ object UserApps {
fun checkUpdate(context: Context, app: App): UpdateCheck {
val repo = app.repo ?: return UpdateCheck.Failed(context.getString(R.string.user_apps_update_failed))
val (release, problem) = latestRelease(context, repo)
val dir = AppImageManager.dir(context, app.key.substringAfter(':'))
val (release, problem) = latestRelease(context, repo, FileUtils.readString(File(dir, GITHUB_ASSET))?.trim()?.takeIf { it.isNotEmpty() })
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), GITHUB_PUBLISHED))?.trim().orEmpty()
val published = FileUtils.readString(File(dir, GITHUB_PUBLISHED))?.trim().orEmpty()
val newer = if (published.isNotEmpty() && release.published.isNotEmpty()) release.published > published else release.tag != app.version
return if (newer) UpdateCheck.Available(release) else UpdateCheck.Current(app.version ?: release.tag)
}
@@ -1,60 +0,0 @@
package com.droiddeck.launcher.session
import android.app.Notification
import android.content.Context
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.compose.runtime.mutableStateListOf
import androidx.core.app.NotificationManagerCompat
data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long)
object BottomNotifications {
val items = mutableStateListOf<BottomNotification>()
fun granted(context: Context): Boolean =
context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context)
internal fun keep(own: String, pkg: String, ongoing: Boolean, groupSummary: Boolean, title: String, text: String): Boolean =
pkg != own && !ongoing && !groupSummary && (title.isNotBlank() || text.isNotBlank())
internal fun add(list: MutableList<BottomNotification>, item: BottomNotification) {
list.removeAll { it.key == item.key }
list.add(0, item)
while (list.size > MAX_ITEMS) list.removeAt(list.lastIndex)
}
fun clear() = items.clear()
internal fun post(context: Context, sbn: StatusBarNotification) {
val n = sbn.notification ?: return
val extras = n.extras
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT) ?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
if (!keep(context.packageName, sbn.packageName, sbn.isOngoing, (n.flags and Notification.FLAG_GROUP_SUMMARY) != 0, title, text)) return
val app = runCatching {
val pm = context.packageManager
pm.getApplicationLabel(pm.getApplicationInfo(sbn.packageName, 0)).toString()
}.getOrDefault(sbn.packageName)
add(items, BottomNotification(sbn.key, app, title, text, sbn.postTime))
}
internal fun remove(key: String) {
items.removeAll { it.key == key }
}
private const val MAX_ITEMS = 30
}
class BottomNotificationListener : NotificationListenerService() {
override fun onListenerConnected() {
BottomNotifications.clear()
runCatching { activeNotifications }.getOrNull()?.sortedBy { it.postTime }?.forEach { BottomNotifications.post(this, it) }
}
override fun onNotificationPosted(sbn: StatusBarNotification) = BottomNotifications.post(this, sbn)
override fun onNotificationRemoved(sbn: StatusBarNotification) = BottomNotifications.remove(sbn.key)
override fun onListenerDisconnected() = BottomNotifications.clear()
}
@@ -68,8 +68,9 @@ object GameStorage {
externalStoragePath: Boolean = isExternalStoragePath(path),
): Boolean = when {
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY -> true
path.isDirectory -> true
externalStoragePath -> false
state == Environment.MEDIA_UNKNOWN || state == null -> path.isDirectory || path.parentFile?.isDirectory == true
state == Environment.MEDIA_UNKNOWN || state == null -> path.parentFile?.isDirectory == true
else -> false
}
@@ -69,8 +69,8 @@ object SessionDisplay {
const val UNFOLDED_MIN_DP = 600
fun unfoldedGameHeight(width: Int, height: Int): Int =
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
fun unfoldedGameHeight(width: Int, height: Int, tabRow: Int = 0): Int =
(minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) - tabRow.coerceAtLeast(0)).coerceAtLeast(0) and 1.inv()
fun unfoldedControlsWanted(
enabled: Boolean,
@@ -40,6 +40,7 @@ object SessionFiles {
WinComponents.publish(context)
val files = arrayOf(
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
"usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu",
"usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory",
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
@@ -112,6 +113,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
"usr/local/bin/droiddeck-flatpak-run" to "usr/local/bin/droiddeck-flatpak-run",
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
"usr/local/bin/droiddeck-appimage-exec" to "usr/local/bin/droiddeck-appimage-exec",
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
@@ -1555,10 +1555,6 @@ object SessionPrefs {
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
}
fun deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel =
com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null))
@@ -65,6 +65,8 @@ private fun releaseDateValue(raw: String): Long =
runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L)
} ?: 0L
internal fun entryTime(first: Long?, fallback: Long?): Long = first?.takeIf { it > 0 } ?: fallback?.takeIf { it > 0 } ?: 0L
fun <T> sortGames(
items: List<T>,
sort: String,
@@ -204,7 +204,8 @@ object BattleNetBackend : StoreBackend {
fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game ->
val art = BattleNet.art(context, game.title)
val cover = art.cover?.let { Uri.fromFile(it).toString() }
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", installedAt = game.folder.lastModified(),
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
}
private fun shelves(context: Context): StoreShelves {
@@ -64,6 +64,8 @@ object GogBackend : StoreBackend {
}, "gog-sync").start()
}
override fun search(context: Context, query: String): List<CatalogItem> = GogStoreCatalog.search(query)
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } }
@@ -58,16 +58,17 @@ object GogLibrary {
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
onStatus(app.getString(R.string.stores_sync_game_list))
val listing = ownedListing(token)
val account = ownedIds(token)
val listed: List<GogGame> = listing?.games
?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: account?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable))
val named = listing != null
StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids")
val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }
val cachedList = cached(app)
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val ownedSet = ownedSet(listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }, account, cachedIds)
StoresState.logLine(if (named) "GOG: listing ${listed.size} games, ${ownedSet.size} owned ids" else "GOG: listing unreadable, ${listed.size} owned ids")
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
val heavy = force || cachedList.isEmpty() || stale
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped }
@@ -133,6 +134,9 @@ object GogLibrary {
return all.ifEmpty { null }?.let { Listing(it, owned) }
}
fun ownedSet(listed: Set<String>, account: List<String>?, cached: Set<String> = emptySet()): Set<String> =
if (account == null) listed + cached else listed + account
private fun ownedIds(token: String): List<String>? {
val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null
val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null
@@ -151,7 +151,7 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (
?.let { UserApps.Request(UserApps.Source.Script(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
AddSource.APPIMAGE -> imagePath?.takeIf { it.endsWith(".appimage", ignoreCase = true) }?.let { File(it) }
?.let { UserApps.Request(UserApps.Source.AppImage(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), custom, iconPath) to (custom ?: it) }
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it, UserApps.githubAsset(repo)), custom, iconPath) to (custom ?: it) }
AddSource.FLATPAK -> bundlePath?.takeIf { it.endsWith(".flatpak", ignoreCase = true) }?.let { File(it) }
?.let { UserApps.Request(UserApps.Source.FlatpakBundle(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
?: flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) }
@@ -0,0 +1,99 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.animateFloat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.outlined.AutoAwesome
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentHost
private object BubblePlace {
var x by mutableStateOf(Float.NaN)
var y by mutableStateOf(Float.NaN)
var hidden by mutableStateOf(false)
}
/**
* The agent's bubble over a session: there once the agent has been used, pulsing while it works,
* marked when a reply or failure has not been seen. Drag it to either edge; a long press hides it
* until the next session.
*/
@Composable
internal fun AgentBubble(onOpen: () -> Unit) {
if (!AgentHost.installed || BubblePlace.hidden || AgentHost.entries.isEmpty()) return
val colors = MaterialTheme.colorScheme
val density = androidx.compose.ui.platform.LocalDensity.current
val size = 56.dp
val sizePx = with(density) { size.toPx() }
val margin = with(density) { 12.dp.toPx() }
val working = AgentHost.state == AgentHost.State.WORKING
val pulse = androidx.compose.animation.core.rememberInfiniteTransition(label = "agentPulse")
val ring by pulse.animateFloat(
0.35f, 1f,
androidx.compose.animation.core.infiniteRepeatable(androidx.compose.animation.core.tween(900), androidx.compose.animation.core.RepeatMode.Reverse),
label = "agentRing",
)
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
val width = constraints.maxWidth.toFloat()
val height = constraints.maxHeight.toFloat()
if (BubblePlace.x.isNaN()) {
BubblePlace.x = width - sizePx - margin
BubblePlace.y = height * 0.3f
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset { androidx.compose.ui.unit.IntOffset(BubblePlace.x.toInt(), BubblePlace.y.toInt()) }
.size(size)
.graphicsLayer { alpha = if (working || AgentHost.unseen) 1f else 0.7f }
.clip(androidx.compose.foundation.shape.CircleShape)
.background(colors.surface)
.border(
2.dp,
if (working) colors.primary.copy(alpha = ring) else if (AgentHost.failed && AgentHost.unseen) colors.error else colors.outlineVariant,
androidx.compose.foundation.shape.CircleShape,
)
.pointerInput(width, height) {
detectDragGestures(onDragEnd = {
BubblePlace.x = if (BubblePlace.x + sizePx / 2 < width / 2) margin else width - sizePx - margin
}) { change, drag ->
change.consume()
BubblePlace.x = (BubblePlace.x + drag.x).coerceIn(0f, width - sizePx)
BubblePlace.y = (BubblePlace.y + drag.y).coerceIn(0f, height - sizePx)
}
}
.pointerInput(Unit) {
detectTapGestures(onTap = { onOpen() }, onLongPress = { BubblePlace.hidden = true })
}
.testTag("agent-bubble"),
) {
Icon(androidx.compose.material.icons.Icons.Outlined.AutoAwesome, stringResource(R.string.rail_agent), tint = colors.primary)
if (AgentHost.unseen && !working) Box(
Modifier.align(Alignment.TopEnd).padding(6.dp).size(10.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(if (AgentHost.failed) colors.error else colors.primary),
)
}
}
}
@@ -0,0 +1,307 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Psychology
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentAttachments
import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentHost.Entry
@Composable
internal fun AgentEntry(entry: Entry, running: Boolean, last: Boolean = false) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
when (entry) {
is Entry.Asked -> Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (entry.files.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { entry.files.forEach { AttachmentChip(it, null) } }
if (entry.text.isNotEmpty()) SelectionContainer {
Text(entry.text, fontSize = 15.sp, color = pal.onPrimary,
modifier = Modifier.widthIn(max = 560.dp).clip(Shape16).background(pal.primary).padding(horizontal = 14.dp, vertical = 10.dp))
}
}
is Entry.Said -> SelectionContainer { AgentMarkdown(entry.text, Modifier.widthIn(max = 760.dp).padding(horizontal = 4.dp)) }
is Entry.Thought -> AgentThought(entry.text, live = false)
is Entry.Used -> if (entry.tool == "TodoWrite") AgentTodos(entry.input) else AgentToolCall(entry, running)
is Entry.Result -> Unit
is Entry.Finished -> if (last && AgentHost.answerless(AgentHost.entries)) AgentNoReply() else AgentTurnEnd(entry)
}
}
@Composable
private fun AgentTurnEnd(entry: Entry.Finished) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val text = when {
entry.stopped -> stringResource(R.string.agent_stopped)
entry.error != null -> stringResource(R.string.agent_failed, entry.error)
else -> entry.usage?.let { u ->
buildList {
if (u.durationMs > 0) add(clock(u.durationMs / 1000))
if (u.costUsd >= 0.005) add("$" + String.format(java.util.Locale.US, "%.2f", u.costUsd))
if (u.inputTokens > 0 || u.outputTokens > 0) add(stringResource(R.string.agent_tokens, tokens(u.inputTokens), tokens(u.outputTokens)))
}.joinToString(" · ")
}
} ?: return
if (text.isEmpty()) return
Text(text, fontSize = 12.sp, color = if (entry.error != null && !entry.stopped) colors.error else pal.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp))
}
@Composable
private fun AgentNoReply() {
val context = LocalContext.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(horizontal = 4.dp)) {
Text(stringResource(R.string.agent_no_reply), fontSize = 13.sp, color = AttentionAmber)
SecondaryButton(stringResource(R.string.agent_retry), compact = true) { AgentHost.retry(context) }
}
}
internal fun tokens(n: Long) = if (n >= 1000) "${n / 1000}k" else n.toString()
@Composable
internal fun AgentThought(text: String, live: Boolean) {
val pal = LocalPalette.current
var open by rememberSaveable(text.take(40)) { mutableStateOf(false) }
Column(Modifier.padding(horizontal = 4.dp).animateContentSize()) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clip(Shape12).clickable { open = !open }.padding(vertical = 4.dp)) {
Icon(Icons.Outlined.Psychology, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
Text(stringResource(if (live) R.string.agent_thinking else R.string.agent_thought), fontSize = 13.sp, color = pal.onSurfaceVariant,
modifier = Modifier.padding(start = 6.dp))
Icon(if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
if (open || live) Text(
if (open) text else text.takeLast(240), fontSize = 13.sp, fontStyle = FontStyle.Italic, color = pal.onSurfaceVariant, lineHeight = 19.sp,
maxLines = if (open) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 760.dp).padding(start = 22.dp),
)
}
}
@Composable
private fun AgentTodos(list: String) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
Column(
Modifier.widthIn(max = 560.dp).padding(horizontal = 4.dp).clip(Shape12).background(pal.surfaceVariant).padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (line in list.lines().filter { it.isNotBlank() }) {
val done = line.startsWith("[x]")
val doing = line.startsWith("[~]")
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(14.dp).clip(Shape12).background(when {
done -> pal.good
doing -> pal.primary
else -> pal.line
}))
Text(line.drop(4), fontSize = 13.sp, color = if (done) pal.onSurfaceVariant else colors.onSurface,
fontWeight = if (doing) FontWeight.SemiBold else null, modifier = Modifier.padding(start = 8.dp))
}
}
}
}
/** A tool call: one line with its status, opening to the full input and output. */
@Composable
private fun AgentToolCall(entry: Entry.Used, running: Boolean) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var open by rememberSaveable(entry.id.ifEmpty { entry.detail }) { mutableStateOf(false) }
Column(
Modifier.fillMaxWidth().padding(horizontal = 4.dp).clip(Shape12).background(pal.surfaceVariant).animateContentSize(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { open = !open }.testTag("agent-tool").padding(horizontal = 10.dp, vertical = 8.dp),
) {
Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) {
when {
running -> CircularProgressIndicator(Modifier.size(13.dp), strokeWidth = 2.dp, color = pal.primary)
entry.failed -> Icon(Icons.Outlined.ErrorOutline, null, tint = colors.error, modifier = Modifier.size(16.dp))
entry.output != null -> Icon(Icons.Outlined.CheckCircle, null, tint = pal.good, modifier = Modifier.size(16.dp))
else -> Icon(Icons.Outlined.Close, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
}
Text(entry.tool, fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold, color = pal.primary, modifier = Modifier.padding(start = 8.dp))
Text(entry.detail, fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(start = 8.dp))
Icon(if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
if (open) Column(Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (entry.input.isNotBlank()) CodeBox(stringResource(R.string.agent_tool_input), entry.input, colors.onSurface)
when {
entry.output == null && running -> Text(stringResource(R.string.agent_tool_running), fontSize = 12.sp, color = pal.onSurfaceVariant)
entry.output == null -> Text(stringResource(R.string.agent_tool_no_result), fontSize = 12.sp, color = pal.onSurfaceVariant)
entry.output.isBlank() -> Text(stringResource(R.string.agent_tool_empty), fontSize = 12.sp, color = pal.onSurfaceVariant)
else -> CodeBox(stringResource(R.string.agent_tool_output), entry.output, if (entry.failed) colors.error else colors.onSurface)
}
}
}
}
@Composable
private fun CodeBox(label: String, text: String, color: Color) {
val pal = LocalPalette.current
Column {
Text(label, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = pal.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
SelectionContainer {
Text(
text, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = color, lineHeight = 17.sp,
modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).clip(Shape12).background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()).padding(10.dp),
)
}
}
}
/** An attached file: a thumbnail for images, the name otherwise; [onRemove] adds a remove mark. */
@Composable
internal fun AttachmentChip(path: String, onRemove: (() -> Unit)?) {
val context = LocalContext.current
val pal = LocalPalette.current
Box {
if (AgentAttachments.isImage(path)) AsyncImage(
model = AgentAttachments.host(context, path), contentDescription = path.substringAfterLast('/'), contentScale = ContentScale.Crop,
modifier = Modifier.size(72.dp).clip(Shape12).background(pal.surfaceVariant),
) else Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = 40.dp).clip(Shape12).background(pal.surfaceVariant).padding(horizontal = 10.dp, vertical = 8.dp),
) {
Icon(Icons.Outlined.Description, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
Text(path.substringAfterLast('/'), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, maxLines = 1,
overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 200.dp).padding(start = 6.dp, end = if (onRemove != null) 18.dp else 0.dp))
}
if (onRemove != null) Box(
contentAlignment = Alignment.Center,
modifier = Modifier.align(Alignment.TopEnd).padding(3.dp).size(20.dp).clip(Shape12).background(MaterialTheme.colorScheme.surface).clickable(onClick = onRemove),
) {
Icon(Icons.Outlined.Close, stringResource(R.string.agent_remove_attachment), tint = pal.onSurfaceVariant, modifier = Modifier.size(14.dp))
}
}
}
/** The Markdown agents write: headings, lists, fenced code, `code` and **bold**. */
@Composable
internal fun AgentMarkdown(text: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
for (block in markdownBlocks(text)) when (block) {
is MdBlock.Code -> Text(block.text, fontSize = 13.sp, fontFamily = FontFamily.Monospace, color = colors.onSurface,
modifier = Modifier.fillMaxWidth().clip(Shape12).background(pal.surfaceVariant).padding(12.dp))
is MdBlock.Line -> Text(
inlineMarkdown(block.text, pal.primary2, pal.surfaceVariant),
fontSize = if (block.heading) 17.sp else 15.sp,
fontWeight = if (block.heading) FontWeight.Bold else null,
lineHeight = 22.sp, color = colors.onSurface,
modifier = if (block.bullet) Modifier.padding(start = 12.dp) else Modifier,
)
}
}
}
internal sealed interface MdBlock {
data class Line(val text: String, val heading: Boolean = false, val bullet: Boolean = false) : MdBlock
data class Code(val text: String) : MdBlock
}
private val HEADING = Regex("^#{1,6}\\s+")
private val BULLET = Regex("^\\s*[-*+]\\s+")
internal fun markdownBlocks(text: String): List<MdBlock> {
val out = mutableListOf<MdBlock>()
val code = StringBuilder()
var inCode = false
for (raw in text.lines()) {
if (raw.trimStart().startsWith("```")) {
if (inCode) {
out += MdBlock.Code(code.toString().trimEnd('\n'))
code.clear()
}
inCode = !inCode
continue
}
if (inCode) {
code.append(raw).append('\n')
continue
}
val line = raw.trimEnd()
if (line.isBlank()) continue
val heading = HEADING.find(line)
val bullet = BULLET.find(line)
out += when {
heading != null -> MdBlock.Line(line.substring(heading.range.last + 1), heading = true)
bullet != null -> MdBlock.Line("• " + line.substring(bullet.range.last + 1), bullet = true)
else -> MdBlock.Line(line)
}
}
if (inCode && code.isNotEmpty()) out += MdBlock.Code(code.toString().trimEnd('\n'))
return out
}
internal fun inlineMarkdown(text: String, codeColor: Color, codeBackground: Color): AnnotatedString = buildAnnotatedString {
var i = 0
while (i < text.length) {
val boldEnd = if (text.startsWith("**", i)) text.indexOf("**", i + 2) else -1
val tickEnd = if (text[i] == '`') text.indexOf('`', i + 1) else -1
when {
boldEnd > i + 2 -> {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text, i + 2, boldEnd) }
i = boldEnd + 2
}
tickEnd > i + 1 -> {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = codeColor, background = codeBackground)) { append(text, i + 1, tickEnd) }
i = tickEnd + 1
}
else -> append(text[i++])
}
}
}
@@ -0,0 +1,389 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Stop
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentAttachments
import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentHost.Entry
import com.droiddeck.launcher.agent.AgentSetup
internal enum class AgentPanel { NONE, MODEL, HISTORY, PROCESSES }
/** Slash commands the app handles itself; others go to the CLI, which has its own. */
private val LOCAL_COMMANDS = listOf("/new", "/model", "/plan", "/history", "/processes", "/stop")
private val CLI_COMMANDS = listOf("/compact", "/cost", "/review", "/init")
@Composable
internal fun AgentPage(modifier: Modifier, inputModifier: Modifier = Modifier, visible: Boolean = true) {
val context = LocalContext.current
remember { AgentHost.refresh(context) }
LaunchedEffect(AgentHost.unseen, visible) { if (visible) AgentHost.seen() }
remember { AgentSetup.check(context) }
var setup by rememberSaveable { mutableStateOf(false) }
if (setup || AgentHost.state == AgentHost.State.MISSING || !(AgentSetup.ready || AgentSetup.completed(context))) {
AgentSetupView(modifier, onDone = { setup = false })
return
}
var panel by rememberSaveable { mutableStateOf(AgentPanel.NONE) }
Column(modifier.fillMaxSize()) {
AgentHeader(panel, { panel = if (panel == it) AgentPanel.NONE else it }, onSetup = { setup = true })
when (panel) {
AgentPanel.HISTORY -> AgentHistory(Modifier.weight(1f)) { panel = AgentPanel.NONE }
AgentPanel.PROCESSES -> AgentProcessList(Modifier.weight(1f))
else -> {
if (panel == AgentPanel.MODEL) AgentModelPicker()
AgentConversation(Modifier.weight(1f))
AgentStatus()
AgentComposer(inputModifier) { panel = it }
}
}
}
}
@Composable
private fun AgentHeader(panel: AgentPanel, onPanel: (AgentPanel) -> Unit, onSetup: () -> Unit) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val working = AgentHost.state == AgentHost.State.WORKING
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.rail_agent), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
val model = AgentHost.models(context).firstOrNull { it.id == AgentHost.model }?.label ?: AgentHost.model ?: stringResource(R.string.agent_model_default)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clip(Shape12).clickable { onPanel(AgentPanel.MODEL) }.padding(vertical = 2.dp).testTag("agent-model"),
) {
val parts = listOfNotNull(AgentHost.harness.name, model, stringResource(R.string.agent_plan_short).takeIf { AgentHost.plan && AgentHost.harness.plans })
Text(parts.joinToString(" · "), fontSize = 13.sp, color = if (panel == AgentPanel.MODEL) pal.primary else pal.onSurfaceVariant)
Icon(Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
}
HeaderAction(Icons.Outlined.History, stringResource(R.string.agent_history), selected = panel == AgentPanel.HISTORY, tag = "agent-history") { onPanel(AgentPanel.HISTORY) }
HeaderAction(Icons.Outlined.Memory, stringResource(R.string.agent_processes), selected = panel == AgentPanel.PROCESSES, tag = "agent-processes") { onPanel(AgentPanel.PROCESSES) }
HeaderAction(Icons.Outlined.Add, stringResource(R.string.agent_new), enabled = AgentHost.entries.isNotEmpty(), tag = "agent-new") {
AgentHost.forget(context)
if (panel != AgentPanel.NONE) onPanel(panel)
}
HeaderAction(Icons.Outlined.Settings, stringResource(R.string.agent_setup_title), enabled = !working, tag = "agent-settings", onClick = onSetup)
}
}
@Composable
internal fun HeaderAction(icon: ImageVector, label: String, enabled: Boolean = true, selected: Boolean = false, tag: String? = null, onClick: () -> Unit) {
val pal = LocalPalette.current
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(start = 8.dp).size(44.dp).clip(Shape12)
.background(if (selected) pal.primary.copy(alpha = 0.2f) else pal.surfaceVariant)
.clickable(enabled = enabled, onClick = onClick)
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
) {
Icon(icon, label, tint = when {
selected -> pal.primary
enabled -> pal.onBackground
else -> pal.onSurfaceVariant.copy(alpha = 0.5f)
}, modifier = Modifier.size(22.dp))
}
}
@Composable
private fun AgentConversation(modifier: Modifier) {
val pal = LocalPalette.current
val list = rememberLazyListState()
val working = AgentHost.state == AgentHost.State.WORKING
val atBottom by remember { derivedStateOf { !list.canScrollForward } }
val items = AgentHost.entries.size + (if (working) 1 else 0)
LaunchedEffect(items) { if (items > 0) list.animateScrollToItem(items - 1) }
LaunchedEffect(AgentHost.live.length / 80, AgentHost.liveThinking.length / 80) {
if (atBottom && items > 0) list.scrollToItem(items - 1, Int.MAX_VALUE / 2)
}
if (AgentHost.entries.isEmpty() && !working) {
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(Icons.Outlined.AutoAwesome, null, tint = pal.primary, modifier = Modifier.size(40.dp))
Text(stringResource(R.string.agent_empty), fontSize = 15.sp, color = pal.onSurfaceVariant,
textAlign = TextAlign.Center, modifier = Modifier.widthIn(max = 460.dp).padding(top = 12.dp))
}
return
}
val start = AgentHost.entries.indexOfLast { it is Entry.Asked }
LazyColumn(modifier.fillMaxWidth(), state = list, verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(AgentHost.entries.size) { i ->
val entry = AgentHost.entries[i]
AgentEntry(entry, running = working && i > start && entry is Entry.Used && entry.output == null, last = !working && i == AgentHost.entries.lastIndex)
}
if (working) item { AgentLive() }
}
}
/** The reply being written, and what the agent is doing right now. */
@Composable
private fun AgentLive() {
val pal = LocalPalette.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (AgentHost.liveThinking.isNotBlank()) AgentThought(AgentHost.liveThinking, live = true)
if (AgentHost.live.isNotBlank()) AgentMarkdown(AgentHost.live, Modifier.widthIn(max = 760.dp).padding(horizontal = 4.dp))
val running = AgentHost.running()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(4.dp)) {
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = pal.primary)
val elapsed = clock((AgentHost.now - AgentHost.turnStarted) / 1000)
Text(
if (running != null) stringResource(R.string.agent_running, running.tool, elapsed) else stringResource(R.string.agent_thinking_for, elapsed),
fontSize = 13.sp, color = pal.onSurfaceVariant,
)
}
}
}
internal fun clock(seconds: Long): String {
val s = seconds.coerceAtLeast(0)
return when {
s < 60 -> "${s}s"
s < 3600 -> "${s / 60}m ${s % 60}s"
else -> "${s / 3600}h ${s % 3600 / 60}m"
}
}
/** Above the composer: a Lab build's progress, the todo item in progress, and queued messages. */
@Composable
private fun AgentStatus() {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val lab = AgentHost.lab
if (lab != null) {
var armed by remember(lab.pid) { mutableStateOf(false) }
Column(
Modifier.fillMaxWidth().padding(top = 10.dp).clip(Shape14).background(colors.surface).border(1.dp, if (lab.failed) AttentionAmber else pal.line, Shape14)
.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-lab"),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
stringResource(if (lab.prepare) R.string.agent_lab_preparing else R.string.agent_lab_building) + " · " + labStage(lab),
fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (lab.failed) AttentionAmber else colors.onBackground,
)
val facts = buildList {
if (lab.running && lab.total > 0) add("${lab.done}/${lab.total}")
add(clock(lab.elapsedSeconds.toLong()))
if (lab.running && lab.etaSeconds >= 0) add(stringResource(R.string.agent_lab_left, clock(lab.etaSeconds.toLong())))
}
Text(facts.joinToString(" · "), fontSize = 12.5.sp, color = pal.onSurfaceVariant)
}
if (lab.running) SecondaryButton(stringResource(if (armed) R.string.agent_lab_cancel_confirm else R.string.agent_lab_cancel), compact = true) {
if (armed) AgentHost.cancelLabBuild(context) else armed = true
}
}
if (lab.running) {
LinearProgressIndicator(
progress = { lab.permille / 1000f }, color = pal.primary, trackColor = pal.surfaceVariant,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
)
if (lab.task.isNotEmpty()) Text(lab.task, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
}
}
val todos = AgentHost.todos()?.lines()?.filter { it.isNotBlank() }
if (AgentHost.state == AgentHost.State.WORKING && !todos.isNullOrEmpty()) {
val doing = todos.firstOrNull { it.startsWith("[~]") } ?: todos.firstOrNull { it.startsWith("[ ]") }
val done = todos.count { it.startsWith("[x]") }
if (doing != null) Text(
"${doing.drop(4)} · $done/${todos.size}", fontSize = 13.sp, color = pal.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
)
}
for (queued in AgentHost.queue) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp).fillMaxWidth().clip(Shape12).background(pal.surfaceVariant).padding(start = 12.dp),
) {
Text(stringResource(R.string.agent_queued, queued.text), fontSize = 13.sp, color = pal.onSurfaceVariant, maxLines = 1,
overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clickable { AgentHost.unqueue(queued) }) {
Icon(Icons.Outlined.Close, stringResource(R.string.agent_unqueue), tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
}
}
}
@Composable
private fun labStage(lab: com.droiddeck.launcher.agent.LabProgress) = when (lab.stage) {
"building" -> stringResource(R.string.agent_lab_stage_building, lab.permille / 10)
"packaging" -> stringResource(R.string.agent_lab_stage_packaging)
"restarting" -> stringResource(R.string.agent_lab_stage_restarting)
"ready", "prepared" -> stringResource(R.string.agent_lab_stage_ready)
"failed" -> stringResource(R.string.agent_lab_stage_failed, lab.task)
else -> stringResource(R.string.agent_lab_stage_starting)
}
@Composable
private fun AgentComposer(inputModifier: Modifier, onPanel: (AgentPanel) -> Unit) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var draft by rememberSaveable { mutableStateOf("") }
val working = AgentHost.state == AgentHost.State.WORKING
val send = send@{
val text = draft.trim()
when (text.substringBefore(' ')) {
"/new" -> AgentHost.forget(context)
"/model" -> onPanel(AgentPanel.MODEL)
"/plan" -> AgentHost.choosePlan(context, !AgentHost.plan)
"/history" -> onPanel(AgentPanel.HISTORY)
"/processes" -> onPanel(AgentPanel.PROCESSES)
"/stop" -> AgentHost.stop(context)
else -> {
if (text.isEmpty() && AgentAttachments.pending.isEmpty()) return@send
AgentHost.send(context, text, AgentAttachments.pending.toList())
AgentAttachments.pending.clear()
}
}
draft = ""
}
if (draft.startsWith("/") && !draft.contains(' ')) {
val matches = (LOCAL_COMMANDS + CLI_COMMANDS).filter { it.startsWith(draft) && it != draft }
if (matches.isNotEmpty()) Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 10.dp).fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
for (command in matches) Text(
command, fontSize = 13.sp, fontFamily = FontFamily.Monospace, color = pal.primary,
modifier = Modifier.clip(Shape12).background(pal.surfaceVariant).clickable { draft = "$command " }.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
if (AgentAttachments.pending.isNotEmpty()) Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 10.dp).fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
for (path in AgentAttachments.pending.toList()) AttachmentChip(path) { AgentAttachments.pending.remove(path) }
}
var focused by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 16.dp).clip(Shape16).background(colors.surface)
.border(1.dp, if (focused) pal.primary else pal.line, Shape16).padding(start = 6.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
) {
if (LocalActivityResultRegistryOwner.current != null) AttachButton()
Box(Modifier.weight(1f).padding(start = 8.dp, top = 11.dp, bottom = 11.dp)) {
if (draft.isEmpty()) Text(
stringResource(if (working) R.string.agent_hint_working else R.string.agent_hint), fontSize = 16.sp,
color = pal.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
BasicTextField(
draft, { draft = it },
Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.testTag("agent-input").then(inputModifier),
textStyle = TextStyle(fontSize = 16.sp, color = colors.onSurface),
cursorBrush = SolidColor(pal.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { send() }),
maxLines = 6,
)
}
if (working) RoundButton(Icons.Outlined.Stop, stringResource(R.string.agent_stop), pal.error.copy(alpha = 0.22f), colors.error, "agent-stop") {
AgentHost.stop(context)
}
val canSend = draft.isNotBlank() || AgentAttachments.pending.isNotEmpty()
RoundButton(
Icons.AutoMirrored.Outlined.Send, stringResource(if (working) R.string.agent_queue else R.string.agent_send),
if (canSend) pal.primary else pal.surfaceVariant, if (canSend) pal.onPrimary else pal.onSurfaceVariant, "agent-send", enabled = canSend,
) { send() }
}
}
@Composable
private fun AttachButton() {
val context = LocalContext.current
val pal = LocalPalette.current
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris.isEmpty()) return@rememberLauncherForActivityResult
val chat = AgentHost.chat
Thread({
val paths = AgentAttachments.copy(context, chat, uris)
android.os.Handler(android.os.Looper.getMainLooper()).post { AgentAttachments.pending.addAll(paths) }
}, "agent-attach").start()
}
RoundButton(Icons.Outlined.AttachFile, stringResource(R.string.agent_attach), pal.surfaceVariant, pal.onBackground, "agent-attach") {
runCatching { picker.launch(arrayOf("*/*")) }
}
}
@Composable
private fun RoundButton(
icon: ImageVector, label: String, background: androidx.compose.ui.graphics.Color, tint: androidx.compose.ui.graphics.Color,
tag: String, enabled: Boolean = true, onClick: () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(start = 6.dp).size(46.dp).clip(CircleShape).background(background)
.clickable(enabled = enabled, onClick = onClick).testTag(tag),
) {
Icon(icon, label, tint = tint, modifier = Modifier.size(22.dp))
}
}
@@ -0,0 +1,182 @@
package com.droiddeck.launcher.ui
import android.text.format.DateUtils
import android.text.format.Formatter
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentChats
import com.droiddeck.launcher.agent.AgentHarness
import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentProcesses
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/** The model and plan mode for the next turns; a conversation keeps going with whatever is chosen. */
@Composable
internal fun AgentModelPicker() {
val context = LocalContext.current
val pal = LocalPalette.current
val models = remember(AgentHost.harness) { AgentHost.models(context) }
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
val choices = listOf(AgentHarness.Model("", stringResource(R.string.agent_model_default))) + models
for (choice in choices) {
val id = choice.id.ifEmpty { null }
if (id == AgentHost.model) PrimaryButton(choice.label, compact = true) {}
else SecondaryButton(choice.label, compact = true) { AgentHost.chooseModel(context, id) }
}
}
if (AgentHost.harness.plans) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp)) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.agent_plan), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
Text(stringResource(R.string.agent_plan_hint), fontSize = 12.5.sp, color = pal.onSurfaceVariant)
}
ToggleSwitch(AgentHost.plan, label = stringResource(R.string.agent_plan)) { AgentHost.choosePlan(context, it) }
}
}
}
@Composable
internal fun AgentHistory(modifier: Modifier, onOpened: () -> Unit) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var version by remember { mutableIntStateOf(0) }
val chats by produceState(emptyList<AgentChats.Chat>(), version, AgentHost.entries.size) {
value = withContext(Dispatchers.IO) { AgentChats.list(context) }
}
if (chats.isEmpty()) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.agent_history_empty), fontSize = 15.sp, color = pal.onSurfaceVariant)
}
return
}
val working = AgentHost.state == AgentHost.State.WORKING
LazyColumn(modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
items(chats, key = { it.id }) { chat ->
var armed by remember(chat.id) { mutableStateOf(false) }
val current = chat.id == AgentHost.chat
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 62.dp)
.clickable(enabled = !working || current) {
AgentHost.open(context, chat.id)
onOpened()
}
.background(if (current) pal.primary.copy(alpha = 0.12f) else colors.surface)
.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-chat"),
) {
Column(Modifier.weight(1f)) {
Text(chat.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
if (armed) stringResource(R.string.agent_history_delete_warning)
else AgentHarness.byId(chat.harness).name + " · " + when {
System.currentTimeMillis() - chat.updated < 60_000 -> stringResource(R.string.agent_just_now)
else -> DateUtils.getRelativeTimeSpanString(chat.updated, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
},
fontSize = 12.5.sp, color = if (armed) AttentionAmber else pal.onSurfaceVariant,
)
}
SecondaryButton(
stringResource(if (armed) R.string.agent_storage_confirm else R.string.agent_history_delete), compact = true,
enabled = !(current && working),
) {
if (armed) {
AgentHost.delete(context, chat.id)
version++
} else armed = true
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
}
/** Processes in the Linux userland, the agent's own first, each with a way to end it. */
@Composable
internal fun AgentProcessList(modifier: Modifier) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var refresh by remember { mutableIntStateOf(0) }
var procs by remember { mutableStateOf<List<AgentProcesses.Proc>?>(null) }
LaunchedEffect(refresh) {
while (true) {
procs = withContext(Dispatchers.IO) { AgentProcesses.snapshot(AgentHost.turnPid) }
delay(2_000)
}
}
val list = procs ?: return
if (list.isEmpty()) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.agent_processes_empty), fontSize = 15.sp, color = pal.onSurfaceVariant)
}
return
}
LazyColumn(modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
items(list, key = { it.pid }) { proc ->
var open by remember(proc.pid) { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-process")) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(proc.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
if (proc.agent) Text(stringResource(R.string.agent_process_agent), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = pal.primary,
modifier = Modifier.clip(Shape12).background(pal.primary.copy(alpha = 0.15f)).padding(horizontal = 6.dp, vertical = 1.dp))
}
Text(
listOf("pid ${proc.pid}", clock(proc.seconds), Formatter.formatShortFileSize(context, proc.rssKb * 1024), "${proc.cpuPercent}% CPU").joinToString(" · "),
fontSize = 12.5.sp, color = pal.onSurfaceVariant,
)
}
SecondaryButton(stringResource(R.string.agent_process_stop), compact = true) {
AgentProcesses.killTree(proc.pid)
refresh++
}
}
SelectionContainer {
Text(proc.args, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
maxLines = if (open) 40 else 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
}
@@ -0,0 +1,198 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.PairingGuideActivity
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentHarness
import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentSetup
import com.droiddeck.launcher.agent.AgentStorage
import android.text.format.Formatter
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import com.droiddeck.launcher.agent.AgentSetup.Status
import com.droiddeck.launcher.agent.AgentSetup.Step
import com.droiddeck.launcher.core.WirelessAdbPairingService
@Composable
internal fun AgentSetupView(modifier: Modifier, onDone: () -> Unit = {}) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
LaunchedEffect(AgentHost.harness) {
AgentSetup.check(context)
AgentStorage.measure(context)
}
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(stringResource(R.string.agent_setup_title), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
if (!AgentSetup.runtimeInstalled) {
Note(stringResource(R.string.agent_setup_runtime))
return@Column
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
for (harness in AgentHarness.all) {
val chosen = harness == AgentHost.harness
if (chosen) PrimaryButton(harness.name, compact = true) {} else SecondaryButton(harness.name, compact = true) { AgentHost.choose(context, harness) }
}
}
val steps = Step.entries
val done = steps.count { AgentSetup.status[it] == Status.DONE }
val busy = steps.any { AgentSetup.status[it] == Status.WORKING }
var details by rememberSaveable { mutableStateOf(false) }
val summary = if (AgentSetup.ready) buildList {
add(stringResource(R.string.agent_summary_signed_in, AgentHost.harness.name))
if (AgentSetup.status[Step.GITHUB] == Status.DONE) add(stringResource(R.string.agent_step_github))
if (AgentSetup.status[Step.BUILD_TOOLS] == Status.DONE) add(stringResource(R.string.agent_summary_lab))
if (AgentSetup.status[Step.PREPARE] == Status.DONE) add(stringResource(R.string.agent_summary_fast))
}.joinToString(" · ") else steps.firstOrNull { it.required && AgentSetup.status[it] != Status.DONE }?.let { labels(it, AgentHost.harness.name).first }.orEmpty()
ReadinessCard(
done, steps.size, summary, details || !AgentSetup.ready || busy, { details = !details },
title = stringResource(if (AgentSetup.ready) R.string.agent_summary_ready else R.string.agent_summary_not_ready),
ready = AgentSetup.ready,
) {
steps.forEachIndexed { index, step ->
val status = AgentSetup.status[step] ?: Status.UNKNOWN
val (title, hint) = labels(step, AgentHost.harness.name)
val lab = AgentHost.lab?.takeIf { it.prepare && it.running }
val detail = if (step == Step.PREPARE && lab != null)
stringResource(R.string.agent_prepare_progress, lab.permille / 10, clock(lab.etaSeconds.toLong().coerceAtLeast(0)))
else AgentSetup.detail[step] ?: hint
CheckRow(
when (status) {
Status.DONE -> CheckState.OK
Status.WORKING, Status.UNKNOWN -> CheckState.BUSY
Status.TODO, Status.FAILED -> CheckState.WARN
},
title, detail, divider = index < steps.lastIndex,
) {
val blocked = (step == Step.SIGN_IN && AgentSetup.status[Step.INSTALL] != Status.DONE) ||
(step == Step.PREPARE && AgentSetup.status[Step.BUILD_TOOLS] != Status.DONE)
if ((status == Status.TODO || status == Status.FAILED) && !blocked) {
val label = stringResource(if (status == Status.FAILED) R.string.agent_setup_retry else action(step))
val run = { if (step == Step.CONNECTION) pair(context) else AgentSetup.run(context, step) }
if (step.required) PrimaryButton(label, compact = true, onClick = run) else SecondaryButton(label, compact = true, onClick = run)
}
}
}
}
if (AgentSetup.ready) PrimaryButton(stringResource(R.string.agent_setup_done), main = true, onClick = onDone)
StorageCard()
}
}
@Composable
private fun StorageCard() {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val items = AgentStorage.Item.entries.filter { (AgentStorage.sizes[it] ?: 0L) > 0L }
if (items.isEmpty()) return
val total = items.sumOf { AgentStorage.sizes[it] ?: 0L }
var open by rememberSaveable { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp)) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.agent_storage_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(Formatter.formatShortFileSize(context, total), fontSize = 13.sp, color = pal.onSurfaceVariant)
}
SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true) { open = !open }
}
if (open) items.forEachIndexed { index, item ->
if (index == 0) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
var armed by remember(item) { mutableStateOf(false) }
val busy = AgentStorage.removing[item] == true
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp),
) {
Column(Modifier.weight(1f)) {
Text(stringResource(storageTitle(item)), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(
if (armed) stringResource(storageWarning(item)) else Formatter.formatShortFileSize(context, AgentStorage.sizes[item] ?: 0L),
fontSize = 13.sp, color = if (armed) AttentionAmber else pal.onSurfaceVariant,
)
}
SecondaryButton(
stringResource(if (armed) R.string.agent_storage_confirm else R.string.agent_storage_remove),
enabled = !busy && AgentHost.state != AgentHost.State.WORKING, compact = true,
) {
if (armed) {
armed = false
AgentStorage.remove(context, item)
} else armed = true
}
}
if (index < items.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
}
private fun storageTitle(item: AgentStorage.Item) = when (item) {
AgentStorage.Item.CLAUDE_CODE -> R.string.agent_storage_claude
AgentStorage.Item.CODEX -> R.string.agent_storage_codex
AgentStorage.Item.BUILD_TOOLS -> R.string.agent_step_build
AgentStorage.Item.SOURCE -> R.string.agent_storage_source
AgentStorage.Item.CACHES -> R.string.agent_storage_caches
}
private fun storageWarning(item: AgentStorage.Item) = when (item) {
AgentStorage.Item.SOURCE -> R.string.agent_storage_source_warning
AgentStorage.Item.CACHES -> R.string.agent_storage_caches_warning
else -> R.string.agent_storage_reinstall_warning
}
@Composable
private fun labels(step: Step, harness: String): Pair<String, String> = when (step) {
Step.CONNECTION -> stringResource(R.string.agent_step_connection) to stringResource(R.string.agent_step_connection_hint)
Step.INSTALL -> stringResource(R.string.agent_step_install, harness) to stringResource(R.string.agent_step_install_hint)
Step.SIGN_IN -> stringResource(R.string.agent_step_sign_in, harness) to stringResource(R.string.agent_step_sign_in_hint)
Step.GITHUB -> stringResource(R.string.agent_step_github) to stringResource(R.string.agent_step_github_hint)
Step.BUILD_TOOLS -> stringResource(R.string.agent_step_build) to stringResource(R.string.agent_step_build_hint)
Step.PREPARE -> stringResource(R.string.agent_step_prepare) to stringResource(R.string.agent_step_prepare_hint)
}
private fun action(step: Step): Int = when (step) {
Step.CONNECTION -> R.string.agent_action_pair
Step.INSTALL -> R.string.agent_action_install
Step.SIGN_IN, Step.GITHUB -> R.string.agent_action_sign_in
Step.BUILD_TOOLS -> R.string.agent_action_install
Step.PREPARE -> R.string.agent_action_prepare
}
/** The pairing DroidDeck already uses for the phantom process fix; the agent reuses its key. */
private fun pair(context: android.content.Context) {
if (!WirelessAdbPairingService.notificationsEnabled(context)) {
context.startActivity(android.content.Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK))
return
}
WirelessAdbPairingService.start(context)
PairingGuideActivity.start(context, null, false)
}
@@ -1,116 +0,0 @@
package com.droiddeck.launcher.ui
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.BottomNotifications
import kotlinx.coroutines.delay
internal fun notificationAccessIntent(context: android.content.Context): Intent =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra(
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
android.content.ComponentName(context, com.droiddeck.launcher.session.BottomNotificationListener::class.java).flattenToString(),
)
} else Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
@Composable
fun BottomNotificationsPage(openSettings: ((Intent, () -> Boolean) -> Unit)?) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var granted by remember { mutableStateOf(BottomNotifications.granted(context)) }
LaunchedEffect(Unit) {
while (true) {
granted = BottomNotifications.granted(context)
delay(2_000L)
}
}
if (!granted) {
Column(Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)) {
Text(stringResource(R.string.bottom_notifications_why), fontSize = 15.sp, color = colors.onBackground, textAlign = TextAlign.Center)
BottomPillButton(stringResource(R.string.bottom_notifications_allow), "bottom-notifications-allow") {
val intent = notificationAccessIntent(context)
if (openSettings != null) openSettings(intent) { BottomNotifications.granted(context) }
else runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
}
return
}
val shown = BottomNotifications.items
if (shown.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.bottom_notifications_none), fontSize = 15.sp, color = colors.onSurfaceVariant)
}
return
}
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.bottom_notifications_count, shown.size), fontSize = 13.sp, color = colors.onSurfaceVariant,
modifier = Modifier.weight(1f))
BottomPillButton(stringResource(R.string.bottom_notifications_clear), "bottom-notifications-clear") { BottomNotifications.clear() }
}
LazyColumn(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(shown.toList(), key = { it.key }) { n ->
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(pal.surface).padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(n.app, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1,
overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Text(timeFormat.format(java.util.Date(n.postedAt)), fontSize = 12.sp, color = colors.onSurfaceVariant)
}
if (n.title.isNotBlank()) Text(n.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (n.text.isNotBlank()) Text(n.text, fontSize = 14.sp, color = colors.onBackground, maxLines = 4, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@Composable
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
val pal = LocalPalette.current
Box(
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -1,17 +1,31 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
class BottomPageActions(
val takeScreenshot: ((onDone: (Boolean) -> Unit) -> Unit)? = null,
val fps: () -> Float? = { null },
val openSettings: ((intent: android.content.Intent, done: () -> Boolean) -> Unit)? = null,
val runningGame: () -> String? = { null },
)
@@ -20,7 +34,18 @@ internal const val BOTTOM_PAGE_TELEMETRY = "telemetry"
fun sessionBottomPages(actions: BottomPageActions): List<BottomPage> = listOf(
BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) },
BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() },
BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) },
BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) },
BottomPage("chat", R.string.bottom_page_chat, Icons.Outlined.Forum) { BottomChatPage() },
)
@Composable
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
val pal = LocalPalette.current
Box(
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -304,7 +304,8 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
val now = System.nanoTime()
val live = frames.live
val stats = frames.stats
val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) }
val graph = FloatArray(FrameStats.GRAPH_FRAMES)
val graphEnd = if (live) stats.graph(graph) else 0L
val hz = (frames.refreshMilliHz + 499) / 1000
val data = HudData(
fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY,
@@ -317,6 +318,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock,
fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(),
historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()),
graphEnd = graphEnd,
coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit,
tempColor = { sensor, c ->
thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN)
@@ -38,7 +38,8 @@ object BuildWatermark {
if (text.length <= TITLE_LIMIT) text else text.take(TITLE_LIMIT - 1).trimEnd() + "…"
fun install(app: Application) {
val lines = lines() ?: return
val lab = com.droiddeck.launcher.lab.LabOverlay.active?.let { "LAB | ${truncate(it.label)}" }
val lines = (listOfNotNull(lab) + lines().orEmpty()).ifEmpty { return }
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity is QuestXrActivity) return
@@ -270,6 +270,10 @@ private fun Content(
DesktopPage(s, a, modifier, onSelect)
return
}
if (selected == "agent") {
AgentPage(modifier.padding(horizontal = padH).padding(top = padV))
return
}
if (selected == "android-apps" && s.isHomeApp) {
AndroidAppsPage(s, modifier, onAndroidAppClick)
return
@@ -47,6 +47,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LocalMall
@@ -123,7 +124,9 @@ internal fun BottomBar(
internal fun navigationKeys(s: FrontEndState) = buildList {
add("steam"); add("games")
if (s.gameStoresEnabled) add("stores")
add("desktop"); add("components")
add("desktop")
if (com.droiddeck.launcher.agent.AgentHost.available) add("agent")
add("components")
if (s.isHomeApp) add("android-apps")
add("setup"); add("updates")
}
@@ -156,7 +159,7 @@ internal fun SideRail(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
val count = navigationKeys(s).size
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
val shown = Hops.railPreview ?: selected
@@ -197,6 +200,7 @@ internal fun railLabelIcon(key: String): Pair<Int, ImageVector> = when (key) {
"stores" -> R.string.rail_stores to Icons.Outlined.LocalMall
"desktop" -> R.string.rail_desktop to Icons.Outlined.DesktopWindows
"components" -> R.string.rail_components to Icons.Outlined.Layers
"agent" -> R.string.rail_agent to Icons.Outlined.AutoAwesome
"android-apps" -> R.string.rail_apps to Icons.Outlined.Apps
"setup" -> R.string.rail_setup to Icons.Outlined.Tune
else -> R.string.rail_updates to Icons.Outlined.SystemUpdate
@@ -84,7 +84,7 @@ import com.droiddeck.launcher.stores.download.StoreDownloadTier
import com.droiddeck.launcher.R
private enum class CheckState { OK, WARN, BUSY }
internal enum class CheckState { OK, WARN, BUSY }
private const val STORAGE_TAB = 3
private const val LAUNCHER_TAB = 2
@@ -97,7 +97,7 @@ private const val GLASSES_TAB = 6
private const val SYNC_TAB = 4
@Composable
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
internal fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = when (state) {
@@ -690,10 +690,12 @@ internal fun SetupPanel(
}
@Composable
private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
internal fun ReadinessCard(
passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, title: String? = null, ready: Boolean = passing == total,
checks: @Composable () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val ready = passing == total
Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -709,7 +711,7 @@ private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean,
}
Column(Modifier.weight(1f)) {
Text(
stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
title ?: stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
)
if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
@@ -16,7 +16,7 @@ import com.droiddeck.launcher.input.*
internal fun InputActionEditor(
control: InputControl, current: InputAction, title: String, onBack: () -> Unit, onSave: (InputAction) -> Unit,
element: InputSurfaceElement? = null, nativeTargets: Boolean = true, detail: String? = null,
properties: (@Composable ColumnScope.() -> Unit)? = null,
properties: (@Composable ColumnScope.(InputAction) -> Unit)? = null,
click: Boolean = false,
) {
val context = LocalContext.current
@@ -46,7 +46,7 @@ internal fun InputActionEditor(
SettingsPage(host, title, onBack, eyebrow = stringResource(R.string.input_properties), lede = detail,
action = { PrimaryButton(stringResource(R.string.gesture_save_changes), enabled = valid(draft),
modifier = Modifier.testTag("input-action-save")) { onSave(draft) } }) {
properties?.invoke(this)
properties?.invoke(this, draft)
Spacer(Modifier.height(12.dp))
SheetChips(listOf(stringResource(R.string.input_route_controller), stringResource(R.string.input_route_local), stringResource(R.string.input_disabled)), route) {
when (it) {
@@ -58,12 +58,17 @@ internal fun InputDisplayContent(host: ComponentActivity, display: Display, onDi
dismiss()
}
}
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowShown(display.displayId)
showPresentation()
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
if (event == Lifecycle.Event.ON_START) showPresentation()
}
host.lifecycle.addObserver(observer)
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
onDispose {
host.lifecycle.removeObserver(observer)
presentation.dismiss()
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowClosed(display.displayId)
}
}
}
@@ -24,7 +24,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
internal fun InputElementProperties(element: InputSurfaceElement, onChange: (InputSurfaceElement) -> Unit) {
internal fun InputElementProperties(element: InputSurfaceElement, action: InputAction? = null, onChange: (InputSurfaceElement) -> Unit) {
val current by rememberUpdatedState(element)
val change by rememberUpdatedState(onChange)
OutlinedTextField(element.name, { onChange(element.copy(name = it.take(80))) }, label = { Text(stringResource(R.string.input_name)) },
@@ -72,7 +72,9 @@ internal fun InputElementProperties(element: InputSurfaceElement, onChange: (Inp
}
val host = rememberMenuHost()
if ((element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) && element.icon == 0 && element.image.isEmpty() ||
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:"))
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:") ||
(element.kind == InputElementKind.STICK || element.kind == InputElementKind.DPAD) && (action as? InputAction.Directions)
?.let { listOf(it.up, it.right, it.down, it.left).plus(if (element.kind == InputElementKind.STICK) it.diagonals.orEmpty() else emptyList()).any { code -> code != 0 } } == true)
ToggleRow(host, "element-label", stringResource(R.string.input_show_label), null, element.showLabel) { onChange(element.copy(showLabel = it)) }
SliderRow(stringResource(R.string.ctrl_opacity), null, (element.opacity * 100).roundToInt().coerceIn(20, 100), 20..100, step = 5,
format = { "$it%" }, modifier = Modifier.testTag("setting-element-opacity")) { onChange(element.copy(opacity = it / 100f)) }
@@ -169,7 +169,7 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe
?: b.controls[editing.control] ?: InputAction.Native()
InputActionEditor(editing.control, current, if (editing.click) stringResource(R.string.input_click_name, editing.element!!.name)
else editing.element?.name ?: editing.key?.label ?: editing.control.label,
properties = { if (!editing.click) elementProperties?.let { e -> InputElementProperties(e) { elementProperties = it } } },
properties = { action -> if (!editing.click) elementProperties?.let { e -> InputElementProperties(e, action) { elementProperties = it } } },
onBack = { edit = null }, element = editing.element, nativeTargets = editing.key == null, click = editing.click,
detail = if (library.usersOfBindings(b.id).size > 1) stringResource(R.string.input_shared_detail, b.name, library.usersOfBindings(b.id).size)
else "${profile.name} · ${roleLabel(role)}", onSave = { action ->
@@ -69,7 +69,7 @@ private class GamepadScaler(private val context: android.content.Context, privat
}
private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputBindings, val defaults: List<InputSurfaceElement>? = null,
private val gamepad: GamepadScaler? = null) {
private val gamepad: GamepadScaler? = null, snap: Boolean = true) {
var elements by mutableStateOf(layout.elements)
var actions by mutableStateOf(preset.elements)
var clicks by mutableStateOf(preset.clicks)
@@ -77,7 +77,7 @@ private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputB
var toolsExpanded by mutableStateOf(false)
var toolsX by mutableFloatStateOf(.02f)
var toolsY by mutableFloatStateOf(.02f)
var snap by mutableStateOf(true)
var snap by mutableStateOf(snap)
var adding by mutableStateOf(false)
var properties by mutableStateOf(false)
var clickProperties by mutableStateOf(false)
@@ -132,7 +132,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val (w, h) = OnScreenLayout.generatedSize(context, savedLayout, metrics.widthPixels, metrics.heightPixels) ?: (metrics.widthPixels to metrics.heightPixels)
GamepadScaler(display, w, h)
}
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad)
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad, ControllerPrefs.snap(context))
}
val manager = remember { context.getSystemService(DisplayManager::class.java) }
var revision by remember { mutableIntStateOf(0) }
@@ -162,7 +162,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val listener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(id: Int) { revision++ }
override fun onDisplayRemoved(id: Int) { revision++ }
override fun onDisplayChanged(id: Int) { revision++ }
override fun onDisplayChanged(id: Int) = Unit
}
manager.registerDisplayListener(listener, Handler(Looper.getMainLooper()))
onDispose { manager.unregisterDisplayListener(listener) }
@@ -171,13 +171,18 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val presentation = SurfaceEditorPresentation(host, target, transparent = host is SessionActivity && role == InputRole.PRIMARY) {
SurfaceCanvas(draft, transparent = host is SessionActivity && role == InputRole.PRIMARY)
}
(host as? SessionActivity)?.inputWindowShown(target.displayId)
runCatching { presentation.show() }.onFailure { failure = it.message }
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
if (event == Lifecycle.Event.ON_START) runCatching { presentation.show() }
}
host.lifecycle.addObserver(observer)
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
onDispose {
host.lifecycle.removeObserver(observer)
presentation.dismiss()
(host as? SessionActivity)?.inputWindowClosed(target.displayId)
}
}
// Compose 1.6 custom dialog sizing subtracts system bars even when they are hidden.
Dialog(onDismissRequest = ::leave, properties = DialogProperties(usePlatformDefaultWidth = true, decorFitsSystemWindows = false)) {
@@ -198,8 +203,8 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
if (draft.clickProperties) draft.clickAction(element) else draft.action(element),
if (draft.clickProperties) stringResource(R.string.input_click_name, element.name) else element.name,
onBack = { draft.properties = false }, element = element, click = draft.clickProperties,
properties = {
if (!draft.clickProperties) InputElementProperties(properties) { properties = it }
properties = { action ->
if (!draft.clickProperties) InputElementProperties(properties, action) { properties = it }
if (layoutUsers > 1) Text(stringResource(R.string.input_editor_shared, layoutUsers),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp))
@@ -309,13 +314,14 @@ private fun FloatingSurfaceTools(draft: SurfaceDraft, onSave: () -> Unit, onLeav
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SurfaceToolbar(draft: SurfaceDraft, onSave: () -> Unit, onLeave: () -> Unit) {
val context = LocalContext.current
FlowRow(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onLeave)
SecondaryButton(stringResource(R.string.input_editor_add), enabled = draft.elements.size < 64, compact = true) { draft.adding = true }
SecondaryButton(stringResource(R.string.input_editor_undo), enabled = draft.history.isNotEmpty(), compact = true) { draft.undo() }
if (draft.defaults != null) SecondaryButton(stringResource(R.string.input_reset_positions), enabled = draft.positioned != null, compact = true) { draft.resetPositions() }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(draft.snap, { draft.snap = it })
Checkbox(draft.snap, { draft.snap = it; ControllerPrefs.setSnap(context, it) })
Text(stringResource(R.string.ctrl_editor_snap))
}
PrimaryButton(stringResource(R.string.input_editor_done), compact = true, onClick = onSave)
@@ -32,10 +32,12 @@ import com.droiddeck.launcher.input.InputSurfaceStyle
import com.droiddeck.launcher.input.pointerSubset
import com.droiddeck.launcher.input.InputSurfaceSource
import com.droiddeck.launcher.input.InputElementKind
import com.droiddeck.launcher.input.InputAction
import com.droiddeck.launcher.input.InputButtonIcons
import com.droiddeck.launcher.input.InputButtonShape
import com.droiddeck.launcher.input.InputButtonImages
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.pcKeyLabel
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.atan2
import kotlin.math.hypot
@@ -195,7 +197,7 @@ class InputSurfaceView(
canvas.addView(when (element.kind) {
InputElementKind.STICK -> StickView(context, element, source, element.floating, element.hideWhenIdle && pad != null)
InputElementKind.TRACKPAD -> TrackpadView(context, element.control == InputControl.RIGHT_PAD, source = source)
InputElementKind.DPAD -> DirectionPad(context, source)
InputElementKind.DPAD -> DirectionPad(context, element, source)
else -> HoldButton(context, element, source)
}.apply { tag = element; contentDescription = element.name; styleElement(this); source?.onStateChanged = { postInvalidate() } })
}
@@ -541,6 +543,7 @@ class InputSurfaceView(
if (touching || clicking) shade(color, 160, 1f) else shade(color, 120, 0.35f), if (touching) light(color, 230, 0.7f) else light(color, 150, 0.25f))
if (!element.showLabel) return
labelPaint.color = textColor(touching)
if (drawDirectionLabels(canvas, source, false, cx, cy, radius * 0.66f, radius * 0.34f, labelPaint)) return
labelPaint.textSize = knobRadius * 0.8f
val label = if (element.name == element.control.label) glyph(element.control) else element.name
canvas.drawText(label, cx + knobX, cy + knobY + labelPaint.textSize * 0.35f, labelPaint)
@@ -552,6 +555,9 @@ class InputSurfaceView(
canvas.drawCircle(cx, cy, reach(), ring)
knob.color = if (touching) ACCENT else KNOB
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
if (!element.showLabel) return
labelPaint.color = if (touching) ACCENT else TEXT
drawDirectionLabels(canvas, source, false, cx, cy, reach() * 1.32f, reach() * 0.68f, labelPaint)
}
@SuppressLint("ClickableViewAccessibility")
@@ -613,10 +619,11 @@ class InputSurfaceView(
}
}
private inner class DirectionPad(context: Context, private val source: InputSurfaceSource? = null) : View(context) {
private inner class DirectionPad(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource? = null) : View(context) {
private val density = resources.displayMetrics.density
private var held = 0
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD); color = TEXT }
private val rect = RectF()
override fun onDraw(canvas: Canvas) {
@@ -636,6 +643,7 @@ class InputSurfaceView(
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
arm.color = KNOB
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
if (element.showLabel) drawDirectionLabels(canvas, source, true, cx, cy, (half + thick) / 2f, thick * 1.7f, label)
}
@SuppressLint("ClickableViewAccessibility")
@@ -717,6 +725,29 @@ class InputSurfaceView(
else -> control.id.uppercase()
}
private val CARDINAL = listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)
private val DIAGONAL = listOf(.7071f to -.7071f, .7071f to .7071f, -.7071f to .7071f, -.7071f to -.7071f)
internal fun directionLabels(context: Context, action: InputAction.Directions?, cardinalOnly: Boolean): List<Pair<Pair<Float, Float>, String>> {
if (action == null) return emptyList()
val codes = listOf(action.up, action.right, action.down, action.left).zip(CARDINAL) +
if (cardinalOnly) emptyList() else action.diagonals.orEmpty().zip(DIAGONAL)
return codes.filter { it.first != 0 }.map { (code, unit) -> unit to pcKeyLabel(context, code) }
}
private fun View.drawDirectionLabels(canvas: Canvas, source: InputSurfaceSource?, cardinalOnly: Boolean, cx: Float, cy: Float,
distance: Float, box: Float, paint: Paint): Boolean {
val labels = directionLabels(context, source?.directions, cardinalOnly)
paint.style = Paint.Style.FILL
for ((unit, text) in labels) {
paint.textSize = box * .6f
val measured = paint.measureText(text)
if (measured > box) paint.textSize *= box / measured
canvas.drawText(text, cx + unit.first * distance, cy + unit.second * distance - (paint.ascent() + paint.descent()) / 2f, paint)
}
return labels.isNotEmpty()
}
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int, paint: Paint) {
val (fx, fy) = when (direction) { 0 -> 0f to -1f; 1 -> 1f to 0f; 2 -> 0f to 1f; else -> -1f to 0f }
val path = Path()
@@ -0,0 +1,161 @@
package com.droiddeck.launcher.ui
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.lab.LabOverlay
/**
* Lab builds on this device: the one in use, one waiting for a restart, and the kept ones.
* [onRebuild] offers to build a kept one again for this install, when something can.
*/
@Composable
internal fun LabBuilds(onRebuild: ((LabOverlay.Info) -> Unit)? = null) {
val context = LocalContext.current
var version by remember { mutableIntStateOf(0) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val current = remember(version) { LabOverlay.current(context) }
val pending = remember(version) { LabOverlay.pending(context) }
val saved = remember(version) { LabOverlay.saved(context) }
if (current == null && pending == null && saved.isEmpty()) return
val active = LabOverlay.active
val paused = active == null && current != null && LabOverlay.skipped == LabOverlay.SKIPPED_APP_IMAGE
val restartFailed = stringResource(R.string.lab_restart_failed)
/** Android may have given the APK an app image again, which a Lab build cannot load over. */
fun restart() {
busy = true
error = null
Thread({
val result = runCatching {
if (LabOverlay.needsRecompile(context)) WirelessAdbFix.shell(context, LabOverlay.recompileCommand(context))
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
busy = false
if (result.isSuccess) LabOverlay.restart(context)
else error = restartFailed.format(result.exceptionOrNull()?.message.orEmpty())
}
}, "lab-restart").start()
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {
Text(
stringResource(R.string.lab_builds).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp),
)
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
val rows = mutableListOf<@Composable () -> Unit>()
if (current != null) rows += {
BuildRow(
current, true,
when {
active != null -> stringResource(R.string.lab_in_use)
paused -> stringResource(R.string.lab_paused_row)
else -> stringResource(R.string.lab_not_loaded, LabOverlay.skipped.orEmpty())
},
warn = active == null,
) {
if (paused) PrimaryButton(stringResource(R.string.lab_restart), compact = true, enabled = !busy) { restart() }
SecondaryButton(stringResource(R.string.lab_turn_off), compact = true, enabled = !busy) { LabOverlay.turnOff(context) }
}
}
if (pending != null) rows += {
BuildRow(pending, false, stringResource(R.string.lab_waiting), warn = false) {
PrimaryButton(stringResource(R.string.lab_restart), compact = true, enabled = !busy) { restart() }
}
}
for (build in saved) rows += {
var armed by remember(build.dir) { mutableStateOf(false) }
val info = build.info
BuildRow(
info, false,
when {
armed -> stringResource(R.string.lab_delete_warning)
!build.usable -> stringResource(R.string.lab_other_install)
build.reason == LabOverlay.Reason.DID_NOT_START -> stringResource(R.string.lab_off_did_not_start)
else -> stringResource(R.string.lab_kept)
},
warn = armed || !build.usable || build.reason == LabOverlay.Reason.DID_NOT_START,
) {
if (!build.usable && !armed && info != null && onRebuild != null) SecondaryButton(stringResource(R.string.lab_rebuild), compact = true, enabled = !busy) {
onRebuild(info)
}
if (build.usable && !armed) SecondaryButton(stringResource(R.string.lab_use), compact = true, enabled = !busy) {
runCatching { LabOverlay.stage(context, build) }
.onSuccess { restart() }
.onFailure { error = it.message }
}
SecondaryButton(stringResource(if (armed) R.string.lab_delete_confirm else R.string.lab_delete), compact = true, enabled = !busy) {
if (armed) {
LabOverlay.delete(build)
version++
} else armed = true
}
}
}
rows.forEachIndexed { index, row ->
row()
if (index < rows.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
error?.let { Text(it, fontSize = 12.5.sp, color = AttentionAmber, modifier = Modifier.padding(start = 2.dp, top = 8.dp)) }
}
}
@Composable
private fun BuildRow(info: LabOverlay.Info?, current: Boolean, status: String, warn: Boolean, actions: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(34.dp).clip(Shape12)
.background(if (current) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant)) {
Icon(Icons.Outlined.Code, null, tint = if (current) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f)) {
Text(info?.label ?: "?", fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis)
val built = info?.built?.takeIf { it > 0 }?.let { DateUtils.getRelativeTimeSpanString(it * 1000, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() }
Text(listOfNotNull(status, built).joinToString(" · "), fontSize = 12.5.sp, color = if (warn) AttentionAmber else colors.onSurfaceVariant,
maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { actions() }
}
}
@@ -109,6 +109,7 @@ import com.droiddeck.launcher.stores.itch.ItchUpdates
import com.droiddeck.launcher.stores.gog.GogMaintenance
import com.droiddeck.launcher.stores.retainOrder
import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
import com.droiddeck.launcher.stores.entryTime
import com.droiddeck.launcher.stores.sortGames
import kotlinx.coroutines.launch
import androidx.compose.runtime.setValue
@@ -296,8 +297,8 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
val sizesPending = sizeSort && !sizesWaited && candidates.any { sizeOf(it) == null }
val sorted = sortGames(
candidates, sort, title = { it.title }, releaseDate = { it.item?.releaseDate.orEmpty() },
lastPlayed = { e -> e.game?.lastPlayed ?: e.storeKey?.let { lastPlayed[it] } ?: 0L },
installedAt = { e -> e.storeKey?.let { installedAt[it] } ?: 0L },
lastPlayed = { e -> entryTime(e.game?.lastPlayed, e.storeKey?.let { lastPlayed[it] }) },
installedAt = { e -> entryTime(e.storeKey?.let { installedAt[it] }, e.game?.installedAt) },
size = { e -> sizeOf(e) ?: 0L },
)
var stable by remember { mutableStateOf(emptyList<LibraryEntry>()) }
@@ -323,7 +323,7 @@ class SecondScreenPresentation(
context.getString(if (keyboardShown.value) R.string.second_hide_keyboard else R.string.second_show_keyboard),
icon = R.drawable.ic_bottom_keyboard, active = keyboardShown.value, tag = "bottom-keyboard") { showKeyboard() }))
SecondScreenMode.DECK_CONTROLS -> model(swap, deckPanelTabs(context, deckPanel.value, ::selectDeckPanel))
SecondScreenMode.DASHBOARD -> model(swap)
SecondScreenMode.DASHBOARD, SecondScreenMode.CUSTOM_CONTROLS -> model(swap)
SecondScreenMode.TERMINAL -> model(listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard,
tag = "bottom-keyboard") { terminalPanel?.showKeyboard() }))
else -> null
@@ -9,12 +9,16 @@ internal enum class SessionDrawerPage(val firstControl: String) {
EFFECTS("look"),
VR("steamvr"),
GAMES("anisotropy"),
AGENT("agent-input"),
SESSION("suspend"),
}
internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List<SessionDrawerPage> =
internal fun sessionDrawerPages(
hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
): List<SessionDrawerPage> =
SessionDrawerPage.entries.filter {
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES)
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) &&
(hasAgent || it != SessionDrawerPage.AGENT)
}
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
@@ -18,6 +18,7 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -43,6 +44,7 @@ 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.AutoAwesome
import androidx.compose.material.icons.outlined.ScreenShare
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PictureInPictureAlt
@@ -172,6 +174,7 @@ private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.VR -> R.string.vr_title
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
SessionDrawerPage.AGENT -> R.string.rail_agent
SessionDrawerPage.SESSION -> R.string.mode_session
}
@@ -184,6 +187,7 @@ private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.VR -> VrHeadsetIcon
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
SessionDrawerPage.AGENT -> Icons.Outlined.AutoAwesome
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
}
@@ -219,7 +223,6 @@ class DrawerActions(
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
val onScreenButtonsVisible: Boolean,
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val micAvailable: Boolean = false,
@@ -325,6 +328,7 @@ class DrawerActions(
val onStop: () -> Unit,
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
val onDismissScreen: (() -> Unit)? = null,
val components: ComponentsManager.Snapshot? = null,
val systemPowerVr: Boolean = false,
val onComponentsRefresh: () -> Unit = {},
@@ -488,7 +492,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
) {
SteamActionPill(
stringResource(R.string.common_close), Icons.Outlined.Close, focus.track(page, "close"),
) { host.open = null; a.onClose() }
) { host.open = null; (a.onDismissScreen ?: a.onClose)() }
if (roomyHeader) Spacer(Modifier.weight(1f))
SteamActionPill(
stringResource(R.string.stores_dl_pause), Icons.Outlined.Pause, focus.track(page, "pause"),
@@ -762,6 +766,13 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
track = { key -> focus.track(page, key) },
)
}
SessionDrawerPage.AGENT -> AgentPage(
Modifier.fillMaxWidth()
.height((LocalConfiguration.current.screenHeightDp - 190).coerceAtLeast(240).dp)
.padding(horizontal = 14.dp),
inputModifier = focus.track(page, "agent-input"),
visible = open,
)
SessionDrawerPage.SESSION -> {
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
@@ -98,6 +98,7 @@ internal class HudData(
val tempColor: (HudMetrics.TempSensor, Int) -> Color,
val redC: (HudMetrics.TempSensor) -> Int?,
val historySeconds: Int = 0,
val graphEnd: Long = 0L,
) {
val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it }
val hz: Int get() = if (refreshHz > 0) refreshHz else 60
@@ -268,18 +269,26 @@ private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean =
private val GRAPH_MIN_GAP = 3.dp
@Composable
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier) {
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier, end: Long = 0L) {
Canvas(modifier) {
if (values.size < 2 || hi <= lo) return@Canvas
val stride = ceil(GRAPH_MIN_GAP.toPx() * (values.size - 1) / size.width).toInt().coerceAtLeast(1)
val points = if (stride == 1) values else values.chunked(stride) { it.average().toFloat() }
if (points.size < 2) return@Canvas
// Buckets start at multiples of stride counted from the first sample ever drawn, so a sample keeps
// its bucket as the window scrolls; counted from the window's start, every new frame re-paired them.
val skip = Math.floorMod(values.size.toLong() - end, stride.toLong()).toInt()
val path = Path()
points.forEachIndexed { i, v ->
val x = i / (points.size - 1f) * size.width
val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
var drawn = 0
var start = skip
while (start < values.size) {
val stop = minOf(start + stride, values.size)
var sum = 0f
for (i in start until stop) sum += values[i]
val x = (start + stop - 1) / 2f / (values.size - 1f) * size.width
val y = size.height - ((sum / (stop - start) - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
if (drawn++ == 0) path.moveTo(x, y) else path.lineTo(x, y)
start = stop
}
if (drawn < 2) return@Canvas
drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
}
@@ -287,7 +296,7 @@ private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modif
@Composable
private fun FrameGraph(d: HudData, accent: Color, height: Dp) {
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height))
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height), d.graphEnd)
}
@Composable
@@ -589,7 +598,7 @@ private fun ColumnScope.Pacing(d: HudData, accent: Color) {
drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash)
}
}
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize())
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize(), d.graphEnd)
for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) {
Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color,
Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp))
@@ -285,6 +285,13 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
tests.forEach { t -> TestRow(t, u.follow.pr == t.pr) { ua.onFollow(Follow(Channel.TEST, t.pr)) } }
}
}
val context = androidx.compose.ui.platform.LocalContext.current
LabBuilds(onRebuild = if (com.droiddeck.launcher.agent.AgentHost.installed) { info ->
com.droiddeck.launcher.agent.AgentHost.send(context, context.getString(R.string.agent_rebuild_lab, info.label))
context.startActivity(android.content.Intent(context, com.droiddeck.launcher.FrontEndActivity::class.java)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent")
.addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT))
} else null)
}
}
-8
View File
@@ -1259,7 +1259,6 @@
<string name="deck_tab_gamepad">يد التحكّم</string>
<string name="deck_surface_stick">عصا التحكّم</string>
<string name="deck_surface_trackpad">لوحة اللمس</string>
<string name="deck_surface_switch">التبديل بين عصا التحكّم ولوحة اللمس</string>
<string name="deck_close">إغلاق عناصر التحكّم على الشاشة الثانية</string>
<string name="deck_open_steam_menu">فتح قائمة Steam</string>
<string name="deck_right_trackpad">لوحة اللمس اليمنى</string>
@@ -1874,13 +1873,6 @@
<string name="bottom_downloads_shader">جارٍ تنزيل ذاكرة المظلِّلات المؤقتة</string>
<string name="bottom_downloads_paused">متوقف مؤقتًا</string>
<string name="bottom_downloads_queued">بانتظار البدء</string>
<string name="bottom_page_notifications">الإشعارات</string>
<string name="bottom_notifications_listener">الشاشة السفلية لـ DroidDeck</string>
<string name="bottom_notifications_why">اعرض الرسائل والإشعارات الأخرى هنا أثناء اللعب. تبقى على هذه الشاشة فقط ولا تُحفظ. يطلب Android الإذن مرة واحدة: تُفتح صفحة الوصول إلى الإشعارات على هذه الشاشة؛ فعِّل DroidDeck هناك وستعود هذه الصفحة تلقائيًا.</string>
<string name="bottom_notifications_allow">السماح بالوصول إلى الإشعارات</string>
<string name="bottom_notifications_none">لا توجد إشعارات.</string>
<string name="bottom_notifications_count">الإشعارات: %1$d</string>
<string name="bottom_notifications_clear">مسح</string>
<string name="bottom_page_screenshots">لقطات الشاشة</string>
<string name="stats_fps_needs_hud">يتطلب FPS تشغيل واجهة الأداء</string>
<string name="stats_speed">السرعة</string>
@@ -1308,7 +1308,6 @@
<string name="deck_tab_gamepad">Control</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
<string name="deck_right_trackpad">Trackpad derecho</string>
@@ -1866,13 +1865,6 @@
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
<string name="bottom_downloads_paused">En pausa</string>
<string name="bottom_downloads_queued">Esperando para empezar</string>
<string name="bottom_page_notifications">Notificaciones</string>
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
<string name="bottom_notifications_none">No hay notificaciones.</string>
<string name="bottom_notifications_count">%1$d notificaciones</string>
<string name="bottom_notifications_clear">Borrar</string>
<string name="bottom_page_screenshots">Capturas de pantalla</string>
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
<string name="stats_speed">Velocidad</string>
-8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Геймпад</string>
<string name="deck_surface_stick">Стик</string>
<string name="deck_surface_trackpad">Тракпад</string>
<string name="deck_surface_switch">Превключване между стик и тракпад</string>
<string name="deck_close">Затваряне на контролите за втория екран</string>
<string name="deck_open_steam_menu">Отваряне на менюто на Steam</string>
<string name="deck_right_trackpad">Десен тракпад</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Изтегляне на кеша на шейдърите</string>
<string name="bottom_downloads_paused">На пауза</string>
<string name="bottom_downloads_queued">Изчаква стартиране</string>
<string name="bottom_page_notifications">Известия</string>
<string name="bottom_notifications_listener">Долен екран на DroidDeck</string>
<string name="bottom_notifications_why">Показвайте тук съобщения и други известия, докато играете. Те остават само на този екран и не се запазват. Android пита веднъж: страницата за достъп до известията се отваря на този екран; включете DroidDeck там и тази страница ще се върне сама.</string>
<string name="bottom_notifications_allow">Разрешаване на достъпа до известията</string>
<string name="bottom_notifications_none">Няма известия.</string>
<string name="bottom_notifications_count">Известия: %1$d</string>
<string name="bottom_notifications_clear">Изчисти</string>
<string name="bottom_page_screenshots">Екранни снимки</string>
<string name="stats_fps_needs_hud">За FPS трябва HUD да е включен</string>
<string name="stats_speed">Скорост</string>
-8
View File
@@ -1245,7 +1245,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Páčka</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Přepnout mezi páčkou a trackpadem</string>
<string name="deck_close">Zavřít ovládání druhé obrazovky</string>
<string name="deck_open_steam_menu">Otevřít nabídku Steamu</string>
<string name="deck_right_trackpad">Pravý trackpad</string>
@@ -1826,13 +1825,6 @@
<string name="bottom_downloads_shader">Stahování mezipaměti shaderů</string>
<string name="bottom_downloads_paused">Pozastaveno</string>
<string name="bottom_downloads_queued">Čeká na spuštění</string>
<string name="bottom_page_notifications">Oznámení</string>
<string name="bottom_notifications_listener">Spodní obrazovka DroidDeck</string>
<string name="bottom_notifications_why">Zobrazujte zde zprávy a další oznámení během hraní. Zůstávají jen na této obrazovce a neukládají se. Android se zeptá jednou: na této obrazovce se otevře jeho stránka přístupu k oznámením; zapněte tam DroidDeck a tato stránka se sama vrátí.</string>
<string name="bottom_notifications_allow">Povolit přístup k oznámením</string>
<string name="bottom_notifications_none">Žádná oznámení.</string>
<string name="bottom_notifications_count">Oznámení: %1$d</string>
<string name="bottom_notifications_clear">Vymazat</string>
<string name="bottom_page_screenshots">Snímky obrazovky</string>
<string name="stats_fps_needs_hud">FPS vyžaduje zapnutý HUD</string>
<string name="stats_speed">Rychlost</string>
-8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Skift mellem stick og trackpad</string>
<string name="deck_close">Luk kontrollerne til den anden skærm</string>
<string name="deck_open_steam_menu">Åbn Steam-menuen</string>
<string name="deck_right_trackpad">Højre trackpad</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Downloader shader-cache</string>
<string name="bottom_downloads_paused">På pause</string>
<string name="bottom_downloads_queued">Venter på at starte</string>
<string name="bottom_page_notifications">Notifikationer</string>
<string name="bottom_notifications_listener">DroidDecks nederste skærm</string>
<string name="bottom_notifications_why">Vis beskeder og andre notifikationer her, mens du spiller. De bliver kun på denne skærm og gemmes ikke. Android spørger én gang: siden for notifikationsadgang åbnes på denne skærm; slå DroidDeck til der, så vender denne side selv tilbage.</string>
<string name="bottom_notifications_allow">Tillad notifikationsadgang</string>
<string name="bottom_notifications_none">Ingen notifikationer.</string>
<string name="bottom_notifications_count">%1$d notifikationer</string>
<string name="bottom_notifications_clear">Ryd</string>
<string name="bottom_page_screenshots">Skærmbilleder</string>
<string name="stats_fps_needs_hud">FPS kræver, at HUD er slået til</string>
<string name="stats_speed">Hastighed</string>
-8
View File
@@ -1223,7 +1223,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Zwischen Stick und Trackpad wechseln</string>
<string name="deck_close">Steuerung auf dem Zweitbildschirm schließen</string>
<string name="deck_open_steam_menu">Steam-Menü öffnen</string>
<string name="deck_right_trackpad">Rechtes Trackpad</string>
@@ -1770,13 +1769,6 @@
<string name="bottom_downloads_shader">Shader-Cache wird heruntergeladen</string>
<string name="bottom_downloads_paused">Pausiert</string>
<string name="bottom_downloads_queued">Wartet auf Start</string>
<string name="bottom_page_notifications">Benachrichtigungen</string>
<string name="bottom_notifications_listener">DroidDeck – unterer Bildschirm</string>
<string name="bottom_notifications_why">Zeigt Nachrichten und andere Benachrichtigungen hier an, während du spielst. Sie bleiben nur auf diesem Bildschirm und werden nicht gespeichert. Android fragt einmal: Die Seite für den Benachrichtigungszugriff öffnet sich auf diesem Bildschirm; aktiviere dort DroidDeck, dann kehrt diese Seite von selbst zurück.</string>
<string name="bottom_notifications_allow">Benachrichtigungszugriff erlauben</string>
<string name="bottom_notifications_none">Keine Benachrichtigungen.</string>
<string name="bottom_notifications_count">%1$d Benachrichtigungen</string>
<string name="bottom_notifications_clear">Löschen</string>
<string name="bottom_page_screenshots">Screenshots</string>
<string name="stats_fps_needs_hud">FPS benötigt das aktive HUD</string>
<string name="stats_speed">Geschwindigkeit</string>
-8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Χειριστήριο</string>
<string name="deck_surface_stick">Μοχλός</string>
<string name="deck_surface_trackpad">Πινακίδα αφής</string>
<string name="deck_surface_switch">Εναλλαγή μεταξύ μοχλού και πινακίδας αφής</string>
<string name="deck_close">Κλείσιμο χειριστηρίων δεύτερης οθόνης</string>
<string name="deck_open_steam_menu">Άνοιγμα μενού Steam</string>
<string name="deck_right_trackpad">Δεξιά πινακίδα αφής</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Λήψη cache shader</string>
<string name="bottom_downloads_paused">Σε παύση</string>
<string name="bottom_downloads_queued">Αναμονή για έναρξη</string>
<string name="bottom_page_notifications">Ειδοποιήσεις</string>
<string name="bottom_notifications_listener">DroidDeck – κάτω οθόνη</string>
<string name="bottom_notifications_why">Εμφάνιση μηνυμάτων και άλλων ειδοποιήσεων εδώ ενώ παίζετε. Παραμένουν μόνο σε αυτή την οθόνη και δεν αποθηκεύονται. Το Android ρωτά μία φορά: η σελίδα πρόσβασης ειδοποιήσεων ανοίγει σε αυτή την οθόνη· ενεργοποιήστε εκεί το DroidDeck και αυτή η σελίδα επιστρέφει μόνη της.</string>
<string name="bottom_notifications_allow">Αποδοχή πρόσβασης ειδοποιήσεων</string>
<string name="bottom_notifications_none">Δεν υπάρχουν ειδοποιήσεις.</string>
<string name="bottom_notifications_count">%1$d ειδοποιήσεις</string>
<string name="bottom_notifications_clear">Απαλοιφή</string>
<string name="bottom_page_screenshots">Στιγμιότυπα οθόνης</string>
<string name="stats_fps_needs_hud">Τα FPS απαιτούν ενεργό HUD</string>
<string name="stats_speed">Ταχύτητα</string>
-8
View File
@@ -1238,7 +1238,6 @@
<string name="deck_tab_gamepad">Mando</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
<string name="deck_right_trackpad">Trackpad derecho</string>
@@ -1802,13 +1801,6 @@
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
<string name="bottom_downloads_paused">En pausa</string>
<string name="bottom_downloads_queued">Esperando para empezar</string>
<string name="bottom_page_notifications">Notificaciones</string>
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
<string name="bottom_notifications_none">No hay notificaciones.</string>
<string name="bottom_notifications_count">%1$d notificaciones</string>
<string name="bottom_notifications_clear">Borrar</string>
<string name="bottom_page_screenshots">Capturas de pantalla</string>
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
<string name="stats_speed">Velocidad</string>
-8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Peliohjain</string>
<string name="deck_surface_stick">Tatti</string>
<string name="deck_surface_trackpad">Kosketuslevy</string>
<string name="deck_surface_switch">Vaihda tatin ja kosketuslevyn välillä</string>
<string name="deck_close">Sulje toisen näytön ohjaimet</string>
<string name="deck_open_steam_menu">Avaa Steam-valikko</string>
<string name="deck_right_trackpad">Oikea kosketuslevy</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Ladataan sävytinvälimuistia</string>
<string name="bottom_downloads_paused">Keskeytetty</string>
<string name="bottom_downloads_queued">Odottaa alkamista</string>
<string name="bottom_page_notifications">Ilmoitukset</string>
<string name="bottom_notifications_listener">DroidDeckin alanäyttö</string>
<string name="bottom_notifications_why">Näytä viestit ja muut ilmoitukset täällä pelatessasi. Ne näkyvät vain tällä näytöllä, eikä niitä tallenneta. Android kysyy kerran: sen ilmoitusten käyttöoikeussivu avautuu tälle näytölle; kytke DroidDeck siellä päälle, niin tämä sivu palaa itsestään.</string>
<string name="bottom_notifications_allow">Salli ilmoitusten käyttö</string>
<string name="bottom_notifications_none">Ei ilmoituksia.</string>
<string name="bottom_notifications_count">Ilmoituksia: %1$d</string>
<string name="bottom_notifications_clear">Tyhjennä</string>
<string name="bottom_page_screenshots">Kuvakaappaukset</string>
<string name="stats_fps_needs_hud">FPS vaatii HUD:n päälle</string>
<string name="stats_speed">Nopeus</string>
-8
View File
@@ -1238,7 +1238,6 @@
<string name="deck_tab_gamepad">Manette</string>
<string name="deck_surface_stick">Joystick</string>
<string name="deck_surface_trackpad">Pavé tactile</string>
<string name="deck_surface_switch">Basculer entre joystick et pavé tactile</string>
<string name="deck_close">Fermer les commandes du deuxième écran</string>
<string name="deck_open_steam_menu">Ouvrir le menu Steam</string>
<string name="deck_right_trackpad">Pavé tactile droit</string>
@@ -1802,13 +1801,6 @@
<string name="bottom_downloads_shader">Téléchargement du cache de shaders</string>
<string name="bottom_downloads_paused">En pause</string>
<string name="bottom_downloads_queued">En attente de démarrage</string>
<string name="bottom_page_notifications">Notifications</string>
<string name="bottom_notifications_listener">Écran inférieur DroidDeck</string>
<string name="bottom_notifications_why">Affichez ici les messages et autres notifications pendant que vous jouez. Ils restent uniquement sur cet écran et ne sont pas enregistrés. Android ne le demande qu\'une fois : sa page d\'accès aux notifications s\'ouvre sur cet écran ; activez-y DroidDeck et cette page reviendra d\'elle-même.</string>
<string name="bottom_notifications_allow">Autoriser l\'accès aux notifications</string>
<string name="bottom_notifications_none">Aucune notification.</string>
<string name="bottom_notifications_count">%1$d notifications</string>
<string name="bottom_notifications_clear">Effacer</string>
<string name="bottom_page_screenshots">Captures d\'écran</string>
<string name="stats_fps_needs_hud">Les FPS nécessitent d\'activer le HUD</string>
<string name="stats_speed">Vitesse</string>
-8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">गेमपैड</string>
<string name="deck_surface_stick">स्टिक</string>
<string name="deck_surface_trackpad">ट्रैकपैड</string>
<string name="deck_surface_switch">स्टिक और ट्रैकपैड के बीच बदलें</string>
<string name="deck_close">दूसरी स्क्रीन के कंट्रोल बंद करें</string>
<string name="deck_open_steam_menu">Steam मेनू खोलें</string>
<string name="deck_right_trackpad">दायां ट्रैकपैड</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">शेडर कैश डाउनलोड हो रहा है</string>
<string name="bottom_downloads_paused">रुका हुआ</string>
<string name="bottom_downloads_queued">शुरू होने का इंतज़ार</string>
<string name="bottom_page_notifications">नोटिफ़िकेशन</string>
<string name="bottom_notifications_listener">DroidDeck नीचे की स्क्रीन</string>
<string name="bottom_notifications_why">खेलते समय मैसेज और अन्य नोटिफ़िकेशन यहां दिखाएं। वे सिर्फ़ इसी स्क्रीन पर रहते हैं और सेव नहीं होते। Android एक बार पूछता है: उसका नोटिफ़िकेशन ऐक्सेस पेज इसी स्क्रीन पर खुलता है; वहां DroidDeck चालू करें और यह पेज अपने-आप वापस आ जाएगा।</string>
<string name="bottom_notifications_allow">नोटिफ़िकेशन ऐक्सेस की अनुमति दें</string>
<string name="bottom_notifications_none">कोई नोटिफ़िकेशन नहीं।</string>
<string name="bottom_notifications_count">%1$d नोटिफ़िकेशन</string>
<string name="bottom_notifications_clear">साफ़ करें</string>
<string name="bottom_page_screenshots">स्क्रीनशॉट</string>
<string name="stats_fps_needs_hud">FPS के लिए HUD चालू होना चाहिए</string>
<string name="stats_speed">स्पीड</string>
-8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Kontroller</string>
<string name="deck_surface_stick">Kar</string>
<string name="deck_surface_trackpad">Érintőpad</string>
<string name="deck_surface_switch">Váltás a kar és az érintőpad között</string>
<string name="deck_close">Második képernyős vezérlők bezárása</string>
<string name="deck_open_steam_menu">Steam-menü megnyitása</string>
<string name="deck_right_trackpad">Jobb érintőpad</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Shader-gyorsítótár letöltése</string>
<string name="bottom_downloads_paused">Szüneteltetve</string>
<string name="bottom_downloads_queued">Indításra vár</string>
<string name="bottom_page_notifications">Értesítések</string>
<string name="bottom_notifications_listener">DroidDeck alsó képernyő</string>
<string name="bottom_notifications_why">Itt jelenítheti meg az üzeneteket és más értesítéseket játék közben. Csak ezen a képernyőn láthatók, és nem kerülnek mentésre. Az Android egyszer kérdez rá: az értesítési hozzáférés oldala ezen a képernyőn nyílik meg; kapcsolja be ott a DroidDecket, és ez az oldal magától visszatér.</string>
<string name="bottom_notifications_allow">Értesítési hozzáférés engedélyezése</string>
<string name="bottom_notifications_none">Nincsenek értesítések.</string>
<string name="bottom_notifications_count">%1$d értesítés</string>
<string name="bottom_notifications_clear">Törlés</string>
<string name="bottom_page_screenshots">Képernyőképek</string>
<string name="stats_fps_needs_hud">Az FPS-hez be kell kapcsolni a HUD-ot</string>
<string name="stats_speed">Sebesség</string>
-8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stik</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Beralih antara stik dan trackpad</string>
<string name="deck_close">Tutup kontrol layar kedua</string>
<string name="deck_open_steam_menu">Buka menu Steam</string>
<string name="deck_right_trackpad">Trackpad kanan</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">Mengunduh cache shader</string>
<string name="bottom_downloads_paused">Dijeda</string>
<string name="bottom_downloads_queued">Menunggu dimulai</string>
<string name="bottom_page_notifications">Notifikasi</string>
<string name="bottom_notifications_listener">Layar bawah DroidDeck</string>
<string name="bottom_notifications_why">Tampilkan pesan dan notifikasi lain di sini saat Anda bermain. Notifikasi hanya muncul di layar ini dan tidak disimpan. Android hanya meminta sekali: halaman akses notifikasinya terbuka di layar ini; aktifkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
<string name="bottom_notifications_allow">Izinkan akses notifikasi</string>
<string name="bottom_notifications_none">Tidak ada notifikasi.</string>
<string name="bottom_notifications_count">%1$d notifikasi</string>
<string name="bottom_notifications_clear">Hapus</string>
<string name="bottom_page_screenshots">Tangkapan layar</string>
<string name="stats_fps_needs_hud">FPS memerlukan HUD aktif</string>
<string name="stats_speed">Kecepatan</string>
-8
View File
@@ -1230,7 +1230,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Levetta</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Alterna tra levetta e trackpad</string>
<string name="deck_close">Chiudi i controlli sul secondo schermo</string>
<string name="deck_open_steam_menu">Apri il menu Steam</string>
<string name="deck_right_trackpad">Trackpad destro</string>
@@ -1794,13 +1793,6 @@
<string name="bottom_downloads_shader">Download della cache degli shader</string>
<string name="bottom_downloads_paused">In pausa</string>
<string name="bottom_downloads_queued">In attesa di avvio</string>
<string name="bottom_page_notifications">Notifiche</string>
<string name="bottom_notifications_listener">Schermo inferiore DroidDeck</string>
<string name="bottom_notifications_why">Mostra qui messaggi e altre notifiche mentre giochi. Restano solo su questo schermo e non vengono salvate. Android lo chiede una sola volta: la sua pagina di accesso alle notifiche si apre su questo schermo; attiva DroidDeck lì e questa pagina torna da sola.</string>
<string name="bottom_notifications_allow">Consenti accesso alle notifiche</string>
<string name="bottom_notifications_none">Nessuna notifica.</string>
<string name="bottom_notifications_count">%1$d notifiche</string>
<string name="bottom_notifications_clear">Cancella</string>
<string name="bottom_page_screenshots">Screenshot</string>
<string name="stats_fps_needs_hud">Gli FPS richiedono l\'HUD attivo</string>
<string name="stats_speed">Velocità</string>
-8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">ゲームパッド</string>
<string name="deck_surface_stick">スティック</string>
<string name="deck_surface_trackpad">トラックパッド</string>
<string name="deck_surface_switch">スティックとトラックパッドを切り替え</string>
<string name="deck_close">セカンドスクリーンコントロールを閉じる</string>
<string name="deck_open_steam_menu">Steamメニューを開く</string>
<string name="deck_right_trackpad">右トラックパッド</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">シェーダーキャッシュをダウンロード中</string>
<string name="bottom_downloads_paused">一時停止中</string>
<string name="bottom_downloads_queued">開始待ち</string>
<string name="bottom_page_notifications">通知</string>
<string name="bottom_notifications_listener">DroidDeck下部画面</string>
<string name="bottom_notifications_why">プレイ中、メッセージなどの通知をここに表示します。通知はこの画面にのみ表示され、保存されません。Androidによる確認は一度だけです。この画面に通知へのアクセスのページが開くので、そこでDroidDeckをオンにすると、このページに自動的に戻ります。</string>
<string name="bottom_notifications_allow">通知へのアクセスを許可</string>
<string name="bottom_notifications_none">通知はありません。</string>
<string name="bottom_notifications_count">%1$d件の通知</string>
<string name="bottom_notifications_clear">クリア</string>
<string name="bottom_page_screenshots">スクリーンショット</string>
<string name="stats_fps_needs_hud">FPSの表示にはHUDをオンにする必要があります</string>
<string name="stats_speed">速度</string>
-8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">게임패드</string>
<string name="deck_surface_stick">스틱</string>
<string name="deck_surface_trackpad">터치패드</string>
<string name="deck_surface_switch">스틱과 터치패드 간 전환</string>
<string name="deck_close">보조 화면 컨트롤 닫기</string>
<string name="deck_open_steam_menu">Steam 메뉴 열기</string>
<string name="deck_right_trackpad">오른쪽 터치패드</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">셰이더 캐시 다운로드 중</string>
<string name="bottom_downloads_paused">일시 중지됨</string>
<string name="bottom_downloads_queued">시작 대기 중</string>
<string name="bottom_page_notifications">알림</string>
<string name="bottom_notifications_listener">DroidDeck 하단 화면</string>
<string name="bottom_notifications_why">플레이하는 동안 메시지와 기타 알림을 여기에 표시합니다. 알림은 이 화면에만 표시되며 저장되지 않습니다. Android에서 한 번만 묻습니다. 이 화면에 알림 접근 권한 페이지가 열리면 DroidDeck을 켜세요. 그러면 이 페이지로 자동으로 돌아옵니다.</string>
<string name="bottom_notifications_allow">알림 접근 허용</string>
<string name="bottom_notifications_none">알림이 없습니다.</string>
<string name="bottom_notifications_count">알림 %1$d개</string>
<string name="bottom_notifications_clear">지우기</string>
<string name="bottom_page_screenshots">스크린샷</string>
<string name="stats_fps_needs_hud">FPS를 보려면 HUD를 켜야 합니다</string>
<string name="stats_speed">속도</string>
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more