mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-10 01:51:33 -10:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d46d2461a | ||
|
|
7426c6b682 | ||
|
|
0995cbd5c7 | ||
|
|
2cbb1e47e0 | ||
|
|
9e3814b546 | ||
|
|
3e349d4c78 | ||
|
|
deb9e950b7 | ||
|
|
d3ae5579ad | ||
|
|
58c83968de | ||
|
|
eb3da7595f | ||
|
|
90b80cb84b | ||
|
|
dd075ee138 | ||
|
|
173ffac05b | ||
|
|
c64a72b2a1 | ||
|
|
9a6c46e4ac | ||
|
|
fc46d3c658 | ||
|
|
53cdf00b43 | ||
|
|
7836c32aac | ||
|
|
d9358e8f19 | ||
|
|
5d25f79fa7 | ||
|
|
d44bf32ae8 | ||
|
|
bdaa155baa | ||
|
|
44e72258b1 | ||
|
|
77e30e4b4e | ||
|
|
3f5e3bb19d | ||
|
|
8ffac40b4f | ||
|
|
8337166cc9 | ||
|
|
2d15205ff7 | ||
|
|
71481ac2ff | ||
|
|
2e34213b28 | ||
|
|
05efdf61d8 | ||
|
|
f638979507 | ||
|
|
9d73ff75bc | ||
|
|
2213362336 | ||
|
|
5683be3b9b | ||
|
|
d7dd802429 | ||
|
|
0cf83ce71d | ||
|
|
acecbcd79a | ||
|
|
b6d70b177d | ||
|
|
5f0519dfbf | ||
|
|
914c659f4a | ||
|
|
a178553254 | ||
|
|
774abb738d | ||
|
|
5d63cc3b9e | ||
|
|
f3f3f9ce08 | ||
|
|
75a0230304 | ||
|
|
7c89c14a84 | ||
|
|
b837611efe | ||
|
|
bf434b2652 | ||
|
|
35941bb9e7 | ||
|
|
96a12465e0 | ||
|
|
88d6b70881 | ||
|
|
29823bb1bf | ||
|
|
92a7124589 | ||
|
|
bf95a40e3e | ||
|
|
1355cb3a2b | ||
|
|
51407155fc | ||
|
|
b4a953c659 | ||
|
|
5e438e9248 | ||
|
|
6b110581ac | ||
|
|
92742f37c1 | ||
|
|
ecd916d43f | ||
|
|
a7c10e3f51 | ||
|
|
1760f008ae | ||
|
|
3bb279f635 | ||
|
|
3fef1f3fa1 | ||
|
|
a4f26799ab | ||
|
|
08a6a3cba8 | ||
|
|
005fec2125 | ||
|
|
2e70b8ed73 | ||
|
|
74c0f2127a | ||
|
|
2bd7df5e1a | ||
|
|
b7b96875a1 | ||
|
|
51e5e3a2a4 | ||
|
|
c755d05473 | ||
|
|
aa883830ca | ||
|
|
45e39b7aaf | ||
|
|
bc99cacd02 | ||
|
|
2beeb715fd | ||
|
|
39469e6e8f | ||
|
|
15baa296bb | ||
|
|
e4a4775be5 | ||
|
|
77ef10d601 | ||
|
|
914b3588e1 | ||
|
|
2ade269be3 | ||
|
|
0d034b1996 |
No files matched your search
@@ -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
|
||||
+12
-12
@@ -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
|
||||
@@ -785,18 +792,11 @@ jobs:
|
||||
cd app/src/main/rust/stores
|
||||
cargo test --locked 2>&1 | tail -60
|
||||
|
||||
python-runner:
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/pick-runner.yml
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
|
||||
python-tests:
|
||||
name: Python and native regression tests
|
||||
needs: [source, python-runner]
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ${{ fromJSON(needs.python-runner.outputs.labels) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user