Compare commits

..
350 changed files with 7467 additions and 24268 deletions

No files matched your search

-1
View File
@@ -10,4 +10,3 @@ 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
+7 -28
View File
@@ -136,18 +136,11 @@ jobs:
if-no-files-found: error
retention-days: 14
components-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 }}
components:
name: Prepare verified components
needs: [source, components-runner]
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ${{ fromJSON(needs.components-runner.outputs.labels) }}
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
@@ -313,11 +306,6 @@ 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
@@ -330,18 +318,11 @@ jobs:
compression-level: 0
retention-days: 14
build-runner:
needs: [components]
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 }}
build:
needs: [source, components, xbox, build-runner]
needs: [source, components, xbox]
name: Compile APK and Android tests
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ${{ fromJSON(needs.build-runner.outputs.labels) }}
runs-on: ubuntu-24.04
timeout-minutes: 45
outputs:
variants: ${{ steps.variants.outputs.list }}
@@ -563,11 +544,9 @@ jobs:
- name: Cache checksum-pinned pack inputs
uses: actions/cache@v4
with:
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-
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-
- name: Build the add-on packs
run: |
python3 tools/powervr/fetch.py
-60
View File
@@ -1,60 +0,0 @@
name: Pick runner
on:
workflow_call:
outputs:
labels:
value: ${{ jobs.pick.outputs.labels }}
secrets:
RUNNER_STATUS_TOKEN:
required: false
permissions: {}
jobs:
pick:
name: Pick runner
runs-on: ubuntu-24.04
timeout-minutes: 2
outputs:
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
steps:
- name: Pick the compile runner
id: runner
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
run: |
set -euo pipefail
labels='"ubuntu-24.04"'
if [ -n "$GH_TOKEN" ]; then
idle=$(gh api "orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners" --jq '[.runners[] | select(.status == "online" and .busy == false and any(.labels[]; .name == "droiddeck-ws"))] | length' 2>/dev/null || echo 0)
if [ "${idle:-0}" -gt 0 ]; then
labels='["self-hosted","droiddeck-ws"]'
fi
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"
+5 -7
View File
@@ -7,10 +7,10 @@
DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linux desktop, integration with GOG, Epic Games, Xbox, and other storefronts, Steam VR, and more!
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
<p align="center"><b>100% open source. No ads. No telemetry. No source withheld.</b></p>
<p align="center">
<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://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://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, Mali, Xclipse and PowerVR are experimental. Adreno 710 is currently unsupported. No root needed.
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.
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.7.0/DroidDeck-0.7.0.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.6.9/DroidDeck-0.6.9.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,8 +79,7 @@ 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.
- 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`.
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `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).
@@ -95,7 +94,6 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
- World of Warcraft addon imports replace an addon's folder from a temporary copy, as [WowUp](https://github.com/WowUp/WowUp) does, and read flavour folders the way [WoWDeck](https://github.com/sigboe/wowdeck) does. No code from either is used.
- The World of Warcraft addon Download window searches [WoWInterface](https://www.wowinterface.com) through its MMOUI API. [CurseForge](https://www.curseforge.com/wow) opens in the user's browser, with no API and no key.
- Xbox games load [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) modules (LGPL-2.1-or-later), including `wintypes.dll`. Minecraft's GameInput setup was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT).
- The Xbox Cloud Gaming player uses the WebRTC client of [@unknownskl](https://github.com/unknownskl)'s [Greenlight](https://github.com/unknownskl/greenlight) (MIT), and its offer tweaks and sharpening follow [@redphx](https://github.com/redphx)'s [Better xCloud](https://github.com/redphx/better-xcloud) (MIT). See `tools/xcloud-player/NOTICE.md`.
- The itch.io butler pack ships butler's `7z.so`, itch.io's build of [7-Zip](https://www.7-zip.org) by Igor Pavlov (LGPL-2.1, unRAR restriction on RAR code), unmodified. Other archives use Apache Commons Compress and the runtime's bsdtar (libarchive).
+2 -2
View File
@@ -131,8 +131,8 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 18
versionName "0.7.0"
versionCode 17
versionName "0.6.9"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
+16 -9
View File
@@ -91,6 +91,14 @@
</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>
@@ -187,6 +195,7 @@
</intent-filter>
</activity>
<activity
android:name=".PinRequestActivity"
android:exported="true"
@@ -199,6 +208,13 @@
</intent-filter>
</activity>
<activity
android:name=".input.ControllerEditorActivity"
android:exported="false"
android:theme="@style/SessionTheme"
android:screenOrientation="sensorLandscape"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".sync.SyncthingWebActivity"
@@ -302,7 +318,6 @@
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"
@@ -345,14 +360,6 @@
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<receiver
android:name=".agent.AgentCallReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="${applicationId}.AGENT_CALL" />
</intent-filter>
</receiver>
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
-9
View File
@@ -1,9 +0,0 @@
#!/bin/sh
# Opens a URL in Android's browser on the display the agent is being used on ($DD_BROWSER_DISPLAY).
url=$1
[ -n "$url" ] || exit 1
serial=${ADB_SERIAL:-127.0.0.1:5555}
adb connect "$serial" >/dev/null 2>&1
quoted=$(printf "%s" "$url" | sed "s/'/'\\''/g")
adb -s "$serial" shell "am start --display ${DD_BROWSER_DISPLAY:-0} -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
echo "Open this on any device: $url" >&2
-24
View File
@@ -1,24 +0,0 @@
#!/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"
+5 -9
View File
@@ -2953,19 +2953,15 @@ EXPORT DIR *opendir(const char *name) {
return dir;
}
static bool open_has_mode(int flags) {
return (flags & O_CREAT) != 0 || (flags & O_TMPFILE) == O_TMPFILE;
}
EXPORT int open(const char *pathname, int flags, ...) {
va_list va;
mode_t mode = 0;
mode_t mode;
int fd;
bool hasMode;
va_start(va, flags);
hasMode = open_has_mode(flags);
hasMode = flags & O_CREAT;
if (hasMode) {
mode = va_arg(va, mode_t);
@@ -3037,13 +3033,13 @@ EXPORT int open(const char *pathname, int flags, ...) {
EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
va_list va;
mode_t mode = 0;
mode_t mode;
int fd;
bool hasMode;
va_start(va, flags);
hasMode = open_has_mode(flags);
hasMode = flags & O_CREAT;
if (hasMode) {
mode = va_arg(va, mode_t);
@@ -4255,7 +4251,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds,
#ifdef __GLIBC__
static mode_t open_mode(int flags, va_list va) {
return open_has_mode(flags) ? va_arg(va, mode_t) : 0;
return (flags & (O_CREAT | O_TMPFILE)) ? va_arg(va, mode_t) : 0;
}
EXPORT int open64(const char *pathname, int flags, ...) {
@@ -7,11 +7,8 @@ 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)
@@ -42,9 +39,6 @@ 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)
@@ -39,7 +39,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
runCatching { ComponentUpdater.settle(activity) }
val modes = ComponentsManager.COMPONENTS.associateWith { SessionPrefs.componentMode(activity, it) }
val auto = ComponentsManager.COMPONENTS.associateWith { runCatching { ComponentUpdater.status(activity, it) }.getOrDefault(emptyList()) }
val held = if (runCatching { ComponentsManager.autoDxvkHeld(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
val held = if (runCatching { ComponentsManager.hardwareDxvk(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
ui.post {
compModes = modes
compAuto = auto
@@ -79,12 +79,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
canRestart = !sessionRunning,
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 },
tpUbwcRestartRequired = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity),
)
fun tpUbwcModeForDevice(): String? = tpUbwcMode.takeIf { gpu.model == 740 }
fun tpUbwcRestartRequired(): Boolean = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity)
fun chooseTpUbwcMode(mode: String) {
if (gpu.model != 740) return
SessionPrefs.setTpUbwcMode(activity, mode)
@@ -129,14 +127,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
)
}
var restartBlocked: () -> Boolean = { SessionState.running }
fun loadTpUbwcMode() {
tpUbwcMode = SessionPrefs.tpUbwcMode(activity)
}
fun restartApp() {
if (restartBlocked()) return
if (SessionState.running) return
val launch = activity.packageManager.getLaunchIntentForPackage(activity.packageName) ?: return
activity.startActivity(android.content.Intent.makeRestartActivityTask(launch.component)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "components"))
@@ -603,19 +595,11 @@ 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 -> DriverBundle.selectDisplay(activity, id)
else -> SessionPrefs.setAndroidDriver(activity, id)
}
pairModeOpen = false
setMode(auto = false, mix = true)
@@ -23,7 +23,6 @@ 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
@@ -64,7 +63,9 @@ import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.ConfirmDialog
import com.droiddeck.launcher.ui.ControllerActions
import com.droiddeck.launcher.ui.ControllerMappingPage
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.ControllerEditorActivity
import com.droiddeck.launcher.ui.FrontEndScreen
import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
@@ -201,7 +202,6 @@ class FrontEndActivity : ComponentActivity() {
private var downloadPrefix by mutableStateOf("")
private var downloadProxy by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var lowStorageWarning by mutableStateOf(true)
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
@@ -625,7 +625,6 @@ class FrontEndActivity : ComponentActivity() {
private var dualScreenMode by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
private var dualScreenSessions by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
private var dualScreenControls by mutableStateOf(true)
private var androidTextFieldKeyboard by mutableStateOf(false)
private var launcherStarted = false
private lateinit var displayManager: DisplayManager
private val secondScreenDisplayListener = object : DisplayManager.DisplayListener {
@@ -874,7 +873,6 @@ 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)
@@ -890,15 +888,7 @@ class FrontEndActivity : ComponentActivity() {
putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
}
}
.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) }
}, steamSession = true)
}
private fun Intent.returnTo(returnHome: Boolean): Intent {
@@ -925,17 +915,14 @@ 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, next, returnHome) -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> {
recordPlayed(next)
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 ->
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
}
}
@@ -1097,15 +1084,11 @@ class FrontEndActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
drivers.restartBlocked = ::runtimeChangesBlocked
drivers.loadTpUbwcMode()
pendingSessionLogExport = savedInstanceState?.getString(KEY_PENDING_SESSION_LOG_EXPORT)?.let(::File)
homeChoiceOpen = savedInstanceState?.getBoolean(STATE_HOME_CHOICE_OPEN) == true
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()
@@ -1127,7 +1110,6 @@ class FrontEndActivity : ComponentActivity() {
downloadProxy = DownloadSource.proxyHostPort(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
lowStorageWarning = SessionPrefs.lowStorageWarning(this)
LauncherIcon.repair(this, intent?.component)
launcherIcon = LauncherIcon.current(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
@@ -1138,10 +1120,6 @@ 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(
@@ -1178,7 +1156,6 @@ class FrontEndActivity : ComponentActivity() {
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
graphicsLogs = graphicsLogs, steamGpuDrawing = steamGpuDrawing, bridgeFellBack = bridgeFellBack, bridgeFellBackMemory = bridgeFellBackMemory,
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
tpUbwcMode = drivers.tpUbwcModeForDevice(), tpUbwcRestartRequired = drivers.tpUbwcRestartRequired(), canRestartApp = !runtimeChangesBlocked(),
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
@@ -1198,7 +1175,6 @@ class FrontEndActivity : ComponentActivity() {
dualScreenMode = dualScreenMode,
dualScreenSessions = dualScreenSessions,
dualScreenControls = dualScreenControls,
androidTextFieldKeyboard = androidTextFieldKeyboard,
packages = packageRows,
packageCatalogLoading = catalogLoading,
packageBusyId = pkgId,
@@ -1221,7 +1197,6 @@ class FrontEndActivity : ComponentActivity() {
downloadPrefix = downloadPrefix,
downloadProxy = downloadProxy,
animationsEnabled = animationsEnabled,
lowStorageWarning = lowStorageWarning,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
@@ -1425,8 +1400,6 @@ class FrontEndActivity : ComponentActivity() {
},
onClearCachedApps = { on -> SessionPrefs.setClearCachedApps(this, on); clearCachedApps = on },
onMemoryHud = { value -> SessionPrefs.setMemoryHud(this, value); memoryHud = value },
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
onRestartApp = { drivers.restartApp() },
onBackgroundAppLimit = { limit ->
SessionPrefs.setBackgroundAppLimit(this, limit); backgroundAppLimit = limit
if (limit == com.droiddeck.launcher.core.BackgroundAppLimit.OFF) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
@@ -1530,14 +1503,6 @@ class FrontEndActivity : ComponentActivity() {
SessionPrefs.setDualScreenSessionControls(this, on)
dualScreenControls = on
},
onAndroidTextFieldKeyboard = { on ->
SessionPrefs.setAndroidTextFieldKeyboard(this, on)
androidTextFieldKeyboard = on
},
onLowStorageWarning = { on ->
SessionPrefs.setLowStorageWarning(this, on)
lowStorageWarning = on
},
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
@@ -1637,7 +1602,23 @@ class FrontEndActivity : ComponentActivity() {
},
updates = updates.actions(),
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
onOpacity = { o -> ControllerPrefs.setOpacity(this, o); refreshController() },
onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() },
onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() },
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
onRumble = { on -> ControllerPrefs.setRumble(this, on); refreshController() },
onDeadZone = { v -> ControllerPrefs.setDeadZone(this, v); refreshController() },
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
page = page,
@@ -1687,8 +1668,6 @@ class FrontEndActivity : ComponentActivity() {
lowSpace?.let { free ->
com.droiddeck.launcher.ui.LowSpaceDialog(
free,
warn = lowStorageWarning,
onWarnChange = { on -> SessionPrefs.setLowStorageWarning(this, on); lowStorageWarning = on },
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
onDismiss = { lowSpace = null },
@@ -1784,9 +1763,8 @@ class FrontEndActivity : ComponentActivity() {
private fun playSteam() {
val storage = com.droiddeck.launcher.storage.StorageState
val free = storage.freeBytes(this)
if (storage.shouldWarnLowSpace(SessionPrefs.lowStorageWarning(this), storage.lowSpaceDismissed, SessionState.running, free)) {
lowSpace = free
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
lowSpace = storage.freeBytes(this)
storage.refresh(this)
} else startSteamSession()
}
@@ -2066,7 +2044,6 @@ class FrontEndActivity : ComponentActivity() {
dualScreenDevice = com.droiddeck.launcher.input.DualScreen.anyDisplay(displayManager, ownDisplayId) != null
dualScreenSessions = SessionPrefs.dualScreenSessionMode(this)
dualScreenControls = SessionPrefs.dualScreenSessionControls(this)
androidTextFieldKeyboard = SessionPrefs.androidTextFieldKeyboard(this)
val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, ownDisplayId)
dualDisplayId = target?.displayId ?: -1
syncDualPanel()
@@ -2159,7 +2136,13 @@ class FrontEndActivity : ComponentActivity() {
@Composable
private fun MappingHost() {
com.droiddeck.launcher.ui.InputSettingsPage(onBack = { showMapping = false })
val settings = controllerSettings ?: return
ControllerMappingPage(
mapping = settings.mapping,
onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() },
onReset = { ControllerPrefs.resetMapping(this); refreshController() },
onBack = { showMapping = false },
)
}
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
@@ -2243,6 +2226,7 @@ class FrontEndActivity : ComponentActivity() {
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onMode = { m -> drivers.chooseMode(m) },
onRestart = { drivers.restartApp() },
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
),
protons = com.droiddeck.launcher.ui.ProtonsState(
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
@@ -2295,14 +2279,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, next) -> {}
handToBattleNet(full) { pendingSwitch = next } -> {}
else -> pendingSwitch = next
}
}
}, "battlenet-open").start()
}
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): Boolean {
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): 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)
@@ -2313,13 +2297,10 @@ 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) 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))
}
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))
}
}, "battlenet-request").start()
return true
@@ -2457,7 +2438,6 @@ class FrontEndActivity : ComponentActivity() {
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
},
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onInputSettings = { showPerformance = false; showComponents = false; showMapping = true },
onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
File diff suppressed because it is too large. Load diff
@@ -1,44 +0,0 @@
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)
}
}
@@ -1,55 +0,0 @@
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,8 +284,6 @@ 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))
@@ -1,41 +0,0 @@
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()
}
}
@@ -1,134 +0,0 @@
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,7 +33,6 @@ 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
@@ -60,7 +59,6 @@ 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) }
@@ -1,278 +0,0 @@
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
@@ -1,487 +0,0 @@
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)
}
@@ -1,110 +0,0 @@
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()
}
}
}
@@ -1,105 +0,0 @@
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
}
@@ -1,149 +0,0 @@
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)
}
}
}
@@ -1,95 +0,0 @@
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
}
})
}
}
@@ -1,53 +0,0 @@
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()
}
}
@@ -58,7 +58,7 @@ public final class Downloader {
private static void closeAsync(InputStream active) {
CANCEL_EXECUTOR.execute(() -> {
try { active.close(); } catch (IOException | IllegalStateException ignored) { }
try { active.close(); } catch (IOException ignored) { }
});
}
@@ -12,9 +12,6 @@ object Hashes {
@JvmStatic
fun sha512(file: File): String = hex(file, "SHA-512")
@JvmStatic
fun md5(file: File): String = hex(file, "MD5")
@JvmStatic
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
@@ -90,8 +90,7 @@ object StorageRoots {
.filter { present(it.state) }
.forEachIndexed { index, volume ->
val uuid = volume.uuid?.takeIf { it.isNotBlank() }
val directory = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) volume.directory else null
val dirName = directory?.name?.takeIf { it.isNotBlank() }
val dirName = volume.directory?.name?.takeIf { it.isNotBlank() }
val descSlug = volume.getDescription(context)?.trim()
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
@@ -99,7 +98,9 @@ object StorageRoots {
entry.removable = !volume.isPrimary && volume.isRemovable
if (entry.label == null) entry.label = labelFor(context, volume, uuid ?: dirName ?: descSlug)
directory?.let { entry.candidates += it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
volume.directory?.let { entry.candidates += it }
}
val name = uuid ?: dirName
if (name != null) {
entry.candidates += File("/storage/$name")
@@ -18,10 +18,9 @@ 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, installedAt)
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
@@ -32,7 +31,6 @@ 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"
@@ -223,18 +221,6 @@ 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()
@@ -263,13 +249,12 @@ 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 = maxOf(lastPlayed, recordedPlay(context, g.gameId)), gameFiles = g.folder,
hero = art.hero ?: art.header, lastPlayed = lastPlayed, 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,7 +27,6 @@ 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) }
@@ -45,7 +44,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).put("installedAt", g.installedAt).apply { g.storeId?.let { put("storeId", it) } },
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
)
val f = file(context)
val tmp = File(f.path + ".tmp")
@@ -27,11 +27,12 @@ object AutoPicks {
}
val BUILT_IN = listOf(
Rule(setOf(Family.A8XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A7XX_LOW), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_710)),
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_ONEUI)),
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)),
Rule(setOf(Family.A7XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
Rule(setOf(Family.A6XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
Rule(setOf(Family.A6XX), prefer = listOf(DriverPairs.BANNER)),
Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)),
Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)),
)
@@ -159,12 +159,6 @@ 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,7 +191,6 @@ 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);
}
@@ -97,7 +97,6 @@ object TurnipReleases {
// Venus): its driver pairs fall past the first 15, so it is read in one full page.
val perPage = if (src.repo == NON_ADRENO_REPO) 100 else 15
val releases = JSONArray(get(context, "https://api.github.com/repos/${src.repo}/releases?per_page=$perPage"))
val found = JSONArray()
val seen = HashSet<String>()
var newest: String? = null
for (i in 0 until releases.length()) {
@@ -115,13 +114,12 @@ object TurnipReleases {
val label = kind.label
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
found.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
.put("bundle", kind.bundle).put("id", a.optLong("id")).put("updated_at", a.optString("updated_at")))
}
}
for (i in 0 until found.length()) assets.put(found.get(i))
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
} catch (e: Exception) {
failed.add(src.label)
@@ -129,8 +127,7 @@ object TurnipReleases {
}
}
if (failed.size == sources.size) throw lastError ?: IOException("no source answered")
carryOver(failed, prefs(context).getString(KEY_RELEASE, null)?.let { runCatching { JSONObject(it) }.getOrNull() }, assets, latest)
runCatching { fetch(context, AutoPicks.URL, AutoPicks.URL) }
runCatching { get(context, AutoPicks.URL, AutoPicks.URL) }
.onFailure { Log.i(TAG, "auto picks: ${it.message}") }
.getOrNull()?.takeIf { AutoPicks.parse(it) != null }
?.let { prefs(context).edit().putString(KEY_PICKS, it).apply() }
@@ -140,22 +137,12 @@ object TurnipReleases {
return parse(stored)
}
internal var fetch: (Context, String, String) -> String = { context, source, url -> get(context, source, url) }
internal fun carryOver(failed: List<String>, previous: JSONObject?, assets: JSONArray, latest: JSONArray) {
if (previous == null || failed.isEmpty()) return
for ((key, into) in listOf("assets" to assets, "latest" to latest)) {
val old = previous.optJSONArray(key) ?: continue
for (i in 0 until old.length()) old.optJSONObject(i)?.takeIf { it.optString("source") in failed }?.let(into::put)
}
}
private fun get(context: Context, url: String): String {
val sources = DownloadSource.candidates(url)
var last: IOException? = null
for ((index, source) in sources.withIndex()) {
try {
val body = fetch(context, source, url)
val body = get(context, source, url)
DownloadSource.succeeded(source)
return body
} catch (e: IOException) {
@@ -534,6 +534,9 @@ class DroidDeckHudView(context: Context) : View(context) {
private val inkRect = android.graphics.Rect()
private fun rebuild() {
val renderingLayer = if (size == DroidDeckHudSize.STRIP && dock == DroidDeckHudDock.NONE)
LAYER_TYPE_SOFTWARE else LAYER_TYPE_NONE
if (layerType != renderingLayer) setLayerType(renderingLayer, null)
glyphs.clear(); tileRects.clear(); graphRect = null
headerMark = null; headerWordmark = null; chipRect = null; dPanel = null; dDividerTop = -1f; tabRect = null; stripRect = null
val s = snap
@@ -1350,15 +1353,10 @@ class DroidDeckHudView(context: Context) : View(context) {
canvas.drawText(g.text, g.x, g.baseline, paint)
if (g.glow) paint.clearShadowLayer()
}
headerMark?.let {
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedMark(canvas, it)
else DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent)
}
headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
headerWordmark?.let { WordmarkArt.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
graphRect?.let {
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedGraph(canvas, it) else drawGraph(canvas, it)
}
graphRect?.let { drawGraph(canvas, it) }
lockController.drawBadge(canvas)
}
@@ -1383,41 +1381,6 @@ class DroidDeckHudView(context: Context) : View(context) {
canvas.drawPath(graphPath, graphPaint)
}
private var cachedGraphBitmap: Bitmap? = null
private var cachedGraphCanvas: Canvas? = null
private val cachedGraphRect = RectF()
private var cachedMarkBitmap: Bitmap? = null
private var cachedMarkKey = ""
private fun drawCachedGraph(canvas: Canvas, rect: RectF) {
val w = rect.width().roundToInt().coerceAtLeast(1)
val h = rect.height().roundToInt().coerceAtLeast(1)
var bitmap = cachedGraphBitmap
if (bitmap == null || bitmap.width != w || bitmap.height != h) {
bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
cachedGraphBitmap = bitmap
cachedGraphCanvas = Canvas(bitmap)
cachedGraphRect.set(0f, 0f, w.toFloat(), h.toFloat())
}
bitmap.eraseColor(Color.TRANSPARENT)
drawGraph(cachedGraphCanvas!!, cachedGraphRect)
canvas.drawBitmap(bitmap, rect.left, rect.top, null)
}
private fun drawCachedMark(canvas: Canvas, rect: RectF) {
val w = rect.width().roundToInt().coerceAtLeast(1)
val h = rect.height().roundToInt().coerceAtLeast(1)
val key = "$w,$h,${blend(colValue)},$accent"
var bitmap = cachedMarkBitmap
if (bitmap == null || key != cachedMarkKey) {
bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
DroidDeckMark.draw(Canvas(bitmap), 0f, 0f, h.toFloat(), blend(colValue), accent)
cachedMarkBitmap = bitmap
cachedMarkKey = key
}
canvas.drawBitmap(bitmap, rect.left, rect.top, null)
}
private fun drawStrip(canvas: Canvas, rect: RectF) {
val p = parent as? View
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
@@ -6,7 +6,6 @@ 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
@@ -39,7 +38,6 @@ 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) {
@@ -70,7 +68,6 @@ internal class FrameStats {
fun reset() {
graph.fill(0f)
graphHead = 0
graphFrames = 0L
lastPresentNs = 0L
lastFpsUpdateNs = 0L
framesSinceUpdate = 0
@@ -89,9 +86,8 @@ internal class FrameStats {
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
@Synchronized
fun graph(out: FloatArray): Long {
fun graph(out: FloatArray) {
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
return graphFrames
}
@Synchronized
@@ -9,7 +9,6 @@ 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
@@ -34,8 +33,6 @@ 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 ||
@@ -46,7 +43,7 @@ object BottomScreenGuests {
}
class BottomScreenWatch(private val service: AccessibilityService) {
private class Arrival(val pkg: String, val at: Long)
private class Arrival(val pkg: String)
private val thread = HandlerThread("bottom-screen").apply { start() }
private val handler = Handler(thread.looper)
@@ -55,8 +52,6 @@ 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() {
@@ -77,7 +72,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, event.eventTime)
arrivals[event.displayId] = Arrival(pkg)
}
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
else -> return
@@ -102,25 +97,19 @@ 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 }
.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) }
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
val arrival = seen[id]
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
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
@@ -0,0 +1,136 @@
package com.droiddeck.launcher.input
import com.droiddeck.launcher.ui.focusGlide
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.onSignal
import com.droiddeck.launcher.ui.LocalPalette
class ControllerEditorActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private lateinit var controls: OnScreenControls
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
immersive()
val root = FrameLayout(this)
root.setBackgroundColor(0xFF0B0F14.toInt())
controls = OnScreenControls(this, null, editing = true)
root.addView(controls, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
val bar = ComposeView(this).apply {
setContent {
DroidDeckTheme {
var snap by remember { mutableStateOf(ControllerPrefs.read(context).snap) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(20.dp))
.padding(start = 5.dp, end = 5.dp, top = 8.dp, bottom = 5.dp),
) {
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
EditorButton(stringResource(R.string.ctrl_editor_snap), false, selected = snap) {
snap = !snap
ControllerPrefs.setSnap(context, snap)
controls.setSnap(snap)
}
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
controls.saveLayout()
finish()
}
}
}
}
}
}
root.addView(bar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
setContentView(root)
}
@Composable
private fun EditorButton(label: String, primary: Boolean, selected: Boolean? = null, onClick: () -> Unit) {
val pal = LocalPalette.current
Text(
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = when {
primary -> pal.onSignal
selected == true -> pal.signal
else -> MaterialTheme.colorScheme.onBackground
},
modifier = Modifier.clip(RoundedCornerShape(50))
.background(
when {
primary -> pal.signal
selected == true -> pal.signal.copy(alpha = 0.18f)
else -> Color.White.copy(alpha = 0.06f)
},
)
.focusGlide(RoundedCornerShape(50))
.then(
if (selected == null) Modifier.clickable(onClick = onClick)
else Modifier.toggleable(value = selected, role = Role.Switch, onValueChange = { onClick() }),
)
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) immersive()
}
private fun immersive() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
if (Build.VERSION.SDK_INT >= 28) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
}
@@ -73,7 +73,7 @@ object ControllerPrefs {
keyboardButton = p.getBoolean("keyboardButton", true),
selectSteam = p.getBoolean("selectSteam", false),
joyConVr = joyConVr(context),
snap = snap(context),
snap = p.getBoolean("snap", true),
customLayout = p.all.keys.any { it.startsWith("layout.") },
mapping = mappable.associate { (id, _) -> id to target(context, id) },
deadZone = deadZone(context),
@@ -87,6 +87,11 @@ 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()
}
@@ -124,6 +129,10 @@ 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()
}
@@ -138,8 +147,6 @@ 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()
}
@@ -162,7 +169,62 @@ object ControllerPrefs {
prefs(context).edit().remove("layout.${width}x$height").apply()
}
fun screenKeysShown(context: Context): Boolean = prefs(context).getBoolean("screenKeysShown", true)
fun setScreenKeysShown(context: Context, shown: Boolean) {
prefs(context).edit().putBoolean("screenKeysShown", shown).apply()
}
fun screenKeys(context: Context): List<ScreenKey> = decodeScreenKeys(prefs(context).getString("screenKeys", null).orEmpty())
fun setScreenKeys(context: Context, keys: List<ScreenKey>) {
prefs(context).edit().putString("screenKeys", encodeScreenKeys(keys)).apply()
}
fun encodeScreenKeys(keys: List<ScreenKey>): String = keys.joinToString(";") {
"${it.codes.joinToString("+")}:${it.x},${it.y},${it.size}:${escape(it.glyph)}:${escape(it.text)}:${escape(it.image)}:${it.opacity}:${it.shape}:${if (it.toggle) 1 else 0}:${if (it.labels) 1 else 0}"
}
fun decodeScreenKeys(raw: String): List<ScreenKey> = raw.split(';').mapNotNull { entry ->
val parts = entry.split(':')
if (parts.size < 2) return@mapNotNull null
val keys = parts[0].split('+').mapNotNull { it.toIntOrNull() }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val nums = parts[1].split(',').mapNotNull { it.toFloatOrNull() }.takeIf { it.size == 3 } ?: return@mapNotNull null
ScreenKey(
keys, nums[0], nums[1], nums[2].toInt(),
parts.getOrNull(2)?.let(::unescape).orEmpty(),
parts.getOrNull(3)?.let(::unescape).orEmpty(),
parts.getOrNull(4)?.let(::unescape).orEmpty(),
parts.getOrNull(5)?.toFloatOrNull()?.coerceIn(0.2f, 1f) ?: 1f,
parts.getOrNull(6)?.toIntOrNull()?.coerceIn(SCREEN_KEY_PILL, SCREEN_KEY_STICK) ?: SCREEN_KEY_PILL,
parts.getOrNull(7) == "1",
parts.getOrNull(8) != "0",
)
}
fun screenLayouts(context: Context): Map<String, String> =
prefs(context).all.mapNotNull { (key, value) ->
if (!key.startsWith("screenLayout.") || value !is String) null else key.removePrefix("screenLayout.") to value
}.toMap()
fun saveScreenLayout(context: Context, name: String, raw: String) {
prefs(context).edit().putString("screenLayout.$name", raw).apply()
}
fun deleteScreenLayout(context: Context, name: String) {
prefs(context).edit().remove("screenLayout.$name").apply()
}
private fun escape(value: String) = value.replace("%", "%25").replace(";", "%3B").replace(":", "%3A").replace(",", "%2C")
private fun unescape(value: String) = value.replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
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()
}
}
@@ -5,165 +5,53 @@ object DeckControls {
const val R4 = 2
const val L5 = 4
const val R5 = 8
private val controls = arrayOf(InputControl.L4, InputControl.R4, InputControl.L5, InputControl.R5,
InputControl.LEFT_PAD, InputControl.RIGHT_PAD, InputControl.LEFT_CLICK, InputControl.RIGHT_CLICK)
private val sources = ArrayList<Source>()
private val physical = Source(InputRole.PHYSICAL)
private var router: InputRouter? = null
private const val LEFT_TOUCH = 16
private const val RIGHT_TOUCH = 32
private const val LEFT_CLICK = 64
private const val RIGHT_CLICK = 128
private const val SLOT = 0
private var controls = 0
private val pads = ShortArray(4)
private val pressure = ShortArray(2)
private val touchOwners = arrayOfNulls<Source>(2)
@Synchronized
fun setRouter(next: InputRouter?) {
if (router === next) return
for (source in sources) source.closeRoutes()
router = next
for (source in sources) source.configure()
fun setGrip(bit: Int, down: Boolean) {
set(bit, down)
publish()
}
@Synchronized
fun clearRouter(owner: InputRouter?) {
if (owner != null && router === owner) setRouter(null)
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
val at = if (right) 2 else 0
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
pads[at] = if (touching) axis(x) else 0
pads[at + 1] = if (touching) axis(y) else 0
publish()
}
@Synchronized
@JvmOverloads
fun source(role: InputRole, routed: Boolean = false) = Source(role, routed)
@Synchronized
fun setGrip(bit: Int, down: Boolean) = physical.setGrip(bit, down)
@Synchronized
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = physical.setPad(right, touching, x, y)
@Synchronized
fun setClick(right: Boolean, down: Boolean) = physical.setClick(right, down)
fun setClick(right: Boolean, down: Boolean) {
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
publish()
}
@Synchronized
fun releaseAll() {
for (source in sources) source.clear()
publish()
}
@Synchronized
fun suspend() {
for (source in sources) source.suspend()
publish()
}
@Synchronized
fun refresh() {
for (source in sources) source.route()
publish()
}
class Source internal constructor(private val role: InputRole, private val routed: Boolean = false) {
private val x = FloatArray(8)
private val y = FloatArray(8)
private val touched = BooleanArray(8)
private var routes: Array<InputRouter.Channel>? = null
internal var mask = 0
internal val axes = ShortArray(4)
internal fun configure() {
routes = router?.takeUnless { routed }?.let { r -> Array(8) { i ->
r.channel(role, controls[i]).also { it.awaitNeutral(x[i], y[i], touched[i]) }
} }
mask = 0
axes.fill(0)
}
private fun attach() {
if (this !in sources) { sources.add(this); configure() }
}
fun setGrip(bit: Int, down: Boolean) = synchronized(DeckControls) {
val at = when (bit) { L4 -> 0; R4 -> 1; L5 -> 2; R5 -> 3; else -> return@synchronized }
update(at, if (down) 1f else 0f)
}
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = synchronized(DeckControls) {
update(if (right) 5 else 4, if (touching) x else 0f, if (touching) y else 0f, touching)
}
fun setClick(right: Boolean, down: Boolean) = synchronized(DeckControls) {
update(if (right) 7 else 6, if (down) 1f else 0f)
}
private fun update(at: Int, value: Float, second: Float = 0f, contact: Boolean = false) {
val timing = InputTiming.begin()
attach()
x[at] = value; y[at] = second; touched[at] = contact
route()
publish()
InputTiming.end(timing)
}
internal fun native(control: InputControl?, value: Float, second: Float, contact: Boolean) = synchronized(DeckControls) {
require(routed)
attach()
x.fill(0f); y.fill(0f); touched.fill(false)
val at = controls.indexOf(control)
if (at >= 0) { x[at] = value; y[at] = second; touched[at] = contact }
route(); publish()
}
internal fun route() {
mask = 0
axes.fill(0)
for (i in controls.indices) {
val target = if (routes == null) controls[i] else routes!![i].update(x[i], y[i], touched[i]) ?: continue
val at = controls.indexOf(target)
if (at < 0) continue
if (at == 4 || at == 5) {
if (touched[i]) {
mask = mask or (1 shl at)
axes[(at - 4) * 2] = axis(x[i]); axes[(at - 4) * 2 + 1] = axis(y[i])
}
} else if (x[i] > 0.5f) mask = mask or (1 shl at)
}
}
internal fun clear() {
x.fill(0f); y.fill(0f); touched.fill(false); mask = 0; axes.fill(0)
routes?.forEach { it.release() }
}
internal fun suspend() {
mask = 0; axes.fill(0)
routes?.forEach { it.suspend() }
}
fun release() = synchronized(DeckControls) { clear(); publish() }
internal fun closeRoutes() { routes?.forEach { it.close() }; routes = null }
fun close() = synchronized(DeckControls) {
clear(); closeRoutes(); sources.remove(this); publish()
}
}
private fun axis(value: Float) = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
private fun publish() {
var mask = 0
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
controls = 0
pads.fill(0)
for (source in sources) {
mask = mask or source.mask
}
for (side in 0..1) {
val contact = 16 shl side
val owner = touchOwners[side]?.takeIf { it.mask and contact != 0 }
?: sources.firstOrNull { it.mask and contact != 0 }
touchOwners[side] = owner
if (owner == null) continue
pads[side * 2] = owner.axes[side * 2]
pads[side * 2 + 1] = owner.axes[side * 2 + 1]
}
pressure[0] = if (mask and 64 != 0) Short.MAX_VALUE else 0
pressure[1] = if (mask and 128 != 0) Short.MAX_VALUE else 0
FakeInputWriter.writeDeckControls(0, mask, pads, pressure)
pressure.fill(0)
publish()
}
private fun set(bit: Int, on: Boolean) {
controls = if (on) controls or bit else controls and bit.inv()
}
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
}
@@ -70,14 +70,6 @@ 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) {
@@ -16,7 +16,6 @@ object DualScreen {
const val MODE_OFF = "off"
val modes = listOf(MODE_AUTO, MODE_ON, MODE_OFF)
private const val TYPE_INTERNAL = 1
private const val TYPE_EXTERNAL = 2
private val knownModels = setOf("retroid pocket duo", "ayn thor")
private val touchedDisplays = mutableSetOf<Int>()
private val takenListeners = java.util.concurrent.CopyOnWriteArraySet<() -> Unit>()
@@ -86,22 +85,9 @@ object DualScreen {
when (mode) {
MODE_OFF -> null
MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId)
else -> if (dockedToDisplay(displayManager, primaryDisplayId)) null else builtInDisplay(displayManager, primaryDisplayId)
else -> builtInDisplay(displayManager, primaryDisplayId)
}?.takeUnless { taken(it.displayId) }
fun leavesBottomOnDock(wasDocked: Boolean, docked: Boolean, mode: String, automaticOnBuiltIn: Boolean): Boolean =
docked && !wasDocked && mode == MODE_AUTO && automaticOnBuiltIn
fun dockedToDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Boolean {
val glasses = com.droiddeck.launcher.glasses.Glasses.state.value
val external = displayManager.getDisplays().filter {
it.isValid && it.displayId != primaryDisplayId && it.state != Display.STATE_OFF && displayType(it) == TYPE_EXTERNAL
}
val glassesId = glasses.displayId.takeIf { it != Display.INVALID_DISPLAY }
?: com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(external, glasses.present)?.displayId
return external.any { it.displayId != glassesId && com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(listOf(it), false) == null }
}
fun anyDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? =
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull {
it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.input
import android.view.MotionEvent
internal class GestureTouchStreamGate {
enum class Source { SURFACE, ON_SCREEN_CONTROLS }
enum class Source { SURFACE, SCREEN_KEYS, ON_SCREEN_CONTROLS }
private var owner: Source? = null
@@ -1,27 +0,0 @@
package com.droiddeck.launcher.input
class HeldInput(private val emit: (Int, Boolean) -> Unit) {
private val owners = IntArray(768)
@Synchronized
fun set(source: Int, code: Int, down: Boolean) {
if (code !in owners.indices) return
val before = owners[code]
val after = if (down) before or source else before and source.inv()
owners[code] = after
if ((before == 0) != (after == 0)) emit(code, after != 0)
}
@Synchronized
fun release(source: Int) {
for (code in owners.indices) if (owners[code] and source != 0) set(source, code, false)
}
@Synchronized
fun clear() {
for (code in owners.indices) if (owners[code] != 0) {
owners[code] = 0
emit(code, false)
}
}
}
@@ -1,43 +0,0 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import java.io.File
import java.util.UUID
internal object InputButtonImages {
const val DIR = "screenkeys"
private const val MAX_SIZE_DP = 140
fun file(context: Context, name: String): File? =
name.takeIf { it.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && it != "." && it != ".." }?.let { File(File(context.filesDir, DIR), it) }
fun import(context: Context, uri: Uri): String? {
val dir = File(context.filesDir, DIR).apply { mkdirs() }
val name = "key_" + UUID.randomUUID() + ".png"
val longest = (MAX_SIZE_DP * context.resources.displayMetrics.density).toInt()
return runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
val source = maxOf(bounds.outWidth, bounds.outHeight)
if (source <= 0) return@runCatching null
var sample = 1
while (source / (sample * 2) >= longest) sample *= 2
val bitmap = context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
} ?: return@runCatching null
val scale = longest.toFloat() / maxOf(bitmap.width, bitmap.height)
val scaled = if (scale < 1f) Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt().coerceAtLeast(1),
(bitmap.height * scale).toInt().coerceAtLeast(1), true) else bitmap
try {
val saved = File(dir, name).outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }
if (saved) name else null
} finally {
if (scaled !== bitmap) bitmap.recycle()
scaled.recycle()
}
}.getOrNull().also { if (it == null) File(dir, name).delete() }
}
}
@@ -1,123 +0,0 @@
package com.droiddeck.launcher.input
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.roundToInt
internal object InputLayoutFile {
private val NAMES: Map<Int, String> = mapOf(
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
21 to "KEY_Y", 44 to "KEY_Z",
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
)
private val CODES: Map<String, Int> = NAMES.entries.associate { (code, name) -> name to code }
fun fileName(layout: SavedInputLayout) = layout.name.replace(Regex("[^A-Za-z0-9 _.-]"), "_").trim().ifEmpty { "droiddeck-layout" } + ".icp"
fun export(layout: SavedInputLayout, bindings: SavedInputBindings?, width: Int, height: Int): String? {
val snap = width.coerceAtLeast(1) / 100f
val elements = JSONArray()
for (e in layout.elements) {
val action = bindings?.action(e) ?: InputAction.Disabled
val directional = e.kind == InputElementKind.DPAD || e.kind == InputElementKind.STICK
val codes = when {
directional && action is InputAction.Directions -> listOf(action.up, action.right, action.down, action.left)
!directional && action is InputAction.Keys -> action.codes
else -> continue
}
val binds = JSONArray()
for (code in codes) {
val name = NAMES[code]
if (name != null) binds.put(name) else if (directional) binds.put("")
}
if ((0 until binds.length()).all { binds.getString(it).isEmpty() }) continue
val shape = when {
directional -> "ROUND_RECT"
e.buttonShape == InputButtonShape.CIRCLE -> "CIRCLE"
e.buttonShape == InputButtonShape.RECTANGLE -> "RECT"
else -> "ROUND_RECT"
}
val sizePx = e.height * height
val scale = when {
directional -> sizePx * 2.3f / (14f * snap)
shape == "CIRCLE" -> sizePx / (6f * snap)
else -> sizePx / (4f * snap)
}
elements.put(JSONObject()
.put("type", when (e.kind) { InputElementKind.DPAD -> "D_PAD"; InputElementKind.STICK -> "STICK"; else -> "BUTTON" })
.put("shape", shape).put("customColor", 0).put("bindings", binds).put("scale", scale.toDouble())
.put("x", (e.x + e.width / 2).toDouble()).put("y", (e.y + e.height / 2).toDouble())
.put("toggleSwitch", e.toggle).put("swipeable", false)
.put("text", if (e.icon != 0 || e.image.isNotEmpty()) "" else e.name).put("iconId", 0)
.apply { if (e.opacity < 1f) put("opacity", e.opacity.toDouble()) })
}
if (elements.length() == 0) return null
return JSONObject().put("id", 0).put("name", layout.name).put("cursorSpeed", 1.5).put("elements", elements).toString()
}
fun parse(raw: String, width: Int, density: Float): List<LegacyScreenKey>? {
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val elements = root.optJSONArray("elements") ?: return null
val snap = width.coerceAtLeast(1) / 100f
val keys = mutableListOf<LegacyScreenKey>()
for (i in 0 until elements.length()) {
val element = elements.optJSONObject(i) ?: continue
val type = element.optString("type", "BUTTON")
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
val binds = element.optJSONArray("bindings") ?: continue
val raw = (0 until binds.length()).map { CODES[binds.optString(it)] ?: 0 }
val directional = type != "BUTTON"
val codes = when {
!directional -> raw.filter { it != 0 }.distinct().take(8)
type == "STICK" && raw.size <= 4 -> listOf(raw.getOrElse(0) { 0 }, 0, raw.getOrElse(1) { 0 }, 0, raw.getOrElse(2) { 0 }, 0, raw.getOrElse(3) { 0 }, 0)
else -> raw.take(if (type == "STICK") 8 else 4)
}
if (codes.none { it != 0 }) continue
val shape = when {
type == "D_PAD" -> 5
type == "STICK" -> 6
else -> when (element.optString("shape")) { "CIRCLE" -> 2; "RECT" -> 3; "SQUARE" -> 1; else -> 0 }
}
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it.isFinite() && it > 0f } ?: 1f
val sizePx = when {
directional -> 14f * snap * scale / 2.3f
shape == 2 -> 6f * snap * scale
else -> 4f * snap * scale
}
fun coordinate(name: String) = element.optDouble(name, .5).toFloat().takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: .5f
keys += LegacyScreenKey(codes, coordinate("x"), coordinate("y"), (sizePx / density).roundToInt().coerceIn(24, 140).toFloat(),
text = element.optString("text").trim().take(24),
opacity = element.optDouble("opacity", 1.0).toFloat().takeIf { it.isFinite() }?.coerceIn(.2f, 1f) ?: 1f,
shape = shape, toggle = element.optBoolean("toggleSwitch"))
if (keys.size == 64) break
}
return keys
}
fun import(context: Context, raw: String, fallbackName: String, screen: InputLayoutScreen, style: InputSurfaceStyle,
width: Int, height: Int, density: Float, id: String = newInputId()): Pair<SavedInputLayout, SavedInputBindings>? {
val keys = parse(raw, width, density)?.takeIf { it.isNotEmpty() } ?: return null
val converted = runCatching { LegacyScreenKeyMigration.convert(context, keys, width, height, density) }.getOrNull() ?: return null
val name = runCatching { JSONObject(raw).optString("name") }.getOrNull().orEmpty().trim().take(80).ifEmpty { fallbackName.trim().take(80) }
val layout = SavedInputLayout(id, name, converted.map { it.first }, style = style, screen = screen,
aspect = (width.coerceAtLeast(1).toFloat() / height.coerceAtLeast(1)).coerceIn(.05f, 20f))
return layout to SavedInputBindings(newInputId(), name, false, id, elements = converted.associate { it.first.id to it.second })
}
}
@@ -1,395 +0,0 @@
package com.droiddeck.launcher.input
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 = 14
private const val KEY = "library"
private const val RECOVERY = "library-recovery."
const val OVERLAY = "builtin.overlay"
const val DECK = "builtin.deck"
const val TRACKPAD = "builtin.trackpad"
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
@Synchronized
fun read(context: Context): InputLibrary {
val p = prefs(context)
val raw = p.getString(KEY, null)
val decoded = raw?.let(::decode)
if (raw != null && decoded == null) return scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, migrate(context))))
var library = decoded ?: scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, migrate(context))))
val version = raw?.let { JSONObject(it).getInt("version") } ?: 0
if (version < 5) library = scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, library)))
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) {
val migrated = runCatching { LegacyScreenKeyMigration.migrate(context, library, legacy) }.getOrElse {
android.util.Log.e("InputLibraries", "Could not migrate screen keys; keeping the original settings", it)
return library
}
val merged = mergeScreenKeys(migrated, legacy)
val upgraded = merged ?: migrated
val encoded = encode(upgraded)
require(encoded.length <= 2_000_000)
val edit = p.edit().putString(KEY, encoded).putString(LegacyScreenKeyMigration.BACKUP, legacy)
if (merged != null) edit.putBoolean(LegacyScreenKeyMigration.MERGED, true)
if (!edit.commit()) return library
LegacyScreenKeyMigration.clean(context)
return upgraded
}
}
val backup = p.getString(LegacyScreenKeyMigration.BACKUP, null)
if (backup != null && !p.contains(LegacyScreenKeyMigration.MERGED)) {
val merged = mergeScreenKeys(library, backup)
if (merged != null) {
val encoded = encode(merged)
if (encoded.length <= 2_000_000 && p.edit().putString(KEY, encoded).putBoolean(LegacyScreenKeyMigration.MERGED, true).commit()) library = merged
}
}
if (raw == null || version < VERSION) write(context, library)
if (p.contains(LegacyScreenKeyMigration.BACKUP)) LegacyScreenKeyMigration.clean(context)
return library
}
private fun mergeScreenKeys(library: InputLibrary, legacy: String): InputLibrary? = runCatching { LegacyScreenKeyMigration.merge(library, legacy) }
.onFailure { android.util.Log.e("InputLibraries", "Could not show the imported screen keys with the gamepad", it) }.getOrNull()
fun hasPendingScreenKeyMigration(context: Context) = !prefs(context).contains(LegacyScreenKeyMigration.BACKUP) && LegacyScreenKeyMigration.snapshot(context) != null
fun hasLibraryRecovery(context: Context) = prefs(context).all.keys.any { it.startsWith(RECOVERY) }
@Synchronized
fun update(context: Context, change: (InputLibrary) -> InputLibrary): InputLibrary {
val p = prefs(context)
val stored = p.getString(KEY, null)
val invalid = stored != null && decode(stored) == null
require(!invalid || runCatching { JSONObject(stored!!).getInt("version") }.getOrNull()?.let { it <= VERSION } != false) {
"The saved input library cannot be edited by this version."
}
val updated = change(read(context)).validate()
if (invalid) {
val encoded = encode(updated)
require(encoded.length <= 2_000_000) { "The input library is too large to save." }
check(p.edit().putString(RECOVERY + UUID.nameUUIDFromBytes(stored!!.toByteArray(Charsets.UTF_8)), stored)
.putString(KEY, encoded).commit()) { "Could not save the input library and its recovery copy." }
} else write(context, updated)
return updated
}
private fun write(context: Context, library: InputLibrary) {
val encoded = encode(library.validate())
require(encoded.length <= 2_000_000) { "The input library is too large to save." }
prefs(context).edit().putString(KEY, encoded).apply()
}
private fun migrate(context: Context): InputLibrary {
val profiles = mutableListOf<SavedInputProfile>()
val stock = InputStockLayouts.layouts()
val layouts = (listOf(SavedInputLayout(OVERLAY, "Built-in gamepad", template = InputLayoutTemplate.OVERLAY)) + stock).toMutableList()
val bindings = InputStockLayouts.presets(stock).toMutableList()
for (id in listOf(InputProfile.GAME, InputProfile.DESKTOP)) {
val old = InputProfiles.readLegacy(context, id)
val name = if (id == InputProfile.GAME) "Game" else "Desktop"
val legacySurface = InputSurfaceLayout.hasLegacy(context, id) ||
old.bindings[InputRole.SECONDARY] != InputProfile.defaults(id).bindings[InputRole.SECONDARY]
val layout = if (legacySurface) SavedInputLayout("$id.layout", "$name touch", InputSurfaceLayout.readLegacy(context, id)).also { layouts += it }
else stock.first { it.id == if (id == InputProfile.DESKTOP) TRACKPAD else DECK }
val parts = InputRole.entries.associateWith { role ->
if (role == InputRole.SECONDARY && !legacySurface) {
val preset = bindings.first { it.layoutId == layout.id }
return@associateWith InputPart(preset.id, layout.id)
}
val preset = SavedInputBindings("$id.${role.name.lowercase()}", "$name / ${role.name.lowercase()}",
role == InputRole.PHYSICAL, if (role == InputRole.SECONDARY) layout.id else null,
old.bindings[role].orEmpty(), keys = if (role == InputRole.PHYSICAL) old.keys else emptyList())
bindings += preset
InputPart(preset.id, when (role) { InputRole.PHYSICAL -> null; InputRole.PRIMARY -> OVERLAY; InputRole.SECONDARY -> layout.id })
}
profiles += SavedInputProfile(id, name, id, parts)
}
val selected = prefs(context).getString("selected", InputProfiles.AUTO)
?.takeIf { it == InputProfiles.AUTO || profiles.any { p -> p.id == it } } ?: InputProfiles.AUTO
return InputLibrary(profiles, layouts, bindings, selected).validate()
}
fun setAdaptiveSticks(context: Context, layoutId: String, on: Boolean): Boolean = runCatching {
update(context) { library ->
library.copy(layouts = library.layouts.map { layout ->
if (layout.id != layoutId || !layout.editable) layout
else layout.copy(elements = layout.elements.map { if (it.kind == InputElementKind.STICK) it.copy(floating = on, hideWhenIdle = on) else it })
})
}
}.onSuccess { ControllerPrefs.setAdaptiveSticks(context, on) }.isSuccess
fun setQuickButton(context: Context, layoutId: String, button: InputCommand, shown: Boolean): Boolean = runCatching {
update(context) { library -> library.layouts.firstOrNull { it.id == layoutId && it.editable }?.let { library.withLayout(it.withQuickButton(button, shown)) } ?: library }
}.isSuccess
fun defaultPositions(context: Context, library: InputLibrary, layout: SavedInputLayout): List<InputSurfaceElement>? {
val origin = layout.origin ?: layout.id
InputStockLayouts.layouts().firstOrNull { it.id == origin }?.let { return it.elements }
if (origin == OVERLAY || origin.startsWith("migrated.layout.")) {
val (surfaceContext, metrics) = primaryDisplay(context)
val size = if (origin == OVERLAY) listOf(metrics.widthPixels, metrics.heightPixels)
else origin.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() }
return size.takeIf { it.size == 2 && it.all { side -> side > 0 } }?.let { LegacyGamepadLayout(surfaceContext, it[0], it[1]).elements() }
}
return library.layouts.firstOrNull { it.id == origin && it.id != layout.id }?.elements
}
@Suppress("DEPRECATION")
internal fun primaryDisplay(context: Context): Pair<Context, DisplayMetrics> {
val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
val surfaceContext = display?.let(context::createDisplayContext) ?: context
return surfaceContext to DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) }
}
private fun restyleGamepads(context: Context, library: InputLibrary): InputLibrary {
if (library.layouts.none { it.id == OVERLAY || it.id.startsWith("migrated.layout.") }) return library
val (surfaceContext, metrics) = primaryDisplay(context)
return library.copy(layouts = library.layouts.map { layout ->
val size = when {
layout.id == OVERLAY -> listOf(metrics.widthPixels, metrics.heightPixels)
layout.id.startsWith("migrated.layout.") -> layout.id.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() }
else -> emptyList()
}
if (size.size != 2 || size.any { it <= 0 }) layout
else layout.copy(elements = LegacyGamepadLayout(surfaceContext, size[0], size[1]).restyle(layout.elements))
})
}
private fun upgradeOverlay(context: Context, library: InputLibrary): InputLibrary {
if (library.layouts.none { it.template == InputLayoutTemplate.OVERLAY }) return library
val (surfaceContext, metrics) = primaryDisplay(context)
val settings = ControllerPrefs.read(context)
val elements = LegacyGamepadLayout(surfaceContext, metrics.widthPixels.coerceAtLeast(1), metrics.heightPixels.coerceAtLeast(1)).elements()
val style = InputSurfaceStyle(settings.tint, settings.opacity)
val replacements = library.layouts.filter { it.template == InputLayoutTemplate.OVERLAY }.associate { old ->
old.id to old.copy(name = "Gamepad", elements = elements, template = InputLayoutTemplate.CUSTOM, style = style,
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
}
var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it })
val migrated = mutableMapOf<Pair<String, String>, String>()
for (profile in library.profiles) {
val parts = profile.parts.mapValues { (role, part) ->
val layout = replacements[part.layout] ?: return@mapValues part
val key = part.bindings to layout.id
val id = migrated.getOrPut(key) {
val old = library.preset(part.bindings)
val newId = migrationBindingId(result, old.id, layout.id)
val keyboard = elements.filter { it.id == "builtin:keyboard" }.associate { it.id to InputAction.Command(InputCommand.KEYBOARD) }
val clicks = if (settings.stickClick) old.clicks else old.clicks + elements.filter { it.hasClick }.associate { it.id to InputAction.Disabled }
result = result.withPreset(old.copy(id = newId, layoutId = layout.id, elements = old.elements + keyboard, clicks = clicks))
newId
}
part.copy(bindings = id)
}
result = result.withProfile(profile.copy(parts = parts))
}
val oldBindings = migrated.keys.map { it.first }.toSet()
result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() })
// Older editor layouts were resolution-specific. Keep each as a selectable saved layout.
for (key in ControllerPrefs.prefs(context).all.keys.filter { it.startsWith("layout.") }.sorted()) {
val size = key.removePrefix("layout.").split('x').mapNotNull { it.toIntOrNull() }
if (size.size != 2 || size.any { it <= 0 } || result.layouts.size >= 128) continue
if (size[0] == metrics.widthPixels && size[1] == metrics.heightPixels) continue
val id = "migrated.$key"
if (result.layouts.none { it.id == id }) result = result.withLayout(SavedInputLayout(id, "Gamepad ${size[0]} × ${size[1]}",
LegacyGamepadLayout(surfaceContext, size[0], size[1]).elements(), style = style, aspect = (size[0].toFloat() / size[1]).coerceIn(.05f, 20f)))
}
return result.validate()
}
private fun neutralStockLayouts(library: InputLibrary): InputLibrary = library.copy(layouts = library.layouts.map { layout ->
if ((layout.id == DECK || layout.id == TRACKPAD) && layout.style.tint == ControllerPrefs.STEAM_BLUE) layout.copy(style = layout.style.copy(tint = null))
else layout
})
private fun upgradeStockLayouts(library: InputLibrary): InputLibrary {
val stock = InputStockLayouts.layouts()
val replacements = library.layouts.filter { it.template == InputLayoutTemplate.DECK || it.template == InputLayoutTemplate.TRACKPAD }
.associate { old -> old.id to stock.first { it.id == if (old.template == InputLayoutTemplate.DECK) DECK else TRACKPAD }.copy(id = old.id, name = old.name) }
var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it })
val migrated = mutableMapOf<Pair<String, String>, String>()
for (profile in library.profiles) {
val parts = profile.parts.mapValues { (_, part) ->
val layout = replacements[part.layout] ?: return@mapValues part
val id = migrated.getOrPut(part.bindings to layout.id) {
val old = library.preset(part.bindings)
val id = migrationBindingId(result, old.id, layout.id)
val original = library.layouts.first { it.id == layout.id }
val stockLayout = layout.copy(id = if (original.template == InputLayoutTemplate.TRACKPAD) TRACKPAD else DECK)
result = result.withPreset(old.copy(id = id, layoutId = layout.id,
elements = InputStockLayouts.actions(stockLayout) + old.elements, clicks = InputStockLayouts.clicks(stockLayout) + old.clicks))
id
}
part.copy(bindings = id)
}
result = result.withProfile(profile.copy(parts = parts))
}
val oldBindings = migrated.keys.map { it.first }.toSet()
result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() })
return addStockLayouts(result)
}
private fun addStockLayouts(library: InputLibrary): InputLibrary {
val stock = InputStockLayouts.layouts()
var result = library
for (layout in stock) if (result.layouts.none { it.id == layout.id }) result = result.withLayout(layout)
for (preset in InputStockLayouts.presets(stock)) if (result.bindings.none { it.id == preset.id }) result = result.withPreset(preset)
return result.validate()
}
private fun barStockLayouts(library: InputLibrary): InputLibrary {
var result = library
for (stock in InputStockLayouts.layouts().filter { it.id == DECK || it.id == TRACKPAD }) {
val saved = result.layouts.firstOrNull { it.id == stock.id } ?: continue
val (commands, rest) = saved.elements.partition { it.id.startsWith("command:") }
val untouched = commands.isNotEmpty() && commands.all { it.control == InputControl.A && it.kind == InputElementKind.BUTTON } &&
rest.size == stock.elements.size && rest.all { e -> stock.elements.any { s -> s.id == e.id && s.copy(y = e.y, height = e.height) == e &&
kotlin.math.abs(s.y + s.height - e.y - e.height) < 1e-3f } }
if (untouched) result = result.withLayout(saved.copy(elements = stock.elements))
}
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)
var id = base
var suffix = 0
while (library.bindings.any { it.id == id }) id = "$base.${++suffix}"
return id
}
private fun scopeLayouts(library: InputLibrary): InputLibrary = library.copy(layouts = library.layouts.map { layout ->
val roles = library.profiles.flatMap { p -> p.parts.filterValues { it.layout == layout.id }.keys } +
library.selections.values.flatMap { s -> s.parts.filterValues { library.preset(it).layoutId == layout.id }.keys }
val screen = when {
InputRole.PRIMARY in roles && InputRole.SECONDARY in roles -> InputLayoutScreen.BOTH
InputRole.PRIMARY in roles -> InputLayoutScreen.MAIN
InputRole.SECONDARY in roles -> InputLayoutScreen.SECONDARY
layout.id == OVERLAY || layout.id.startsWith("migrated.layout.") -> InputLayoutScreen.MAIN
else -> InputLayoutScreen.SECONDARY
}
layout.copy(screen = screen)
})
internal fun encode(library: InputLibrary): String {
val layouts = JSONArray()
for (layout in library.layouts) {
val elements = JSONArray()
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("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))
}
val bindings = JSONArray()
for (b in library.bindings) {
val controls = JSONObject(); b.controls.forEach { (c, a) -> controls.put(c.id, InputProfiles.encode(a)) }
val elements = JSONObject(); b.elements.forEach { (id, a) -> elements.put(id, InputProfiles.encode(a)) }
val clicks = JSONObject(); b.clicks.forEach { (id, a) -> clicks.put(id, InputProfiles.encode(a)) }
val keys = JSONArray()
b.keys.forEach { binding -> keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", InputProfiles.encode(binding.action))) }
bindings.put(JSONObject().put("id", b.id).put("name", b.name).put("physical", b.physical)
.put("layout", b.layoutId).put("controls", controls).put("elements", elements).put("keys", keys).put("clicks", clicks))
}
val profiles = JSONArray()
for (p in library.profiles) {
val parts = JSONObject()
p.parts.forEach { (role, part) -> parts.put(role.name, JSONObject().put("bindings", part.bindings).put("layout", part.layout)) }
profiles.put(JSONObject().put("id", p.id).put("name", p.name).put("base", p.base).put("parts", parts))
}
val selections = JSONObject()
for ((mode, selection) in library.selections) {
val parts = JSONObject()
selection.parts.forEach { (role, id) -> parts.put(role.name, id) }
selections.put(mode, JSONObject().put("profile", selection.profile).put("parts", parts).put("profileSelected", selection.profileSelected))
}
return JSONObject().put("selections", selections).put("version", VERSION).put("selected", library.selected).put("steam", library.steam).put("desktop", library.desktop)
.put("profiles", profiles).put("layouts", layouts).put("bindings", bindings).toString()
}
internal fun decode(raw: String): InputLibrary? = runCatching {
require(raw.length <= 2_000_000)
val doc = JSONObject(raw); require(doc.getInt("version") in 2..VERSION)
val layouts = doc.getJSONArray("layouts").objects(256).map { layout ->
val elements = layout.getJSONArray("elements").objects(64).map { e ->
InputSurfaceElement(requireNotNull(InputControl.fromId(e.getString("control"))), e.getDouble("x").toFloat(),
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.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),
layout.optJSONArray("hidden")?.let { hidden -> (0 until hidden.length()).map { InputCommand.valueOf(hidden.getString(it)) }.toSet() }.orEmpty()),
InputLayoutScreen.valueOf(layout.optString("screen", InputLayoutScreen.BOTH.name)), layout.optDouble("aspect", 0.0).toFloat(), layout.optionalString("origin"),
layout.optDouble("scale", 1.0).toFloat())
}
val bindings = doc.getJSONArray("bindings").objects(512).map { b ->
val controls = b.getJSONObject("controls").actions(28).mapKeys { requireNotNull(InputControl.fromId(it.key)) }
val keys = b.getJSONArray("keys").objects(64).map { k -> PhysicalInputBinding(
PhysicalInputKey(k.getString("device"), k.getString("name"), k.getInt("keyCode"), k.getInt("scanCode")),
requireNotNull(InputProfiles.decode(k.getJSONObject("action")))) }
SavedInputBindings(b.getString("id"), b.getString("name"), b.getBoolean("physical"), b.optionalString("layout"),
controls, b.getJSONObject("elements").actions(64), keys, b.optJSONObject("clicks")?.actions(64).orEmpty())
}
val profiles = doc.getJSONArray("profiles").objects(64).map { p ->
val parts = p.getJSONObject("parts")
SavedInputProfile(p.getString("id"), p.getString("name"), p.getString("base"), InputRole.entries.associateWith { role ->
val part = parts.getJSONObject(role.name); InputPart(part.getString("bindings"), part.optionalString("layout"))
})
}
val selections = doc.optJSONObject("selections")?.let { saved ->
require(saved.length() <= 2)
saved.keys().asSequence().associateWith { mode ->
val value = saved.getJSONObject(mode)
val parts = value.getJSONObject("parts")
require(parts.length() <= InputRole.entries.size)
val profile = value.getString("profile")
InputSelection(profile, parts.keys().asSequence().associate { role -> InputRole.valueOf(role) to parts.getString(role) },
value.optBoolean("profileSelected", profile != InputProfiles.AUTO || parts.length() == 0))
}
}.orEmpty()
InputLibrary(profiles, layouts, bindings, doc.getString("selected"), doc.getString("steam"), doc.getString("desktop"), selections).validate()
}.getOrNull()
private fun JSONArray.objects(limit: Int): List<JSONObject> {
require(length() <= limit)
return (0 until length()).map { getJSONObject(it) }
}
private fun JSONObject.actions(limit: Int): Map<String, InputAction> {
require(length() <= limit)
return keys().asSequence().associateWith { requireNotNull(InputProfiles.decode(getJSONObject(it))) }
}
private fun JSONObject.optionalString(key: String): String? = if (isNull(key)) null else getString(key)
}
@@ -1,304 +0,0 @@
package com.droiddeck.launcher.input
import java.util.UUID
enum class InputLayoutTemplate { CUSTOM, OVERLAY, DECK, TRACKPAD }
enum class InputLayoutScreen { MAIN, SECONDARY, BOTH;
fun supports(role: InputRole) = role != InputRole.PHYSICAL && (this == BOTH ||
if (role == InputRole.PRIMARY) this == MAIN else this == SECONDARY)
}
data class InputSurfaceStyle(val tint: Int? = ControllerPrefs.STEAM_BLUE, val opacity: Int = 100, val hidden: Set<InputCommand> = emptySet()) {
fun hides(element: InputSurfaceElement) = element.quickButton?.let { it in hidden } == true
fun shown(elements: List<InputSurfaceElement>) = if (hidden.isEmpty()) elements else elements.filterNot(::hides)
}
data class SavedInputLayout(
val id: String, val name: String, val elements: List<InputSurfaceElement> = emptyList(),
val template: InputLayoutTemplate = InputLayoutTemplate.CUSTOM,
val style: InputSurfaceStyle = InputSurfaceStyle(),
val screen: InputLayoutScreen = InputLayoutScreen.BOTH,
val aspect: Float = 0f,
val origin: String? = null,
val scale: Float = 1f,
) {
val editable get() = template == InputLayoutTemplate.CUSTOM
val quickButtons get() = QUICK_BUTTONS.filter { button -> elements.any { it.quickButton == button } }
fun withQuickButton(button: InputCommand, shown: Boolean) =
copy(style = style.copy(hidden = if (shown) style.hidden - button else style.hidden + button))
fun derived(id: String, name: String) = copy(id = id, name = name, origin = origin ?: this.id)
val adaptiveSticks get() = elements.filter { it.kind == InputElementKind.STICK }.takeIf { editable && it.isNotEmpty() }?.all { it.floating && it.hideWhenIdle }
fun supports(role: InputRole) = screen.supports(role) && when (template) {
InputLayoutTemplate.OVERLAY -> role == InputRole.PRIMARY
InputLayoutTemplate.DECK, InputLayoutTemplate.TRACKPAD -> role == InputRole.SECONDARY
InputLayoutTemplate.CUSTOM -> true
}
}
data class SavedInputBindings(
val id: String, val name: String, val physical: Boolean,
val layoutId: String? = null,
val controls: Map<InputControl, InputAction> = emptyMap(),
val elements: Map<String, InputAction> = emptyMap(),
val keys: List<PhysicalInputBinding> = emptyList(),
val clicks: Map<String, InputAction> = emptyMap(),
) {
fun compatible(layout: SavedInputLayout?) = if (physical) layout == null
else layout != null && if (layout.editable) layoutId == layout.id else layoutId == null
fun action(element: InputSurfaceElement) = elements[element.id]
?: if (element.id == "legacy:${element.control.id}") controls[element.control] ?: InputAction.Native()
else InputAction.Disabled
fun clickAction(element: InputSurfaceElement) = clicks[element.id]
?: defaultElementClick(element, action(element)) { controls[it] ?: InputAction.Native() }
}
data class InputSelection(
val profile: String = InputProfiles.AUTO, val parts: Map<InputRole, String> = emptyMap(), val profileSelected: Boolean = false,
)
data class InputPart(val bindings: String, val layout: String? = null)
data class SavedInputProfile(val id: String, val name: String, val base: String, val parts: Map<InputRole, InputPart>) {
fun binding(role: InputRole) = parts.getValue(role).bindings
}
data class InputLibrary(
val profiles: List<SavedInputProfile>, val layouts: List<SavedInputLayout>, val bindings: List<SavedInputBindings>,
val selected: String = InputProfiles.AUTO,
val steam: String = InputProfile.GAME, val desktop: String = InputProfile.DESKTOP,
val selections: Map<String, InputSelection> = emptyMap(),
) {
fun profile(id: String) = profiles.first { it.id == id }
fun preset(id: String) = bindings.first { it.id == id }
fun layout(profile: String, role: InputRole) = profiles.firstOrNull { it.id == profile }?.parts?.get(role)?.layout
?.let { id -> layouts.firstOrNull { it.id == id } }
private fun mode(isDesktop: Boolean) = if (isDesktop) InputProfile.DESKTOP else InputProfile.GAME
fun selection(isDesktop: Boolean) = selections[mode(isDesktop)] ?: InputSelection(selected)
fun activeId(isDesktop: Boolean): String {
val id = selection(isDesktop).profile
return if (id == InputProfiles.AUTO) { if (isDesktop) desktop else steam } else id
}
fun selectProfile(isDesktop: Boolean, id: String) = copy(selections = selections + (mode(isDesktop) to InputSelection(id, profileSelected = true)))
fun selectPart(isDesktop: Boolean, role: InputRole, bindingsId: String): InputLibrary {
val previous = selection(isDesktop)
return copy(selections = selections + (mode(isDesktop) to previous.copy(parts = previous.parts + (role to bindingsId))))
}
fun activePart(isDesktop: Boolean, role: InputRole): InputPart {
val binding = selection(isDesktop).parts[role]?.let(::preset)
return if (binding != null) InputPart(binding.id, binding.layoutId) else profile(activeId(isDesktop)).parts.getValue(role)
}
fun activeLayout(isDesktop: Boolean, role: InputRole) = activePart(isDesktop, role).layout?.let { id -> layouts.first { it.id == id } }
fun panelPart(isDesktop: Boolean, panel: String?): InputPart =
panel?.let { id -> bindings.firstOrNull { it.id == id } }?.let { InputPart(it.id, it.layoutId) } ?: activePart(isDesktop, InputRole.SECONDARY)
fun panelLayout(isDesktop: Boolean, panel: String?) = panelPart(isDesktop, panel).layout?.let { id -> layouts.firstOrNull { it.id == id } }
fun compileActive(isDesktop: Boolean, panel: String? = null): InputProfile = compileParts(activeId(isDesktop), InputRole.entries.associateWith {
preset(if (it == InputRole.SECONDARY) panelPart(isDesktop, panel).bindings else activePart(isDesktop, it).bindings)
})
fun saveSelection(isDesktop: Boolean, name: String, id: String = newInputId()): InputLibrary {
val current = profile(activeId(isDesktop))
var result = withProfile(SavedInputProfile(id, inputName(name), current.base, InputRole.entries.associateWith { activePart(isDesktop, it) }))
for (role in InputRole.entries) result = result.usePart(id, role, id, shared = false)
return result.selectProfile(isDesktop, id)
}
fun compile(id: String): InputProfile {
val profile = profile(id)
val parts = InputRole.entries.associateWith { preset(profile.binding(it)) }
return compileParts(id, parts)
}
private fun compileParts(id: String, parts: Map<InputRole, SavedInputBindings>): InputProfile {
return InputProfile(id, parts.mapValues { it.value.controls }.filterValues { it.isNotEmpty() },
parts.getValue(InputRole.PHYSICAL).keys,
parts.mapValues { it.value.elements }.filterValues { it.isNotEmpty() },
parts.mapValues { it.value.clicks }.filterValues { it.isNotEmpty() })
}
fun usersOfBindings(id: String) = profiles.filter { p -> p.parts.values.any { it.bindings == id } }
fun selectedBindings(id: String) = selections.values.any { id in it.parts.values }
fun usersOfLayout(id: String) = profiles.filter { p -> p.parts.values.any { it.layout == id } }
fun withProfile(value: SavedInputProfile) = copy(profiles = profiles.filterNot { it.id == value.id } + value)
fun withPreset(value: SavedInputBindings) = copy(bindings = bindings.filterNot { it.id == value.id } + value)
fun withLayout(value: SavedInputLayout) = copy(layouts = layouts.filterNot { it.id == value.id } + value,
bindings = bindings.map { preset -> if (preset.layoutId != value.id) preset else preset.copy(
elements = preset.elements.filter { (id, action) -> value.elements.any { it.id == id && action.validFor(it) } },
clicks = preset.clicks.filter { (id, action) -> value.elements.any { it.id == id && it.hasClick && action.validForClick() } }) })
fun duplicateProfile(id: String, name: String, newId: String = newInputId(), sharePhysical: Boolean = false): InputLibrary {
require(profiles.size < 64)
var result = withProfile(profile(id).copy(id = newId, name = inputName(name)))
for (role in InputRole.entries) result = result.usePart(newId, role, id, shared = sharePhysical && role == InputRole.PHYSICAL)
return result
}
fun usePart(profileId: String, role: InputRole, sourceId: String, shared: Boolean): InputLibrary {
val target = profile(profileId)
val source = profile(sourceId).parts.getValue(role)
if (shared) return withProfile(target.copy(parts = target.parts + (role to source)))
val sourceLayout = layout(sourceId, role)
val name = (target.name + " / " + role.name.lowercase()).take(80)
val copiedLayout = sourceLayout?.takeIf { it.editable }?.derived(newInputId(), name)
val copiedBindings = preset(source.bindings).copy(id = newInputId(), name = name,
layoutId = copiedLayout?.id ?: preset(source.bindings).layoutId)
return (if (copiedLayout == null) this else withLayout(copiedLayout)).withPreset(copiedBindings)
.withProfile(target.copy(parts = target.parts + (role to InputPart(copiedBindings.id, copiedLayout?.id ?: source.layout))))
}
fun useLayout(profileId: String, role: InputRole, layoutId: String, shared: Boolean): InputLibrary {
val source = layouts.first { it.id == layoutId }
require(source.supports(role))
val target = profile(profileId)
val layout = if (shared || !source.editable) source else source.derived(newInputId(), (target.name + " / " + source.name).take(80))
val previous = preset(target.binding(role))
val actions = if (previous.compatible(source)) previous else bindings.firstOrNull { it.compatible(source) } ?: SavedInputBindings(newInputId(), target.name, false,
controls = if (source.editable) emptyMap() else InputProfile.defaults(target.base).bindings[role].orEmpty(),
elements = if (source.editable) source.elements.associate { it.id to InputAction.Disabled } else emptyMap())
val copied = actions.copy(id = newInputId(), name = (target.name + " / " + role.name.lowercase()).take(80),
layoutId = layout.id.takeIf { layout.editable })
return withLayout(layout).withPreset(copied)
.withProfile(target.copy(parts = target.parts + (role to InputPart(copied.id, layout.id))))
}
fun copyBindings(id: String, name: String, profileId: String? = null, role: InputRole? = null, newId: String = newInputId()): InputLibrary {
require(bindings.size < 512)
var result = withPreset(preset(id).copy(id = newId, name = inputName(name)))
if (profileId != null && role != null) result = result.chooseBindings(profileId, role, newId)
return result
}
fun chooseBindings(profileId: String, role: InputRole, id: String): InputLibrary {
val profile = profile(profileId)
require(preset(id).compatible(layout(profileId, role)))
return withProfile(profile.copy(parts = profile.parts + (role to profile.parts.getValue(role).copy(bindings = id))))
}
fun chooseLayout(profileId: String, role: InputRole, id: String): InputLibrary {
val layout = layouts.first { it.id == id }
require(layout.supports(role))
val profile = profile(profileId)
val previous = preset(profile.binding(role))
val compatible = bindings.firstOrNull { it.id == previous.id && it.compatible(layout) }
?: bindings.firstOrNull { it.compatible(layout) }
?: SavedInputBindings(newInputId(), layout.name, false, if (layout.editable) layout.id else null)
return withPreset(compatible).withProfile(profile.copy(parts = profile.parts + (role to InputPart(compatible.id, id))))
}
fun copyLayout(id: String, name: String, newId: String = newInputId()): InputLibrary {
val source = layouts.first { it.id == id }
require(source.editable)
return withLayout(source.derived(newId, inputName(name)))
}
fun removeProfile(id: String): InputLibrary {
require(id != InputProfile.GAME && id != InputProfile.DESKTOP)
return copy(profiles = profiles.filterNot { it.id == id }, selected = selected.takeUnless { it == id } ?: InputProfiles.AUTO,
steam = steam.takeUnless { it == id } ?: InputProfile.GAME, desktop = desktop.takeUnless { it == id } ?: InputProfile.DESKTOP,
selections = selections.mapValues { (_, selection) -> if (selection.profile == id) selection.copy(profile = InputProfiles.AUTO) else selection })
}
fun removeLayout(id: String): InputLibrary {
require(usersOfLayout(id).isEmpty() && bindings.none { it.layoutId == id && selectedBindings(it.id) } && layouts.first { it.id == id }.editable)
return copy(layouts = layouts.filterNot { it.id == id }, bindings = bindings.filterNot { it.layoutId == id })
}
fun removeBindings(id: String): InputLibrary {
require(usersOfBindings(id).isEmpty() && !selectedBindings(id))
return copy(bindings = bindings.filterNot { it.id == id })
}
fun resetProfile(id: String): InputLibrary {
val p = profile(id)
var result = this
val defaults = InputProfile.defaults(p.base)
val parts = p.parts.mapValues { (role, part) ->
val previous = preset(part.bindings)
val isPrivate = usersOfBindings(part.bindings).all { it.id == id }
val preset = SavedInputBindings(if (isPrivate) previous.id else newInputId(),
if (isPrivate) previous.name else (p.name + " / " + role.name.lowercase()).take(80), role == InputRole.PHYSICAL,
part.layout?.takeIf { layout(id, role)?.editable == true }, defaults.bindings[role].orEmpty(),
elements = (layout(id, role)?.let { InputStockLayouts.actions(it) } ?: emptyMap()) + layout(id, role)?.elements.orEmpty().filter { it.id == "builtin:keyboard" }
.associate { it.id to InputAction.Command(InputCommand.KEYBOARD) })
result = result.withPreset(preset)
part.copy(bindings = preset.id)
}
return result.withProfile(p.copy(parts = parts))
}
fun validate(): InputLibrary {
require(profiles.size in 2..64 && layouts.size in 3..256 && bindings.size in 1..512)
fun unique(ids: List<String>) { require(ids.all { it.isNotBlank() && it.length <= 128 } && ids.distinct().size == ids.size) }
unique(profiles.map { it.id }); unique(layouts.map { it.id }); unique(bindings.map { it.id })
require(profiles.any { it.id == InputProfile.GAME } && profiles.any { it.id == InputProfile.DESKTOP })
require(selected == InputProfiles.AUTO || profiles.any { it.id == selected })
require(profiles.any { it.id == steam } && profiles.any { it.id == desktop })
for (layout in layouts) {
require(layout.style.opacity in 1..100 && layout.style.hidden.all { it in QUICK_BUTTONS })
require(layout.aspect == 0f || layout.aspect.isFinite() && layout.aspect in .05f..20f)
require(layout.scale.isFinite() && layout.scale in .5f..2f)
inputName(layout.name); require(layout.elements.size <= 64); unique(layout.elements.map { it.id })
for (e in layout.elements) {
inputName(e.name)
require(e.icon == 0 || InputButtonIcons.has(e.icon))
require(e.image.isEmpty() || e.image.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && e.image != "." && e.image != "..")
require(e.opacity.isFinite() && e.opacity in 0.01f..1f)
require(!e.toggle || e.kind == InputElementKind.BUTTON || e.kind == InputElementKind.TRIGGER)
require(listOf(e.x, e.y, e.width, e.height).all { it.isFinite() })
require(e == e.bounded())
require(if (e.id.startsWith("legacy:")) e.id == "legacy:${e.control.id}" && e.kind == InputElementKind.from(e.control)
else e.control == e.kind.control)
}
}
for (preset in bindings) {
inputName(preset.name)
require(preset.controls.all { (c, a) -> a.validFor(c) })
require(preset.elements.size <= 64 && preset.keys.size <= 64 && preset.clicks.size <= 64)
require(preset.keys.map { Triple(it.key.device, it.key.keyCode, it.key.scanCode) }.distinct().size == preset.keys.size)
require(preset.physical || preset.keys.isEmpty())
val layout = preset.layoutId?.let { id -> layouts.first { it.id == id } }
require(!preset.physical || layout == null && preset.elements.isEmpty() && preset.clicks.isEmpty())
require(preset.elements.all { (id, action) -> layout?.elements?.firstOrNull { it.id == id }?.let { action.validFor(it) } == true })
require(preset.clicks.all { (id, action) -> layout?.elements?.any { it.id == id && it.hasClick } == true && action.validForClick() })
require(preset.keys.all { it.key.device.isNotBlank() && it.key.keyCode >= 0 && it.key.scanCode >= 0 &&
it.action.validFor(InputControl.A) && (it.action !is InputAction.Native || it.action.target == null) })
}
require(selections.keys.all { it == InputProfile.GAME || it == InputProfile.DESKTOP })
for (selection in selections.values) {
require(selection.profile == InputProfiles.AUTO || profiles.any { it.id == selection.profile })
for ((role, id) in selection.parts) {
val binding = preset(id)
require(binding.physical == (role == InputRole.PHYSICAL))
val layout = binding.layoutId?.let { lid -> layouts.first { it.id == lid } }
require(binding.compatible(layout) && (layout == null || layout.supports(role)))
}
}
for (p in profiles) {
inputName(p.name); require(p.base == InputProfile.GAME || p.base == InputProfile.DESKTOP)
require(p.parts.keys == InputRole.entries.toSet())
for ((role, part) in p.parts) {
val layout = part.layout?.let { id -> layouts.first { it.id == id } }
require(if (role == InputRole.PHYSICAL) layout == null else layout?.supports(role) == true)
require(preset(part.bindings).compatible(layout))
}
}
return this
}
}
internal fun inputName(value: String): String = value.trim().also { require(it.isNotEmpty() && it.length <= 80) }
fun newInputId(): String = UUID.randomUUID().toString()
internal fun InputAction.validFor(element: InputSurfaceElement): Boolean = when {
this !is InputAction.Native -> validFor(element.control)
element.id.startsWith("legacy:") -> validFor(element.control)
element.kind == InputElementKind.DPAD -> target == null
target == null -> false
element.kind == InputElementKind.TRACKPAD -> target.isTrackpad
element.kind == InputElementKind.STICK -> target == InputControl.LS || target == InputControl.RS
element.kind == InputElementKind.TRIGGER -> target == InputControl.LT || target == InputControl.RT
else -> target.dimensions == 1 && target != InputControl.LT && target != InputControl.RT
}
@@ -1,35 +0,0 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.Choreographer
class InputMotion(private val router: () -> InputRouter?) : Choreographer.FrameCallback {
private val handler = Handler(Looper.getMainLooper())
private var scheduled = false
private var lastFrame = 0L
private val start = Runnable {
if (!scheduled) {
scheduled = true
lastFrame = System.nanoTime()
Choreographer.getInstance().postFrameCallback(this)
}
}
fun request() {
if (Looper.myLooper() == Looper.getMainLooper()) start.run() else handler.post(start)
}
override fun doFrame(frameTimeNanos: Long) {
val dt = (frameTimeNanos - lastFrame).coerceAtLeast(0) / 1_000_000_000f
lastFrame = frameTimeNanos
if (router()?.tick(dt) == true) Choreographer.getInstance().postFrameCallback(this)
else scheduled = false
}
fun stop() {
handler.removeCallbacks(start)
Choreographer.getInstance().removeFrameCallback(this)
scheduled = false
}
}
@@ -1,107 +0,0 @@
package com.droiddeck.launcher.input
enum class InputRole { PHYSICAL, PRIMARY, SECONDARY }
enum class InputControl(val id: String, val label: String, val dimensions: Int = 1) {
A("a", "A"), B("b", "B"), X("x", "X"), Y("y", "Y"),
LB("lb", "L1"), RB("rb", "R1"), SELECT("select", "View / Select"), START("start", "Menu / Start"),
L3("l3", "L3"), R3("r3", "R3"), GUIDE("guide", "Steam / Guide"), QAM("qam", "Quick access"),
UP("up", "D-pad up"), RIGHT("right", "D-pad right"), DOWN("down", "D-pad down"), LEFT("left", "D-pad left"),
LT("lt", "L2"), RT("rt", "R2"), LS("ls", "Left stick", 2), RS("rs", "Right stick", 2),
L4("l4", "L4"), R4("r4", "R4"), L5("l5", "L5"), R5("r5", "R5"),
LEFT_PAD("left-pad", "Left trackpad", 2), RIGHT_PAD("right-pad", "Right trackpad", 2),
LEFT_CLICK("left-click", "Left trackpad click"), RIGHT_CLICK("right-click", "Right trackpad click");
val isPad: Boolean get() = ordinal <= RS.ordinal
val isTrackpad: Boolean get() = this == LEFT_PAD || this == RIGHT_PAD
companion object {
val padControls = entries.filter { it.isPad }.toTypedArray()
fun fromId(id: String) = entries.firstOrNull { it.id == id }
}
}
enum class InputCommand { MENU, KEYBOARD, STEAM, QUICK_ACCESS }
sealed interface InputAction {
data class Native(val target: InputControl? = null) : InputAction
data object Disabled : InputAction
data class Keys(val codes: List<Int>) : InputAction
data class MouseButton(val code: Int) : InputAction
data class Command(val command: InputCommand) : InputAction
data class Directions(val up: Int, val right: Int, val down: Int, val left: Int, val diagonals: List<Int>? = null) : InputAction
data class Pointer(val speed: Float = 900f) : InputAction
data class Scroll(val speed: Float = 10f, val horizontal: Boolean = false) : InputAction
}
data class InputProfile(
val id: String,
val bindings: Map<InputRole, Map<InputControl, InputAction>> = emptyMap(),
val keys: List<PhysicalInputBinding> = emptyList(),
val elements: Map<InputRole, Map<String, InputAction>> = emptyMap(),
val clicks: Map<InputRole, Map<String, InputAction>> = emptyMap(),
) {
fun action(role: InputRole, control: InputControl): InputAction =
bindings[role]?.get(control) ?: InputAction.Native()
fun withBinding(role: InputRole, control: InputControl, action: InputAction) =
copy(bindings = bindings + (role to (bindings[role].orEmpty() + (control to action))))
fun elementAction(role: InputRole, id: String, control: InputControl): InputAction =
elements[role]?.get(id) ?: if (id == "legacy:${control.id}") action(role, control) else InputAction.Disabled
fun elementClickAction(role: InputRole, element: InputSurfaceElement): InputAction = clicks[role]?.get(element.id)
?: defaultElementClick(element, elementAction(role, element.id, element.control)) { action(role, it) }
companion object {
const val GAME = "game"
const val DESKTOP = "desktop"
fun defaults(id: String): InputProfile {
if (id != DESKTOP) return InputProfile(GAME)
val desktop = mapOf(
InputControl.LS to InputAction.Pointer(), InputControl.RS to InputAction.Scroll(),
InputControl.A to InputAction.MouseButton(272), InputControl.B to InputAction.MouseButton(273),
InputControl.X to InputAction.Keys(listOf(28)), InputControl.Y to InputAction.Keys(listOf(1)),
InputControl.UP to InputAction.Keys(listOf(103)), InputControl.RIGHT to InputAction.Keys(listOf(106)),
InputControl.DOWN to InputAction.Keys(listOf(108)), InputControl.LEFT to InputAction.Keys(listOf(105)),
InputControl.LT to InputAction.MouseButton(273), InputControl.RT to InputAction.MouseButton(272),
InputControl.LB to InputAction.Keys(listOf(29)), InputControl.RB to InputAction.Keys(listOf(56)),
InputControl.SELECT to InputAction.Command(InputCommand.KEYBOARD),
InputControl.START to InputAction.Command(InputCommand.MENU),
InputControl.LEFT_PAD to InputAction.Scroll(), InputControl.RIGHT_PAD to InputAction.Pointer(),
InputControl.LEFT_CLICK to InputAction.MouseButton(273), InputControl.RIGHT_CLICK to InputAction.MouseButton(272),
)
return InputProfile(DESKTOP, InputRole.entries.associateWith { desktop })
}
}
}
internal fun defaultElementClick(element: InputSurfaceElement, action: InputAction, legacy: (InputControl) -> InputAction): InputAction {
fun click(control: InputControl) = when (control) {
InputControl.LS -> InputControl.L3; InputControl.RS -> InputControl.R3
InputControl.LEFT_PAD -> InputControl.LEFT_CLICK; InputControl.RIGHT_PAD -> InputControl.RIGHT_CLICK
else -> null
}
if (!element.hasClick) return InputAction.Disabled
if (element.id == "legacy:${element.control.id}") {
val target = click(element.control) ?: return InputAction.Disabled
return legacy(target).let { if (it is InputAction.Native && it.target == null) InputAction.Native(target) else it }
}
return (action as? InputAction.Native)?.let { click(it.target ?: element.control) }?.let { InputAction.Native(it) } ?: InputAction.Disabled
}
internal fun InputAction.validForClick(): Boolean = if (this is InputAction.Native)
target != null && target.dimensions == 1 && target != InputControl.LT && target != InputControl.RT
else validFor(InputControl.A)
internal fun InputAction.validFor(control: InputControl): Boolean = when (this) {
is InputAction.Native -> target == null || (control.isPad == target.isPad && control.dimensions == target.dimensions &&
(!control.isTrackpad && !target.isTrackpad || control.isTrackpad && target.isTrackpad))
is InputAction.Keys -> codes.isNotEmpty() && codes.size <= 8 && codes.distinct().size == codes.size && codes.all { it in 1..255 }
is InputAction.MouseButton -> code in 272..276
is InputAction.Directions -> control.dimensions == 2 && listOf(up, right, down, left).all { it in 0..255 } && (diagonals == null || diagonals.size == 4 && diagonals.all { it in 0..255 })
is InputAction.Pointer -> control.dimensions == 2 && speed.isFinite() && speed in 100f..3000f
is InputAction.Scroll -> control.dimensions == 2 && speed.isFinite() && speed in 1f..60f
else -> true
}
@@ -1,139 +0,0 @@
package com.droiddeck.launcher.input
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
object InputProfiles {
const val AUTO = "auto"
private const val VERSION = 1
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
fun selection(context: Context): String = InputLibraries.read(context).selected
fun select(context: Context, id: String) {
InputLibraries.update(context) { library ->
require(id == AUTO || library.profiles.any { it.id == id })
library.copy(selected = id, selections = emptyMap())
}
}
fun active(context: Context, desktop: Boolean): InputProfile {
val library = InputLibraries.read(context)
return library.compileActive(desktop)
}
fun read(context: Context, id: String): InputProfile {
return InputLibraries.read(context).compile(id)
}
internal fun readLegacy(context: Context, id: String): InputProfile {
migrate(context)
var result = InputProfile.defaults(id)
val stored = prefs(context).getString("profile.$id", null) ?: return result
val document = runCatching { JSONObject(stored) }.getOrNull() ?: return result
if (document.optInt("version") != VERSION) return result
val bindings = document.optJSONArray("bindings") ?: return result
for (i in 0 until minOf(bindings.length(), 256)) {
val entry = bindings.optJSONObject(i) ?: continue
val role = runCatching { InputRole.valueOf(entry.getString("role")) }.getOrNull() ?: continue
val control = InputControl.fromId(entry.optString("control")) ?: continue
val action = decode(entry.optJSONObject("action") ?: continue) ?: continue
if (action.validFor(control)) result = result.withBinding(role, control, action)
}
val keys = document.optJSONArray("keys")
if (keys != null) result = result.copy(keys = (0 until minOf(keys.length(), 64)).mapNotNull { i ->
runCatching {
val item = keys.getJSONObject(i)
val action = decode(item.getJSONObject("action")) ?: return@mapNotNull null
if (!action.validFor(InputControl.A) || action is InputAction.Native && action.target != null) return@mapNotNull null
val key = PhysicalInputKey(item.getString("device"), item.getString("name"), item.getInt("keyCode"), item.getInt("scanCode"))
if (key.device.isBlank() || key.keyCode < 0 || key.scanCode < 0) return@mapNotNull null
PhysicalInputBinding(key, action)
}.getOrNull()
}.distinctBy { Triple(it.key.device, it.key.keyCode, it.key.scanCode) })
return result
}
fun save(context: Context, profile: InputProfile) {
InputLibraries.update(context) { library ->
val definition = library.profile(profile.id)
var updated = library
for (role in InputRole.entries) {
val preset = updated.preset(definition.binding(role))
updated = updated.withPreset(preset.copy(controls = profile.bindings[role].orEmpty(),
elements = profile.elements[role].orEmpty(), keys = if (role == InputRole.PHYSICAL) profile.keys else emptyList(),
clicks = profile.clicks[role].orEmpty()))
}
updated
}
}
private fun saveLegacy(context: Context, profile: InputProfile) {
val bindings = JSONArray()
for ((role, controls) in profile.bindings) for ((control, action) in controls) {
require(action.validFor(control))
bindings.put(JSONObject().put("role", role.name).put("control", control.id).put("action", encode(action)))
}
val keys = JSONArray()
for (binding in profile.keys) keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", encode(binding.action)))
prefs(context).edit().putString("profile.${profile.id}", JSONObject()
.put("version", VERSION).put("id", profile.id).put("bindings", bindings).put("keys", keys).toString()).apply()
}
fun reset(context: Context, id: String) = InputLibraries.update(context) { it.resetProfile(id) }.let { }
fun resetAll(context: Context) {
prefs(context).edit().clear().putBoolean("migrated", true).apply()
InputSurfaceLayout.resetAll(context)
}
private fun migrate(context: Context) {
val p = prefs(context)
if (p.getBoolean("migrated", false)) return
var game = InputProfile.defaults(InputProfile.GAME)
val old = ControllerPrefs.read(context)
for ((id, target) in old.mapping) {
val source = InputControl.fromId(id) ?: continue
val action = if (target == ControllerPrefs.OFF) InputAction.Disabled
else InputAction.Native(InputControl.fromId(target) ?: continue)
if (target != id) game = game.withBinding(InputRole.PRIMARY, source, action)
}
if (old.selectSteam) game = game.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE))
if (!p.contains("profile.${InputProfile.GAME}")) saveLegacy(context, game)
if (old.selectSteam && !p.contains("profile.${InputProfile.DESKTOP}")) {
saveLegacy(context, InputProfile.defaults(InputProfile.DESKTOP)
.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE)))
}
p.edit().putBoolean("migrated", true).apply()
}
internal fun encode(action: InputAction): JSONObject = JSONObject().apply {
when (action) {
is InputAction.Native -> { put("type", "native"); action.target?.let { put("target", it.id) } }
InputAction.Disabled -> put("type", "disabled")
is InputAction.Keys -> { put("type", "keys"); put("codes", JSONArray(action.codes)) }
is InputAction.MouseButton -> { put("type", "button"); put("code", action.code) }
is InputAction.Command -> { put("type", "command"); put("command", action.command.name) }
is InputAction.Directions -> { put("type", "directions"); put("codes", JSONArray(listOf(action.up, action.right, action.down, action.left))); action.diagonals?.let { put("diagonals", JSONArray(it)) } }
is InputAction.Pointer -> { put("type", "pointer"); put("speed", action.speed.toDouble()) }
is InputAction.Scroll -> { put("type", "scroll"); put("speed", action.speed.toDouble()); put("horizontal", action.horizontal) }
}
}
internal fun decode(json: JSONObject): InputAction? = runCatching {
when (json.getString("type")) {
"native" -> InputAction.Native(if (json.has("target")) InputControl.fromId(json.getString("target")) ?: return null else null)
"disabled" -> InputAction.Disabled
"keys" -> InputAction.Keys(json.getJSONArray("codes").let { a -> (0 until a.length()).map { a.getInt(it) } })
"button" -> InputAction.MouseButton(json.getInt("code"))
"command" -> InputAction.Command(InputCommand.valueOf(json.getString("command")))
"directions" -> json.getJSONArray("codes").let { InputAction.Directions(it.getInt(0), it.getInt(1), it.getInt(2), it.getInt(3), json.optJSONArray("diagonals")?.let { d -> (0 until d.length()).map { d.getInt(it) } }) }
"pointer" -> InputAction.Pointer(json.getDouble("speed").toFloat())
"scroll" -> InputAction.Scroll(json.getDouble("speed").toFloat(), json.optBoolean("horizontal", false))
else -> null
}
}.getOrNull()
}
@@ -1,361 +0,0 @@
package com.droiddeck.launcher.input
import kotlin.math.abs
interface InputOutput {
fun key(code: Int, down: Boolean)
fun button(code: Int, down: Boolean)
fun pointer(dx: Float, dy: Float)
fun scroll(steps: Int)
fun scrollHorizontal(steps: Int) {}
fun command(command: InputCommand)
fun command(command: InputCommand, role: InputRole) = command(command)
fun requestMotion()
}
class InputRouter(private val output: InputOutput) {
private val keys = IntArray(256)
private val buttons = IntArray(5)
private val channels = ArrayList<Channel>()
private val pads = ArrayList<PadSource>()
private var profile = InputProfile.defaults(InputProfile.GAME)
private var moving = false
private var wheel = 0f
private var horizontalWheel = 0f
private var enabled = true
private val snapshots = ArrayList<() -> Unit>()
@Synchronized
fun beforeProfileChange(snapshot: () -> Unit) { snapshots.add(snapshot) }
@Synchronized
fun removeSnapshot(snapshot: () -> Unit) { snapshots.remove(snapshot) }
@Synchronized
fun setEnabled(on: Boolean): Boolean {
if (enabled == on) return false
if (!on) releaseAll()
enabled = on
for (pad in pads) pad.refreshIdentity()
return true
}
@Synchronized
fun setProfile(next: InputProfile) {
if (profile == next) return
for (snapshot in snapshots) snapshot()
for (pad in pads) pad.snapshot()
profile = next
for (channel in channels) channel.configure(channel.actionFrom(next))
for (pad in pads) pad.refreshIdentity()
wheel = 0f
horizontalWheel = 0f
moving = false
}
@JvmOverloads
@Synchronized
fun pad(role: InputRole, retained: PadState? = null): PadSource = PadSource(role, retained).also { pads.add(it) }
@Synchronized
fun channel(role: InputRole, control: InputControl, fixedAction: InputAction? = null, element: InputSurfaceElement? = null, elementClick: Boolean = false): Channel = Channel(role, control, fixedAction, element, elementClick).also {
channels.add(it)
it.configure(it.actionFrom(profile))
}
@Synchronized
fun releaseAll() {
for (snapshot in snapshots) snapshot()
for (pad in pads) pad.snapshot()
for (channel in channels) channel.suspend()
for (pad in pads) pad.refreshIdentity()
wheel = 0f
horizontalWheel = 0f
moving = false
}
@Synchronized
fun tick(seconds: Float): Boolean {
if (!enabled) return false
var x = 0f
var y = 0f
var scroll = 0f
var horizontalScroll = 0f
for (channel in channels) {
if (channel.blocked || channel.control.isTrackpad) continue
val cx = if (abs(channel.x) > 0.12f) channel.x else 0f
val cy = if (abs(channel.y) > 0.12f) channel.y else 0f
when (val action = channel.action) {
is InputAction.Pointer -> { x += cx * action.speed; y += cy * action.speed }
is InputAction.Scroll -> if (action.horizontal) horizontalScroll += cx * action.speed else scroll += cy * action.speed
else -> Unit
}
}
val dt = seconds.coerceIn(0f, 0.05f)
if (x != 0f || y != 0f) output.pointer(x * dt, y * dt)
wheel += scroll * dt
horizontalWheel += horizontalScroll * dt
flushWheel()
moving = x != 0f || y != 0f || scroll != 0f || horizontalScroll != 0f
return moving
}
private fun flushWheel() {
val steps = wheel.toInt()
if (steps != 0) { wheel -= steps; output.scroll(steps) }
val horizontalSteps = horizontalWheel.toInt()
if (horizontalSteps != 0) { horizontalWheel -= horizontalSteps; output.scrollHorizontal(horizontalSteps) }
}
private fun key(code: Int, down: Boolean) {
val was = keys[code]
keys[code] = (was + if (down) 1 else -1).coerceAtLeast(0)
if ((was == 0) != (keys[code] == 0)) output.key(code, down)
}
private fun button(code: Int, down: Boolean) {
val at = code - 272
val was = buttons[at]
buttons[at] = (was + if (down) 1 else -1).coerceAtLeast(0)
if ((was == 0) != (buttons[at] == 0)) output.button(code, down)
}
inner class Channel internal constructor(val role: InputRole, val control: InputControl, private val fixedAction: InputAction?, private val element: InputSurfaceElement?, private val elementClick: Boolean) {
var action: InputAction = InputAction.Native()
private set
internal var x = 0f
internal var y = 0f
internal var contact = false
internal var blocked = false
private var held = false
private val directions = BooleanArray(4)
private var radialKeys = emptyList<Int>()
internal fun actionFrom(profile: InputProfile) = element?.let { if (elementClick) profile.elementClickAction(role, it) else profile.elementAction(role, it.id, control) }
?: profile.action(role, control)
internal fun configure(next: InputAction) {
suspend()
action = (fixedAction ?: next).takeIf { if (elementClick) it.validForClick() else if (element != null) it.validFor(element) else it.validFor(control) }
?: if (element != null) InputAction.Disabled else InputAction.Native()
}
internal fun observe(value: Float, second: Float = 0f, touching: Boolean = false) {
x = value; y = second; contact = touching
}
internal fun awaitNeutral(value: Float, second: Float, touching: Boolean) = synchronized(this@InputRouter) {
observe(value, second, touching)
suspend()
}
internal fun nativeIdentity(): Boolean = enabled && !blocked && (action as? InputAction.Native)?.target == null && action is InputAction.Native
internal fun suspend() {
releaseOutputs()
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
}
fun release() = synchronized(this@InputRouter) {
releaseOutputs()
x = 0f; y = 0f; contact = false; blocked = false
}
fun close() = synchronized(this@InputRouter) {
release()
channels.remove(this)
}
fun acceptsPointerGesture() = synchronized(this@InputRouter) { enabled && !blocked && contact && action is InputAction.Pointer }
fun pointerDelta(dx: Float, dy: Float) = synchronized(this@InputRouter) {
val pointer = action as? InputAction.Pointer
if (enabled && !blocked && contact && pointer != null && dx.isFinite() && dy.isFinite()) output.pointer(dx * pointer.speed, dy * pointer.speed)
}
fun wheelSteps(steps: Int) = synchronized(this@InputRouter) {
val scroll = action as? InputAction.Scroll
if (enabled && !blocked && scroll != null && steps != 0) {
if (scroll.horizontal) output.scrollHorizontal(steps) else output.scroll(steps)
}
}
fun update(value: Float, second: Float = 0f, touching: Boolean = false): InputControl? = synchronized(this@InputRouter) {
val oldX = x
val oldY = y
val oldContact = contact
x = value.coerceIn(-1f, 1f)
y = second.coerceIn(-1f, 1f)
contact = touching
if (!enabled) {
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
return@synchronized null
}
if (blocked) {
if (!contact && abs(x) <= 0.12f && abs(y) <= 0.12f) blocked = false
return@synchronized null
}
when (val a = action) {
is InputAction.Native -> return@synchronized a.target ?: control
is InputAction.Disabled -> Unit
is InputAction.Pointer -> {
if (control.isTrackpad) {
if (contact && oldContact) output.pointer((x - oldX) * a.speed, -(y - oldY) * a.speed)
} else requestMotion()
}
is InputAction.Scroll -> {
if (control.isTrackpad) {
if (contact && oldContact) {
if (a.horizontal) horizontalWheel -= (x - oldX) * a.speed else wheel += (y - oldY) * a.speed
flushWheel()
}
} else requestMotion()
}
is InputAction.Directions -> {
val vertical = if (control.isTrackpad) -y else y
if (a.diagonals == null) {
direction(0, -vertical, a.up); direction(1, x, a.right)
direction(2, vertical, a.down); direction(3, -x, a.left)
} else {
val cardinal = listOf(a.up, a.right, a.down, a.left)
val sector = ((kotlin.math.round(Math.toDegrees(kotlin.math.atan2(vertical, x).toDouble()) / 45).toInt() + 2) % 8 + 8) % 8
val next = if (kotlin.math.hypot(x, vertical) < .24f) emptyList() else if (sector % 2 == 0) listOf(cardinal[sector / 2])
else a.diagonals[sector / 2].let { if (it != 0) listOf(it) else listOf(cardinal[sector / 2], cardinal[(sector / 2 + 1) % 4]) }
val codes = next.filter { it != 0 }.distinct()
radialKeys.filterNot { it in codes }.forEach { key(it, false) }
codes.filterNot { it in radialKeys }.forEach { key(it, true) }
radialKeys = codes
}
}
else -> {
val down = if (control.isTrackpad) contact else maxOf(abs(x), abs(y)) > if (held) 0.35f else 0.55f
if (held != down) {
held = down
when (a) {
is InputAction.Keys -> if (down) a.codes.forEach { key(it, true) } else a.codes.asReversed().forEach { key(it, false) }
is InputAction.MouseButton -> button(a.code, down)
is InputAction.Command -> if (!down) output.command(a.command, role)
else -> Unit
}
}
}
}
null
}
private fun requestMotion() {
if (!moving && (abs(x) > 0.12f || abs(y) > 0.12f)) { moving = true; output.requestMotion() }
}
private fun direction(at: Int, value: Float, code: Int) {
val down = value > if (directions[at]) 0.35f else 0.55f
if (directions[at] != down) { directions[at] = down; if (code != 0) key(code, down) }
}
private fun releaseOutputs() {
when (val a = action) {
is InputAction.Keys -> if (held) a.codes.asReversed().forEach { key(it, false) }
is InputAction.MouseButton -> if (held) button(a.code, false)
is InputAction.Directions -> {
val codes = intArrayOf(a.up, a.right, a.down, a.left)
for (i in directions.indices) if (directions[i] && codes[i] != 0) key(codes[i], false)
}
else -> Unit
}
radialKeys.forEach { key(it, false) }; radialKeys = emptyList()
held = false
directions.fill(false)
}
}
inner class PadSource internal constructor(role: InputRole, retained: PadState?) {
private val routes = Array(InputControl.padControls.size) { channel(role, InputControl.padControls[it]) }
private val result = PadState()
private var last: PadState? = null
private var identity = true
private var nativeCandidate = true
init {
last = retained
if (retained != null) for (route in routes) {
route.awaitNeutral(retained.value(route.control), retained.secondValue(route.control), false)
}
refreshIdentity()
}
internal fun refreshIdentity() {
nativeCandidate = routes.all { (it.action as? InputAction.Native)?.target == null && it.action is InputAction.Native }
identity = enabled && nativeCandidate && routes.none { it.blocked }
}
internal fun snapshot() {
val raw = last ?: return
for (route in routes) {
route.x = raw.value(route.control)
route.y = raw.secondValue(route.control)
}
}
fun route(raw: PadState): PadState = synchronized(this@InputRouter) {
last = raw
if (identity) return@synchronized raw
result.clear()
for (route in routes) {
val x = raw.value(route.control)
val y = raw.secondValue(route.control)
val target = route.update(x, y) ?: continue
result.add(target, x, y)
}
if (nativeCandidate && enabled && routes.none { it.blocked }) identity = true
result
}
fun release() = synchronized(this@InputRouter) {
for (route in routes) route.release()
last = null
result.clear()
refreshIdentity()
}
fun close() = synchronized(this@InputRouter) {
release()
for (route in routes) route.close()
pads.remove(this)
}
}
}
private val padButtons = intArrayOf(PadState.A, PadState.B, PadState.X, PadState.Y, PadState.LB, PadState.RB,
PadState.SELECT, PadState.START, PadState.L3, PadState.R3, PadState.GUIDE, PadState.QAM)
internal fun PadState.value(control: InputControl): Float = when (control) {
InputControl.UP -> if (up) 1f else 0f
InputControl.RIGHT -> if (right) 1f else 0f
InputControl.DOWN -> if (down) 1f else 0f
InputControl.LEFT -> if (left) 1f else 0f
InputControl.LT -> leftTrigger
InputControl.RT -> rightTrigger
InputControl.LS -> leftX
InputControl.RS -> rightX
else -> if (control.ordinal < padButtons.size && isDown(padButtons[control.ordinal])) 1f else 0f
}
internal fun PadState.secondValue(control: InputControl): Float = when (control) {
InputControl.LS -> leftY
InputControl.RS -> rightY
else -> 0f
}
internal fun PadState.add(control: InputControl, x: Float, y: Float = 0f) {
when (control) {
InputControl.UP -> up = up || x > 0.5f
InputControl.RIGHT -> right = right || x > 0.5f
InputControl.DOWN -> down = down || x > 0.5f
InputControl.LEFT -> left = left || x > 0.5f
InputControl.LT -> leftTrigger = maxOf(leftTrigger, x)
InputControl.RT -> rightTrigger = maxOf(rightTrigger, x)
InputControl.LS -> if (x * x + y * y > leftX * leftX + leftY * leftY) { leftX = x; leftY = y }
InputControl.RS -> if (x * x + y * y > rightX * rightX + rightY * rightY) { rightX = x; rightY = y }
else -> if (control.ordinal < padButtons.size && x > 0.5f) press(padButtons[control.ordinal], true)
}
}
@@ -1,80 +0,0 @@
package com.droiddeck.launcher.input
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
enum class DeckPanel(val id: String, val layout: String, val bindings: String, @StringRes val label: Int) {
GAMEPAD("gamepad", "builtin.deck.gamepad", "preset.deck.gamepad", R.string.deck_tab_gamepad),
GRIPS("grips", "builtin.deck.grips", "preset.deck.grips", R.string.deck_tab_grips),
LEFT("left", "builtin.deck.left", "preset.deck.left", R.string.deck_tab_left),
RIGHT("right", "builtin.deck.right", "preset.deck.right", R.string.deck_tab_right),
BOTH("both", InputLibraries.DECK, InputStockLayouts.DECK, R.string.deck_tab_both);
companion object {
fun from(id: String?) = entries.firstOrNull { it.id == id } ?: BOTH
}
}
internal object InputStockLayouts {
private val NEUTRAL = InputSurfaceStyle(tint = null)
const val DECK = "preset.deck"
const val TRACKPAD = "preset.trackpad"
private fun grips() = listOf(InputControl.L4, InputControl.L5, InputControl.R5, InputControl.R4).mapIndexed { i, c ->
InputSurfaceElement(c, .02f + i * .245f, .78f, .225f, .20f)
}
private fun pads() = listOf(InputSurfaceElement(InputControl.LEFT_PAD, .02f, .02f, .47f, .74f),
InputSurfaceElement(InputControl.RIGHT_PAD, .51f, .02f, .47f, .74f))
private fun gripGrid() = listOf(InputControl.L4 to (.02f to .02f), InputControl.L5 to (.02f to .51f),
InputControl.R4 to (.51f to .02f), InputControl.R5 to (.51f to .51f)).map { (c, at) ->
InputSurfaceElement(c, at.first, at.second, .47f, .47f)
}
private fun pad(right: Boolean) = listOf(
InputSurfaceElement(if (right) InputControl.RIGHT_PAD else InputControl.LEFT_PAD, .02f, .02f, .96f, .74f),
InputSurfaceElement(if (right) InputControl.RIGHT_CLICK else InputControl.LEFT_CLICK, .02f, .78f, .96f, .20f),
)
private fun gamepad(): List<InputSurfaceElement> {
fun round(c: InputControl, x: Float, y: Float, size: Float) = InputSurfaceElement(c, x, y, size, size, buttonShape = InputButtonShape.CIRCLE)
fun pill(c: InputControl, x: Float) = InputSurfaceElement(c, x, .02f, .2f, .12f, buttonShape = InputButtonShape.PILL)
return listOf(pill(InputControl.LT, .02f), pill(InputControl.LB, .24f), pill(InputControl.RB, .56f), pill(InputControl.RT, .78f),
InputSurfaceElement(InputControl.LS, .02f, .17f, .4f, .4f),
InputSurfaceElement(InputControl.RIGHT_PAD, .58f, .59f, .4f, .39f),
round(InputControl.UP, .14f, .6f, .12f), round(InputControl.LEFT, .03f, .73f, .12f),
round(InputControl.RIGHT, .25f, .73f, .12f), round(InputControl.DOWN, .14f, .86f, .12f),
round(InputControl.Y, .72f, .17f, .12f), round(InputControl.X, .61f, .3f, .12f),
round(InputControl.B, .83f, .3f, .12f), round(InputControl.A, .72f, .43f, .12f),
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(
InputSurfaceElement(InputControl.RIGHT_PAD, .02f, .02f, .96f, .96f, mouseGestures = true)), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(DeckPanel.GAMEPAD.layout, "Deck gamepad", gamepad(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(DeckPanel.GRIPS.layout, "Deck grips", gripGrid(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(DeckPanel.LEFT.layout, "Deck left trackpad", pad(right = false), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(DeckPanel.RIGHT.layout, "Deck right trackpad", pad(right = true), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
)
fun actions(layout: SavedInputLayout): Map<String, InputAction> = (if (layout.id == InputLibraries.TRACKPAD) mapOf(
"legacy:right-pad" to InputAction.Pointer(), "legacy:a" to InputAction.MouseButton(272), "legacy:b" to InputAction.MouseButton(273),
) else emptyMap()).filterKeys { id -> layout.elements.any { it.id == id } }
fun clicks(layout: SavedInputLayout): Map<String, InputAction> = if (layout.id == InputLibraries.TRACKPAD)
mapOf("legacy:right-pad" to InputAction.MouseButton(272)) else emptyMap()
fun presets(layouts: List<SavedInputLayout>) = layouts.map { layout ->
val desktop = layout.id == InputLibraries.TRACKPAD
val id = if (desktop) TRACKPAD else DeckPanel.entries.first { it.layout == layout.id }.bindings
SavedInputBindings(id, layout.name, false, layout.id,
controls = InputProfile.defaults(if (desktop) InputProfile.DESKTOP else InputProfile.GAME).bindings[InputRole.SECONDARY].orEmpty(),
elements = actions(layout), clicks = clicks(layout))
}
}
@@ -1,122 +0,0 @@
package com.droiddeck.launcher.input
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
enum class InputElementKind(val control: InputControl) {
BUTTON(InputControl.A), STICK(InputControl.LS), TRACKPAD(InputControl.LEFT_PAD),
DPAD(InputControl.LS), TRIGGER(InputControl.LT);
companion object {
fun from(control: InputControl) = when {
control.isTrackpad -> TRACKPAD
control.dimensions == 2 -> STICK
control == InputControl.LT || control == InputControl.RT -> TRIGGER
else -> BUTTON
}
}
}
enum class InputButtonShape { ROUNDED, PILL, CIRCLE, RECTANGLE }
data class InputSurfaceElement(
val control: InputControl, val x: Float, val y: Float, val width: Float, val height: Float,
val id: String = "legacy:${control.id}",
val kind: InputElementKind = InputElementKind.from(control),
val name: String = control.label,
val floating: Boolean = true,
val hideWhenIdle: Boolean = false,
val mouseGestures: Boolean = false,
val toggle: Boolean = false,
val icon: Int = 0,
val image: String = "",
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
fun bounded() = copy(x = x.coerceIn(0f, 0.97f), y = y.coerceIn(0f, 0.97f),
width = width.coerceIn(0.03f, (1f - x.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)), height = height.coerceIn(0.03f, (1f - y.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)))
}
internal val QUICK_BUTTONS = listOf(InputCommand.STEAM, InputCommand.QUICK_ACCESS, InputCommand.KEYBOARD)
val InputSurfaceElement.quickButton: InputCommand? get() = when {
id == "legacy:${InputControl.GUIDE.id}" -> InputCommand.STEAM
id == "legacy:${InputControl.QAM.id}" -> InputCommand.QUICK_ACCESS
id == "builtin:keyboard" -> InputCommand.KEYBOARD
else -> null
}
internal fun List<InputSurfaceElement>.scaled(factor: Float): List<InputSurfaceElement> = map { e ->
fun anchor(centre: Float) = when { centre < 1f / 3f -> 0f; centre > 2f / 3f -> 1f; else -> .5f }
val w = (e.width * factor).coerceIn(.03f, 1f)
val h = (e.height * factor).coerceIn(.03f, 1f)
val cx = e.x + e.width / 2
val cy = e.y + e.height / 2
val ax = anchor(cx)
val ay = anchor(cy)
e.copy(x = (ax + (cx - ax) * factor - w / 2).coerceIn(0f, 1f - w), y = (ay + (cy - ay) * factor - h / 2).coerceIn(0f, 1f - h),
width = w, height = h).bounded()
}
internal fun List<InputSurfaceElement>.withPositionsOf(defaults: List<InputSurfaceElement>): List<InputSurfaceElement> {
val byId = defaults.associateBy { it.id }
return map { e -> byId[e.id]?.let { d -> e.copy(x = d.x, y = d.y, width = d.width, height = d.height).bounded() } ?: e }
}
object InputSurfaceLayout {
private fun prefs(context: Context) = context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE)
fun resetAll(context: Context) { prefs(context).edit().clear().apply() }
fun defaults() = listOf(
InputSurfaceElement(InputControl.LEFT_PAD, 0.02f, 0.02f, 0.46f, 0.65f),
InputSurfaceElement(InputControl.RIGHT_PAD, 0.52f, 0.02f, 0.46f, 0.65f),
InputSurfaceElement(InputControl.A, 0.02f, 0.72f, 0.20f, 0.25f),
InputSurfaceElement(InputControl.B, 0.26f, 0.72f, 0.20f, 0.25f),
InputSurfaceElement(InputControl.SELECT, 0.52f, 0.72f, 0.20f, 0.25f),
InputSurfaceElement(InputControl.START, 0.76f, 0.72f, 0.22f, 0.25f),
)
fun read(context: Context, profile: String, role: InputRole = InputRole.SECONDARY): List<InputSurfaceElement> =
InputLibraries.read(context).layout(profile, role)?.elements.orEmpty()
internal fun hasLegacy(context: Context, profile: String) = prefs(context).contains(profile)
internal fun readLegacy(context: Context, profile: String): List<InputSurfaceElement> {
val text = prefs(context).getString(profile, null) ?: return defaults()
return runCatching {
val doc = JSONObject(text)
require(doc.getInt("version") == 1)
val elements = doc.getJSONArray("elements")
(0 until minOf(elements.length(), 28)).mapNotNull { i ->
val item = elements.optJSONObject(i) ?: return@mapNotNull null
val control = InputControl.fromId(item.optString("control")) ?: return@mapNotNull null
val values = listOf("x", "y", "w", "h").map { item.optDouble(it, Double.NaN).toFloat() }
if (values.any { !it.isFinite() }) return@mapNotNull null
InputSurfaceElement(control, values[0], values[1], values[2], values[3]).bounded()
}.distinctBy { it.control }
}.getOrElse { defaults() }
}
fun save(context: Context, profile: String, layout: List<InputSurfaceElement>) {
InputLibraries.update(context) { library ->
val saved = library.layout(profile, InputRole.SECONDARY) ?: return@update library
library.withLayout(saved.copy(elements = layout.map { it.bounded() }))
}
}
internal fun saveLegacy(context: Context, profile: String, layout: List<InputSurfaceElement>) {
val array = JSONArray()
for (element in layout.distinctBy { it.control }.take(28)) {
val e = element.bounded()
array.put(JSONObject().put("control", e.control.id).put("x", e.x.toDouble()).put("y", e.y.toDouble())
.put("w", e.width.toDouble()).put("h", e.height.toDouble()))
}
prefs(context).edit().putString(profile, JSONObject().put("version", 1).put("elements", array).toString()).apply()
}
}
@@ -1,24 +0,0 @@
package com.droiddeck.launcher.input
import kotlin.math.abs
internal data class SurfaceSnapResult(val element: InputSurfaceElement, val x: Float? = null, val y: Float? = null)
internal fun snapSurfaceElement(element: InputSurfaceElement, others: List<InputSurfaceElement>, thresholdX: Float, thresholdY: Float,
corner: Int = 0): SurfaceSnapResult {
fun axis(start: Float, size: Float, targets: List<Float>, threshold: Float, low: Boolean, high: Boolean): Triple<Float, Float, Float?> {
val anchors = when { low -> listOf(0f); high -> listOf(size); else -> listOf(0f, size / 2f, size) }
val candidate = anchors.flatMap { anchor -> targets.map { target -> Triple(target - start - anchor, target, anchor) } }
.filter { abs(it.first) <= threshold }.minByOrNull { abs(it.first) } ?: return Triple(start, size, null)
val offset = candidate.first
val nextStart = if (high) start else start + offset
val nextSize = when { low -> size - offset; high -> size + offset; else -> size }
if (nextStart < 0f || nextSize < .03f || nextStart + nextSize > 1.00001f) return Triple(start, size, null)
return Triple(nextStart.coerceAtLeast(0f), nextSize, candidate.second)
}
val xTargets = listOf(0f, .5f, 1f) + others.flatMap { listOf(it.x, it.x + it.width / 2f, it.x + it.width) }
val yTargets = listOf(0f, .5f, 1f) + others.flatMap { listOf(it.y, it.y + it.height / 2f, it.y + it.height) }
val x = axis(element.x, element.width, xTargets, thresholdX, corner and 1 != 0, corner and 2 != 0)
val y = axis(element.y, element.height, yTargets, thresholdY, corner and 4 != 0, corner and 8 != 0)
return SurfaceSnapResult(element.copy(x = x.first, y = y.first, width = x.second, height = y.second).bounded(), x.third, y.third)
}
@@ -1,124 +0,0 @@
package com.droiddeck.launcher.input
class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole, val element: InputSurfaceElement) {
private val router = pad.inputRouter
private val route = router?.channel(role, element.control, element = element)
private val deck = DeckControls.source(role, routed = true)
private val clickDeck = DeckControls.source(role, routed = true)
private val clickOwner = Any()
private val clickRoute = if (element.hasClick) router?.channel(role, InputControl.A, element = element, elementClick = true) else null
private val gestureWheel = if (element.mouseGestures) router?.channel(role, InputControl.RIGHT_PAD, fixedAction = InputAction.Scroll()) else null
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) {
if (closed) return
val timing = InputTiming.begin()
route?.pointerDelta(dx, dy)
InputTiming.end(timing)
}
fun gestureWheel(steps: Int) { if (!closed && route?.acceptsPointerGesture() == true) gestureWheel?.wheelSteps(steps) }
fun gestureButton(code: Int, down: Boolean) {
if (closed || down && route?.acceptsPointerGesture() != true) return
when (code) {
272 -> click(down)
273 -> gestureRight?.update(if (down) 1f else 0f)
274 -> gestureMiddle?.update(if (down) 1f else 0f)
}
}
private var nativePad = false
private var nativeDeck = false
private var clickPad = false
private var clickDeckActive = false
private var closed = false
private var fingerDown = false
private var latchBlocked = false
var latched = false
private set
var onStateChanged: () -> Unit = {}
private fun resetLatch() { latched = false; latchBlocked = fingerDown; onStateChanged() }
private val snapshot: () -> Unit = { releaseNative(); resetLatch(); if (element.toggle) route?.release() }
init { router?.beforeProfileChange(snapshot) }
fun update(value: Float, y: Float = 0f, touching: Boolean = false) {
if (closed) return
val timing = InputTiming.begin()
val x = if (element.toggle && route?.action !is InputAction.Command && route?.action !is InputAction.Disabled) {
val down = value > .5f
if (!down) latchBlocked = false
if (down && !fingerDown && !latchBlocked) latched = !latched
fingerDown = down
if (latched) 1f else 0f
} else value
val target = if (router == null) element.control else route?.update(x, y, touching)
val toPad = target?.isPad == true
val toDeck = target != null && !target.isPad
if (toPad || nativePad) writePad(this, target) { state ->
state.clear()
if (toPad) {
if (element.kind == InputElementKind.DPAD) {
state.up = y < -.5f; state.down = y > .5f; state.left = x < -.5f; state.right = x > .5f
} else state.add(target, x, y)
}
}
if (toDeck || nativeDeck) deck.native(if (toDeck) target else null, x, y, touching)
nativePad = toPad; nativeDeck = toDeck
if (element.toggle) onStateChanged()
InputTiming.end(timing)
}
fun cancel() {
if (closed) return
route?.takeIf { it.action is InputAction.Command }?.release()
update(0f)
}
fun click(down: Boolean) {
if (closed) return
val timing = InputTiming.begin()
val target = if (router == null) when (element.control) { InputControl.LS -> InputControl.L3; InputControl.RS -> InputControl.R3; else -> null } else clickRoute?.update(if (down) 1f else 0f)
val toPad = target?.isPad == true
val toDeck = target != null && !target.isPad
if (toPad || clickPad) writePad(clickOwner, target) { state ->
state.clear()
if (toPad && down) state.add(target, 1f)
}
if (toDeck || clickDeckActive) clickDeck.native(if (toDeck) target else null, if (down) 1f else 0f, 0f, false)
clickPad = toPad; clickDeckActive = toDeck
InputTiming.end(timing)
}
private fun writePad(owner: Any, target: InputControl?, mutation: (PadState) -> Unit) {
if (router != null) pad.applyRoutedTouch(owner, role, mutation)
else pad.applyTouch(owner, role, when (target) { InputControl.LS -> PadBridge.LEFT_STICK; InputControl.RS -> PadBridge.RIGHT_STICK; else -> 0 }, mutation)
}
private fun releaseNative() {
if (nativePad) pad.releaseTouch(this)
if (nativeDeck) deck.release()
if (clickPad) pad.releaseTouch(clickOwner)
if (clickDeckActive) clickDeck.release()
clickPad = false; clickDeckActive = false
nativePad = false; nativeDeck = false
}
fun close() {
if (closed) return
closed = true
router?.removeSnapshot(snapshot)
releaseNative(); resetLatch()
route?.close(); clickRoute?.close(); gestureWheel?.close(); gestureRight?.close(); gestureMiddle?.close(); deck.close(); clickDeck.close()
}
fun release() {
if (closed) return
releaseNative(); resetLatch()
route?.release(); clickRoute?.release(); gestureWheel?.release(); gestureRight?.release(); gestureMiddle?.release()
}
}
@@ -1,33 +0,0 @@
package com.droiddeck.launcher.input
import java.util.Locale
object InputTiming {
@JvmField @Volatile var enabled = false
private val samples = LongArray(4096)
private var count = 0L
private var next = 0
@JvmStatic fun begin(): Long = if (enabled) System.nanoTime() else 0L
@JvmStatic fun end(start: Long) {
if (start == 0L) return
val elapsed = System.nanoTime() - start
synchronized(this) {
samples[next] = elapsed
next = (next + 1) % samples.size
count++
}
}
@Synchronized fun reset() { count = 0; next = 0; samples.fill(0) }
@Synchronized fun report(): String {
val size = minOf(count, samples.size.toLong()).toInt()
if (size == 0) return "No input samples"
val values = samples.copyOf(size).sortedArray()
fun percentile(p: Double) = values[((size - 1) * p).toInt()] / 1000.0
return String.format(Locale.ROOT, "Dispatch to publication (µs): p50 %.1f, p95 %.1f, p99 %.1f; %d samples, last %d retained",
percentile(.5), percentile(.95), percentile(.99), count, size)
}
}
@@ -1,352 +0,0 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Rect
import kotlin.math.*
internal class LegacyGamepadLayout(private val context: Context, private val width: Int, private val height: Int, private val safe: Rect = Rect(),
private val scale: Float = 1f) {
private val resources get() = context.resources
private val settings = ControllerPrefs.read(context)
private var fit = 1f
private class Control(val id: String, val group: String, val stick: Int, val radiusDp: Float, val offsetXDp: Float, val offsetYDp: Float) {
var radius = 0f
var cx = 0f
var cy = 0f
val halfW get() = if (id in shoulderIds) radius * SHOULDER_WIDTH else radius
val halfH get() = if (id in shoulderIds) radius * SHOULDER_HEIGHT else radius
}
private val controls = listOf(
Control("up", "dpad", -1, 30f, 0f, -50f),
Control("right", "dpad", -1, 30f, 50f, 0f),
Control("down", "dpad", -1, 30f, 0f, 50f),
Control("left", "dpad", -1, 30f, -50f, 0f),
Control("ls", "ls", 0, 48f, 0f, 0f),
Control("rs", "rs", 1, 48f, 0f, 0f),
Control("a", "face", -1, 30f, 0f, 50f),
Control("b", "face", -1, 30f, 50f, 0f),
Control("x", "face", -1, 30f, -50f, 0f),
Control("y", "face", -1, 30f, 0f, -50f),
Control("lb", "lb", -1, 24f, 0f, 0f),
Control("rb", "rb", -1, 24f, 0f, 0f),
Control("lt", "lt", -1, 24f, 0f, 0f),
Control("rt", "rt", -1, 24f, 0f, 0f),
Control("select", "select", -1, 20f, 0f, 0f),
Control("start", "start", -1, 20f, 0f, 0f),
Control("guide", "guide", -1, 22f, 0f, 0f),
Control("qam", "qam", -1, 22f, 0f, 0f),
)
private val groups = controls.map { it.group }.distinct()
private fun dp(value: Float) = value * resources.displayMetrics.density
private fun scaled(value: Float) = dp(value) * settings.size / 100f * scale * fit
private fun fitScale(): Float {
val metrics = resources.displayMetrics
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
}
private fun pxPerMm(dpi: Float): Float {
val nominal = resources.displayMetrics.densityDpi.toFloat()
return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
}
private fun placeAuto(w: Float, h: Float) {
val metrics = resources.displayMetrics
val mmX = pxPerMm(metrics.xdpi)
val mmY = pxPerMm(metrics.ydpi)
val usableW = w - safe.left - safe.right
val usableH = h - safe.top - safe.bottom
val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
val gap = dp(12f)
val floor = BOTTOM_MARGIN_MM * mmY
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
put("ls", safe.left + inboard, restY)
put("face", w - safe.right - inboard, restY)
besideBelow("dpad", "ls", 1f, gap, floor)
besideBelow("rs", "face", -1f, gap, floor)
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
val shoulderY = if (phone) safe.top + floor + halfH("lb")
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
val bottom = h - safe.bottom - floor - extent("guide")
val spread = extent("guide") + gap / 2f
put("guide", w / 2f - spread, bottom)
put("qam", w / 2f + spread, bottom)
val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
put("select", inner, restY)
put("start", w - inner, restY)
if (phone) {
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
put("select", top, shoulderY)
put("start", w - top, shoulderY)
}
if (crowded("select", gap) || crowded("start", gap)) {
above("select", "dpad", gap)
val (sx, sy) = centre("select")
put("start", w - sx, sy)
if (crowded("start", gap)) above("start", "rs", gap)
}
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val row = centre("select").second
put("guide", w / 2f - spread, row)
put("qam", w / 2f + spread, row)
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val top = safe.top + dp(8f) + extent("guide")
put("guide", w / 2f - spread, top)
put("qam", w / 2f + spread, top)
}
}
}
private fun drop(group: String, anchor: String, gap: Float): Float =
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
val (ax, ay) = centre(anchor)
val reach = extent(anchor) + extent(group) + gap
val floor = height - safe.bottom - margin - extent(group)
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).toFloat(), floor - ay)
val dx = sqrt(max(0f, reach * reach - dy * dy))
put(group, ax + inward * dx, ay + dy)
}
private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
val (ax, _) = centre(anchor)
val spread = extent(bumper) + gap / 2f
put(bumper, ax + inward * spread, y)
put(trigger, ax - inward * spread, y)
}
private fun above(group: String, anchor: String, gap: Float) {
val (ax, ay) = centre(anchor)
put(group, ax, ay - extent(anchor) - gap - extent(group))
}
private fun crowded(group: String, gap: Float, space: Float = gap / 2f, except: String? = null): Boolean {
val (x, y) = centre(group)
return groups.any { other ->
if (other == group || other == except) return@any false
val (ox, oy) = centre(other)
val dx = x - ox
val dy = y - oy
sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + space
}
}
private fun members(group: String) = controls.filter { it.group == group }
private fun centre(group: String): Pair<Float, Float> {
val first = members(group).first()
return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
}
private fun extent(group: String): Float = members(group).maxOf { control ->
sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.halfW
}
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
private fun put(group: String, x: Float, y: Float) {
for (control in members(group)) {
control.cx = x + scaled(control.offsetXDp)
control.cy = y + scaled(control.offsetYDp)
}
}
private fun clamp(group: String) {
val list = members(group)
val margin = dp(4f)
val minX = list.minOf { it.cx - it.halfW } - safe.left - margin
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
val maxY = list.maxOf { it.cy + it.halfH } - (height - safe.bottom - margin)
val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
if (dx == 0f && dy == 0f) return
for (control in list) { control.cx += dx; control.cy += dy }
}
fun elements(): List<InputSurfaceElement> {
fit = fitScale()
controls.forEach { it.radius = scaled(it.radiusDp) }
placeAuto(width.toFloat(), height.toFloat())
for ((group, position) in ControllerPrefs.layout(context, width, height)) {
if (group in groups && position.first.isFinite() && position.second.isFinite()) put(group, position.first * width, position.second * height)
}
groups.forEach(::clamp)
val result = controls.filter {
settings.mapping[it.id] != ControllerPrefs.OFF && (it.id != "guide" || settings.steamButton) && (it.id != "qam" || settings.qamButton)
}.map { c ->
InputSurfaceElement(requireNotNull(InputControl.fromId(c.id)), (c.cx - c.halfW) / width, (c.cy - c.halfH) / height,
2 * c.halfW / width, 2 * c.halfH / height, floating = settings.adaptiveSticks, hideWhenIdle = c.stick >= 0 && settings.adaptiveSticks,
buttonShape = if (c.id in shoulderIds) InputButtonShape.PILL else InputButtonShape.CIRCLE).let(::widenStick).bounded()
}.toMutableList()
if (settings.keyboardButton) result += keyboard()
return result
}
fun regenerate(stored: List<InputSurfaceElement>): List<InputSurfaceElement> = withGeometry(stored, elements())
fun rescale(stored: List<InputSurfaceElement>, from: LegacyGamepadLayout): List<InputSurfaceElement> {
val before = from.elements().associateBy { it.id }
val after = elements().associateBy { it.id }
return stored.map { e ->
val b = before[e.id] ?: return@map e
val a = after[e.id] ?: return@map e
val w = (a.width * e.width / b.width).coerceIn(.03f, 1f)
val h = (a.height * e.height / b.height).coerceIn(.03f, 1f)
val cx = a.x + a.width / 2 + (e.x + e.width / 2 - b.x - b.width / 2)
val cy = a.y + a.height / 2 + (e.y + e.height / 2 - b.y - b.height / 2)
e.copy(x = (cx - w / 2).coerceIn(0f, 1f - w), y = (cy - h / 2).coerceIn(0f, 1f - h), width = w, height = h).bounded()
}
}
fun buttonsOnly(stored: List<InputSurfaceElement>): List<InputSurfaceElement> {
val inset = dp(BUTTONS_ONLY_INSET_DP)
val bottom = height - safe.bottom - inset
val radius = dp(BUTTONS_ONLY_RADIUS_DP)
fun pick(control: InputControl, shown: Boolean) = stored.firstOrNull { it.id == "legacy:${control.id}" }
?: stored.firstOrNull { it.control == control && it.kind == InputElementKind.BUTTON }
?: InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE).takeIf { shown }
return listOfNotNull(
pick(InputControl.GUIDE, settings.steamButton)?.let { circle(it, safe.left + inset, bottom, radius) },
pick(InputControl.QAM, settings.qamButton)?.let { circle(it, width - safe.right - inset, bottom, radius) },
stored.firstOrNull { it.id == KEYBOARD_ID }?.let { circle(it, width / 2f, bottom, dp(KEYBOARD_RADIUS_DP)) },
)
}
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) {
val b = boxes[slot] ?: return
placed += InputSurfaceElement(control, b.left / width, b.top / height, b.width / width, b.height / height,
floating = false, buttonShape = shape).bounded()
}
fun cross(controls: List<InputControl>, slot: GamepadGeometry.Slot) {
val b = boxes[slot] ?: return
val side = min(b.width, b.height)
val spread = side * CROSS_SPREAD
val radius = side * CROSS_RADIUS
controls.zip(listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)).forEach { (control, direction) ->
placed += circle(InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE),
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
}
}
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
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)
box(InputControl.RB, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER, InputButtonShape.PILL)
box(InputControl.RT, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER, InputButtonShape.PILL)
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) }
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) =
e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height).bounded()
private fun withGeometry(stored: List<InputSurfaceElement>, placed: List<InputSurfaceElement>): List<InputSurfaceElement> {
val byId = placed.associateBy { it.id }
return stored.mapNotNull { e -> byId[e.id]?.let { e.copy(x = it.x, y = it.y, width = it.width, height = it.height) } }
}
private fun keyboard(): InputSurfaceElement {
val radius = dp(KEYBOARD_RADIUS_DP)
val cx = safe.left + dp(KEYBOARD_X_DP)
val cy = height - safe.bottom - dp(KEYBOARD_Y_DP)
return InputSurfaceElement(InputControl.A, (cx - radius) / width, (cy - radius) / height, 2 * radius / width, 2 * radius / height,
KEYBOARD_ID, name = "Keyboard", icon = InputButtonIcons.KEYBOARD, buttonShape = InputButtonShape.CIRCLE).bounded()
}
fun restyle(elements: List<InputSurfaceElement>): List<InputSurfaceElement> {
val dpad = elements.filter { it.id == "legacy:${it.control.id}" && it.control in DPAD && it.buttonShape == InputButtonShape.ROUNDED }
val centreX = dpad.map { (it.x + it.width / 2f) * width }.average().toFloat()
val centreY = dpad.map { (it.y + it.height / 2f) * height }.average().toFloat()
val resize = untouchedDpad(dpad, centreX, centreY)
return elements.map { e ->
when {
e.id == KEYBOARD_ID && e.icon == 0 && e.name == "Keyboard" && e.buttonShape == InputButtonShape.ROUNDED &&
e.x == .02f && e.y == .9f && e.width == .12f && e.height == .08f -> keyboard()
e.id == "legacy:${e.control.id}" && e.buttonShape == InputButtonShape.ROUNDED -> {
val round = e.copy(buttonShape = if (e.control.id in shoulderIds) InputButtonShape.PILL else InputButtonShape.CIRCLE)
(if (resize && e.control in DPAD) resizeDpad(round, centreX, centreY) else widenStick(round)).bounded()
}
else -> e
}
}
}
private fun untouchedDpad(dpad: List<InputSurfaceElement>, centreX: Float, centreY: Float): Boolean {
if (dpad.size != DPAD.size) return false
val radius = dpad[0].width * width / 2f
val tolerance = 1.5f
val offset = radius * LEGACY_DPAD_OFFSET_DP / LEGACY_DPAD_RADIUS_DP
return dpad.all { e ->
val old = controls.first { it.id == e.control.id }
val dx = (e.x + e.width / 2f) * width - centreX
val dy = (e.y + e.height / 2f) * height - centreY
abs(e.width * width / 2f - radius) <= tolerance && abs(e.height * height / 2f - radius) <= tolerance &&
abs(dx - offset * sign(old.offsetXDp)) <= tolerance && abs(dy - offset * sign(old.offsetYDp)) <= tolerance
}
}
private fun resizeDpad(e: InputSurfaceElement, centreX: Float, centreY: Float): InputSurfaceElement {
val old = controls.first { it.id == e.control.id }
val radius = e.width * width / 2f * old.radiusDp / LEGACY_DPAD_RADIUS_DP
val cx = centreX + ((e.x + e.width / 2f) * width - centreX) * abs(old.offsetXDp + old.offsetYDp) / LEGACY_DPAD_OFFSET_DP
val cy = centreY + ((e.y + e.height / 2f) * height - centreY) * abs(old.offsetXDp + old.offsetYDp) / LEGACY_DPAD_OFFSET_DP
return e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height)
}
private fun widenStick(e: InputSurfaceElement): InputSurfaceElement {
val extra = e.width * (STICK_REACH - 1f)
return when (e.control) {
InputControl.LS -> (e.x - extra).coerceAtLeast(0f).let { x -> e.copy(x = x, width = e.x + e.width - x) }
InputControl.RS -> e.copy(width = (e.width + extra).coerceAtMost(1f - e.x))
else -> e
}
}
private companion object {
const val MIN_FACE_MM = 9.5f
const val FULL_SIZE_HEIGHT_MM = 85f
const val BOTTOM_MARGIN_MM = 5f
const val DROP_DEGREES = 35.0
const val PHONE_MAX_HEIGHT_MM = 90f
const val SHOULDER_WIDTH = 1.45f
const val SHOULDER_HEIGHT = 0.8f
val shoulderIds = setOf("lb", "rb", "lt", "rt")
const val STICK_REACH = 1.25f
const val LEGACY_DPAD_RADIUS_DP = 26f
const val LEGACY_DPAD_OFFSET_DP = 52f
val DPAD = setOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
const val KEYBOARD_ID = "builtin:keyboard"
const val KEYBOARD_RADIUS_DP = 18f
const val KEYBOARD_X_DP = 90f
const val KEYBOARD_Y_DP = 34f
const val BUTTONS_ONLY_RADIUS_DP = 30f
const val BUTTONS_ONLY_INSET_DP = 44f
const val CROSS_SPREAD = .30f
const val CROSS_RADIUS = .16f
val DPAD_ORDER = listOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
val FACE_ORDER = listOf(InputControl.Y, InputControl.B, InputControl.A, InputControl.X)
val GRIPS = listOf(InputControl.L4 to GamepadGeometry.Slot.L4, InputControl.L5 to GamepadGeometry.Slot.L5,
InputControl.R5 to GamepadGeometry.Slot.R5, InputControl.R4 to GamepadGeometry.Slot.R4)
}
}
@@ -1,130 +0,0 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.display.DisplayManager
import android.util.DisplayMetrics
import android.view.Display
import org.json.JSONObject
internal object LegacyScreenKeyMigration {
const val BACKUP = "legacy-screen-keys-backup"
const val MERGED = "legacy-screen-keys-merged"
private const val SHOWN = "migrated.screenkeys.0"
fun snapshot(context: Context): String? {
val values = ControllerPrefs.prefs(context).all.filterKeys { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") }
return values.takeIf { it.isNotEmpty() }?.let { JSONObject(it).toString() }
}
fun clean(context: Context) {
val prefs = ControllerPrefs.prefs(context)
prefs.edit().apply { prefs.all.keys.filter { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") }.forEach(::remove) }.apply()
}
@Suppress("DEPRECATION")
fun migrate(context: Context, library: InputLibrary, raw: String): InputLibrary {
val old = JSONObject(raw)
val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
val metrics = DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) }
val settings = ControllerPrefs.read(context)
val style = InputSurfaceStyle(settings.tint, settings.opacity)
val sets = buildList {
old.optString("screenKeys").takeIf { it.isNotBlank() }?.let { add("Screen keys (imported)" to it) }
old.keys().asSequence().filter { it.startsWith("screenLayout.") }.sorted().forEach { key ->
add((key.removePrefix("screenLayout.").ifBlank { "Screen keys" } + " (imported)").take(80) to old.getString(key))
}
}
var result = library
for ((index, set) in sets.withIndex()) {
val id = "migrated.screenkeys.$index"
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.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)
val profile = SavedInputProfile(id, set.first, base.base, base.parts + (InputRole.PRIMARY to InputPart(bindings.id, layout.id)))
result = result.withLayout(layout).withPreset(bindings).withProfile(profile)
}
return result.validate()
}
fun merge(library: InputLibrary, raw: String): InputLibrary {
val old = JSONObject(raw)
if (!old.optBoolean("screenKeysShown", true) || old.optString("screenKeys").isBlank()) return library
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" } ?: return library
val actions = library.bindings.firstOrNull { it.id == "$SHOWN.bindings" }?.elements.orEmpty()
var result = library
for (part in listOf(false, true).map { library.activePart(it, InputRole.PRIMARY) }.distinct()) {
val layout = result.layouts.firstOrNull { it.id == part.layout } ?: continue
val preset = result.preset(part.bindings)
if (!layout.editable || preset.layoutId != layout.id || layout.elements.any { e -> keys.elements.any { it.id == e.id } } ||
layout.elements.size + keys.elements.size > 64) continue
val added = keys.elements.mapNotNull { e -> actions[e.id]?.let { e.id to it } }.toMap()
result = result.withLayout(layout.copy(elements = layout.elements + keys.elements))
.withPreset(preset.copy(elements = added + preset.elements))
}
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)
internal fun parse(raw: String): List<LegacyScreenKey> = raw.split(';').filter { it.isNotBlank() }.map { entry ->
val parts = entry.split(':')
require(parts.size >= 2) { "Invalid legacy screen key" }
val codes = parts[0].split('+').map { it.toInt() }
val position = parts[1].split(',').map { it.toFloat() }
require(position.size == 3)
fun text(at: Int) = parts.getOrNull(at).orEmpty().replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
LegacyScreenKey(codes, position[0], position[1], position[2], text(2), text(3), text(4),
parts.getOrNull(5)?.toFloatOrNull() ?: 1f, parts.getOrNull(6)?.toIntOrNull() ?: 0, parts.getOrNull(7) == "1", parts.getOrNull(8) != "0")
}
internal fun convert(context: Context, keys: List<LegacyScreenKey>, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
keys.mapIndexed { index, key ->
val codes = key.codes
require(codes.all { it in 0..255 })
require(listOf(key.x, key.y, key.size).all { it.isFinite() })
val shape = key.shape
val kind = when (shape) { 5 -> InputElementKind.DPAD; 6 -> InputElementKind.STICK; 4 -> InputElementKind.TRIGGER; else -> InputElementKind.BUTTON }
val label = key.text.ifEmpty { key.glyph }.ifEmpty { codes.filter { it != 0 }.joinToString("+") { pcKeyLabel(context, it) } }.ifBlank { "Button" }.take(80)
val image = key.image
val icon = codes.singleOrNull()?.takeIf { InputButtonIcons.has(it) && key.text.isEmpty() && key.glyph.isEmpty() } ?: 0
val heightPx = key.size.coerceIn(24f, 140f) * density
val ratio = when { shape == 2 -> 1f; shape == 4 -> 1.7f; shape == 5 || shape == 6 || image.isNotEmpty() || icon != 0 -> 1.15f; else -> (.55f * label.length + 1.05f).coerceIn(1.7f, 4.6f) }
val w = (heightPx * ratio / width.coerceAtLeast(1)).coerceIn(.03f, 1f)
val h = (heightPx / height.coerceAtLeast(1)).coerceIn(.03f, 1f)
val element = InputSurfaceElement(kind.control, (key.x - w / 2).coerceIn(0f, 1f - w), (key.y - h / 2).coerceIn(0f, 1f - h), w, h,
id = "imported:$index", kind = kind, name = label, floating = false,
toggle = key.toggle && kind != InputElementKind.DPAD && kind != InputElementKind.STICK,
icon = icon, image = image, opacity = key.opacity.coerceIn(.2f, 1f),
buttonShape = when (shape) { 0 -> InputButtonShape.PILL; 2 -> InputButtonShape.CIRCLE; 3 -> InputButtonShape.RECTANGLE; else -> InputButtonShape.ROUNDED },
showLabel = key.labels).bounded()
val action = when (kind) {
InputElementKind.DPAD -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(1) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(3) { 0 })
InputElementKind.STICK -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(4) { 0 }, codes.getOrElse(6) { 0 }, listOf(1, 3, 5, 7).map { codes.getOrElse(it) { 0 } })
else -> codes.filter { it != 0 }.distinct().let { if (it.isEmpty()) InputAction.Disabled else InputAction.Keys(it) }
}
require(action.validFor(element))
element to action
}
}
internal data class LegacyScreenKey(
val codes: List<Int>, val x: Float, val y: Float, val size: Float,
val glyph: String = "", val text: String = "", val image: String = "",
val opacity: Float = 1f, val shape: Int = 0, val toggle: Boolean = false, val labels: Boolean = true,
)
@@ -3,181 +3,831 @@ package com.droiddeck.launcher.input
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.Typeface
import android.graphics.RectF
import android.os.Build
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
import android.widget.FrameLayout
import com.droiddeck.launcher.ui.InputSurfaceView
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayout.Dock? = null) : FrameLayout(context) {
private val surface = InputSurfaceView(context, pad, inputRole = InputRole.PRIMARY, haptics = dock != null)
private var layout: SavedInputLayout? = null
private val safe = Rect()
private var viewWidth = 0
private var viewHeight = 0
private val placements = object : LinkedHashMap<Placement, List<InputSurfaceElement>>(4, .75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Placement, List<InputSurfaceElement>>) = size > PLACEMENT_CACHE
}
private val quickToggle = QuickToggle(context)
var isButtonsOnly = false
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 }
@SuppressLint("ViewConstructor")
class OnScreenControls(
context: Context,
private val pad: PadBridge?,
private val editing: Boolean = false,
private val onKeyboard: (() -> Unit)? = null,
) : View(context) {
init {
addView(surface, LayoutParams(-1, -1))
val size = (QUICK_HIT_DP * 2 * resources.displayMetrics.density).toInt()
addView(quickToggle, LayoutParams(size, size, Gravity.START or Gravity.BOTTOM))
}
private class Control(
val id: String,
val group: String,
val stick: Int,
val radiusDp: Float,
val offsetXDp: Float,
val offsetYDp: Float,
) {
var target = id
var radius = 0f
var cx = 0f
var cy = 0f
var pressedBy = -1
var kx = 0f
var ky = 0f
var ax = 0f
var ay = 0f
var dirty = false
var clicked = false
var lastUp = 0L
var downAt = 0L
var downX = 0f
var downY = 0f
var travel = 0f
val wide = id in shoulderIds
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
fun updateLayout(value: SavedInputLayout?) {
if (layout == value) return
releaseAll()
layout = value
render()
}
fun setButtonsOnly(enabled: Boolean) {
if (isButtonsOnly == enabled) return
releaseAll()
isButtonsOnly = enabled
isQuickHidden = false
render()
}
fun setQuickHidden(hidden: Boolean) {
if (isButtonsOnly || isQuickHidden == hidden) return
releaseAll()
isQuickHidden = hidden
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> {
val key = Placement(layout, viewWidth, viewHeight, Rect(safe), isButtonsOnly)
return placements.getOrPut(key) {
OnScreenLayout.elements(context, layout, viewWidth, viewHeight, safe, isButtonsOnly, dock)
}
}
private fun render() {
val elements = placed()
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 || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
quickToggle.invalidate()
}
fun takesTouchAt(x: Float, y: Float) = surface.takesTouchAt(x, y) ||
quickToggle.visibility == VISIBLE && x >= quickToggle.left && x < quickToggle.right && y >= quickToggle.top && y < quickToggle.bottom
fun releaseAll() = surface.releaseInputs()
fun reload() { releaseAll(); placements.clear(); render() }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)
if (w != viewWidth || h != viewHeight) {
viewWidth = w
viewHeight = h
render()
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
override fun onDetachedFromWindow() { releaseAll(); super.onDetachedFromWindow() }
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
val offset = ((QUICK_CENTER_DP - QUICK_HIT_DP) * resources.displayMetrics.density).toInt()
val params = quickToggle.layoutParams as LayoutParams
val left = (cutout?.safeInsetLeft ?: 0) + offset
val bottom = (cutout?.safeInsetBottom ?: 0) + offset
if (params.leftMargin != left || params.bottomMargin != bottom) {
params.leftMargin = left
params.bottomMargin = bottom
quickToggle.layoutParams = params
}
val fresh = Rect(cutout?.safeInsetLeft ?: 0, if (dock == null) cutout?.safeInsetTop ?: 0 else 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
if (fresh != safe) {
safe.set(fresh)
render()
}
return super.onApplyWindowInsets(insets)
}
private inner class QuickToggle(context: Context) : View(context) {
var tint: Int? = ControllerPrefs.STEAM_BLUE
private val density = resources.displayMetrics.density
private var armed = false
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 1.5f * density }
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textSize = 22 * density
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
val dx = x - cx
val dy = y - cy
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
val hit = r * 1.25f + padding
return dx * dx + dy * dy <= hit * hit
}
override fun onDraw(canvas: Canvas) {
val x = width / 2f
val y = height / 2f
val radius = QUICK_RADIUS_DP * density
val color = tint
fill.color = if (color != null) InputSurfaceView.shade(color, 190, 0.12f) else (InputSurfaceView.SURFACE and 0x00ffffff) or (190 shl 24)
canvas.drawCircle(x, y, radius, fill)
stroke.color = if (color != null) InputSurfaceView.light(color, 150, 0.25f) else InputSurfaceView.LINE
canvas.drawCircle(x, y, radius, stroke)
text.color = if (color != null) InputSurfaceView.light(color, 210, 0.75f) else InputSurfaceView.TEXT
canvas.drawText(if (isQuickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> armed = true
MotionEvent.ACTION_CANCEL -> armed = false
MotionEvent.ACTION_UP -> {
if (armed && event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat()) {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
setQuickHidden(!isQuickHidden)
}
armed = false
}
}
fun drag(x: Float, y: Float): Boolean {
travel = max(travel, hypot(x - downX, y - downY))
var dx = x - ax
var dy = y - ay
val d = sqrt(dx * dx + dy * dy)
val atLimit = d > radius
if (atLimit) { dx = dx / d * radius; dy = dy / d * radius }
if (!atLimit && abs(dx - kx) < MIN_STICK_MOVE_PX && abs(dy - ky) < MIN_STICK_MOVE_PX) return false
kx = dx; ky = dy; dirty = true
return true
}
}
private val stickRedraw = ThrottledRedraw(this, STICK_REDRAW_MS)
private val tapSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop.toFloat()
private fun stickTap(control: Control, event: MotionEvent): Boolean {
val i = event.actionIndex
val travel = max(control.travel, hypot(event.getX(i) - control.downX, event.getY(i) - control.downY))
return stickTap(control.downAt, event.eventTime, travel, tapSlop, control.clicked,
event.actionMasked == MotionEvent.ACTION_CANCEL || (event.flags and MotionEvent.FLAG_CANCELED) != 0)
}
private val controls = listOf(
Control("up", "dpad", -1, 26f, 0f, -52f),
Control("right", "dpad", -1, 26f, 52f, 0f),
Control("down", "dpad", -1, 26f, 0f, 52f),
Control("left", "dpad", -1, 26f, -52f, 0f),
Control("ls", "ls", 0, 48f, 0f, 0f),
Control("rs", "rs", 1, 48f, 0f, 0f),
Control("a", "face", -1, 30f, 0f, 50f),
Control("b", "face", -1, 30f, 50f, 0f),
Control("x", "face", -1, 30f, -50f, 0f),
Control("y", "face", -1, 30f, 0f, -50f),
Control("lb", "lb", -1, 24f, 0f, 0f),
Control("rb", "rb", -1, 24f, 0f, 0f),
Control("lt", "lt", -1, 24f, 0f, 0f),
Control("rt", "rt", -1, 24f, 0f, 0f),
Control("select", "select", -1, 20f, 0f, 0f),
Control("start", "start", -1, 20f, 0f, 0f),
Control("guide", "guide", -1, 22f, 0f, 0f),
Control("qam", "qam", -1, 22f, 0f, 0f),
)
private val groups = controls.map { it.group }.distinct()
private var buttonsOnly = false
val isButtonsOnly: Boolean get() = buttonsOnly
private var quickHidden = false
private var quickPressedBy = -1
private var keyboardPressedBy = -1
private var forwardedPointerBits = 0
var onTouchMiss: ((MotionEvent) -> Unit)? = null
private var settings = ControllerPrefs.read(context)
private var snap = settings.snap
private var safe = Rect()
private var selected: String? = null
private var snapX: Float? = null
private var snapY: Float? = null
private var grabX = 0f
private var grabY = 0f
private var ignoreSaved = false
private var fit = 1f
private var idleFill = 0
private var heldFill = 0
private var idleStroke = 0
private var heldStroke = 0
private var idleText = 0
private var stickFill = 0
private var knobFill = 0
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val arrow = Path()
private val box = RectF()
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
init {
applySettings()
}
private fun dp(value: Float) = value * resources.displayMetrics.density
private fun scaled(value: Float) = dp(value) * settings.size / 100f * fit
private fun fitScale(): Float {
val metrics = resources.displayMetrics
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
}
fun reload() {
releaseAll()
settings = ControllerPrefs.read(context)
setSnap(settings.snap)
applySettings()
relayout()
}
private fun clearSnapGuides() {
if (snapX == null && snapY == null) return
snapX = null
snapY = null
invalidate()
}
private fun applySettings() {
for (control in controls) control.target = settings.mapping[control.id] ?: control.id
val tint = settings.tint
val alpha = settings.opacity / 100f
fun shade(a: Int, f: Float) = Color.argb((a * alpha).toInt(), (Color.red(tint) * f).toInt(), (Color.green(tint) * f).toInt(), (Color.blue(tint) * f).toInt())
fun light(a: Int, f: Float) = Color.argb(
(a * alpha).toInt(),
(Color.red(tint) + (255 - Color.red(tint)) * f).toInt(),
(Color.green(tint) + (255 - Color.green(tint)) * f).toInt(),
(Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt(),
)
idleFill = shade(80, 0.12f)
heldFill = shade(160, 1f)
idleStroke = light(150, 0.25f)
heldStroke = light(230, 0.7f)
idleText = light(210, 0.75f)
stickFill = shade(60, 0.12f)
knobFill = shade(120, 0.35f)
stroke.strokeWidth = dp(1.5f)
}
fun setButtonsOnly(enabled: Boolean) {
if (buttonsOnly == enabled) return
releaseAll()
buttonsOnly = enabled
quickHidden = false
relayout()
}
fun setQuickHidden(hidden: Boolean) {
if (buttonsOnly || editing || quickHidden == hidden) return
if (hidden) releaseAll()
quickHidden = hidden
invalidate()
}
fun setSnap(on: Boolean) {
snap = on
if (!on) clearSnapGuides()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
relayout()
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
val fresh = Rect(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
if (fresh != safe) { safe = fresh; relayout() }
return super.onApplyWindowInsets(insets)
}
override fun onDetachedFromWindow() {
stickRedraw.cancel()
releaseAll()
super.onDetachedFromWindow()
}
private fun relayout() {
if (width <= 0 || height <= 0) return
fit = if (buttonsOnly && !editing) 1f else fitScale()
for (control in controls) control.radius = if (buttonsOnly && !editing && (control.id == "guide" || control.id == "qam")) dp(30f) else scaled(control.radiusDp)
if (buttonsOnly && !editing) {
val inset = dp(44f)
put("guide", safe.left + inset, height - safe.bottom - inset)
put("qam", width - safe.right - inset, height - safe.bottom - inset)
} else {
placeAuto(width.toFloat(), height.toFloat())
if (!ignoreSaved) for ((group, pos) in ControllerPrefs.layout(context, width, height)) {
if (group in groups) put(group, pos.first * width, pos.second * height)
}
}
groups.forEach { clamp(it) }
invalidate()
}
private fun pxPerMm(dpi: Float): Float {
val nominal = resources.displayMetrics.densityDpi.toFloat()
return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
}
private fun placeAuto(w: Float, h: Float) {
val metrics = resources.displayMetrics
val mmX = pxPerMm(metrics.xdpi)
val mmY = pxPerMm(metrics.ydpi)
val usableW = w - safe.left - safe.right
val usableH = h - safe.top - safe.bottom
val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
val gap = dp(12f)
val floor = BOTTOM_MARGIN_MM * mmY
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
put("ls", safe.left + inboard, restY)
put("face", w - safe.right - inboard, restY)
besideBelow("dpad", "ls", 1f, gap, floor)
besideBelow("rs", "face", -1f, gap, floor)
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
val shoulderY = if (phone) safe.top + floor + halfH("lb")
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
val bottom = h - safe.bottom - floor - extent("guide")
val spread = extent("guide") + gap / 2f
put("guide", w / 2f - spread, bottom)
put("qam", w / 2f + spread, bottom)
val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
put("select", inner, restY)
put("start", w - inner, restY)
if (phone) {
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
put("select", top, shoulderY)
put("start", w - top, shoulderY)
}
if (crowded("select", gap) || crowded("start", gap)) {
above("select", "dpad", gap)
val (sx, sy) = centre("select")
put("start", w - sx, sy)
if (crowded("start", gap)) above("start", "rs", gap)
}
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val row = centre("select").second
put("guide", w / 2f - spread, row)
put("qam", w / 2f + spread, row)
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val top = safe.top + dp(8f) + extent("guide")
put("guide", w / 2f - spread, top)
put("qam", w / 2f + spread, top)
}
}
}
private fun drop(group: String, anchor: String, gap: Float): Float =
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
val (ax, ay) = centre(anchor)
val reach = extent(anchor) + extent(group) + gap
val floor = height - safe.bottom - margin - extent(group)
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).toFloat(), floor - ay)
val dx = sqrt(max(0f, reach * reach - dy * dy))
put(group, ax + inward * dx, ay + dy)
}
private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
val (ax, _) = centre(anchor)
val spread = extent(bumper) + gap / 2f
put(bumper, ax + inward * spread, y)
put(trigger, ax - inward * spread, y)
}
private fun above(group: String, anchor: String, gap: Float) {
val (ax, ay) = centre(anchor)
put(group, ax, ay - extent(anchor) - gap - extent(group))
}
private fun crowded(group: String, gap: Float, space: Float = gap / 2f, except: String? = null): Boolean {
val (x, y) = centre(group)
return groups.any { other ->
if (other == group || other == except) return@any false
val (ox, oy) = centre(other)
val dx = x - ox
val dy = y - oy
sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + space
}
}
private fun members(group: String) = controls.filter { it.group == group }
private fun centre(group: String): Pair<Float, Float> {
val first = members(group).first()
return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
}
private fun extent(group: String): Float = members(group).maxOf { control ->
sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.halfW
}
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
private fun put(group: String, x: Float, y: Float) {
for (control in members(group)) {
control.cx = x + scaled(control.offsetXDp)
control.cy = y + scaled(control.offsetYDp)
}
}
private fun clamp(group: String) {
val list = members(group)
val margin = dp(4f)
val minX = list.minOf { it.cx - it.halfW } - safe.left - margin
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
val maxY = list.maxOf { it.cy + it.halfH } - (height - safe.bottom - margin)
val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
if (dx == 0f && dy == 0f) return
for (control in list) { control.cx += dx; control.cy += dy }
}
fun saveLayout() {
if (width <= 0 || height <= 0) return
if (ignoreSaved) { ControllerPrefs.resetLayout(context, width, height); return }
ControllerPrefs.setLayout(context, width, height, groups.associateWith { group ->
val (x, y) = centre(group)
x / width to y / height
})
}
fun resetLayout() {
ControllerPrefs.resetLayout(context, width, height)
ignoreSaved = true
selected = null
relayout()
}
private fun label(control: Control): String = when (control.target) {
"ls" -> "L"
"rs" -> "R"
"select" -> "⧉"
"start" -> "☰"
"guide" -> if (buttonsOnly && !editing) "Steam" else "◉"
"qam" -> "⋯"
else -> control.target.uppercase()
}
override fun onDraw(canvas: Canvas) {
if (!quickHidden) for (control in controls) {
if (!isVisible(control)) continue
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
val held = control.pressedBy != -1
val radius = control.radius
if (control.stick >= 0) {
val bx = if (held) control.ax else control.cx
val by = if (held) control.ay else control.cy
fill.color = if (held) heldFill else stickFill
canvas.drawCircle(bx, by, radius, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(bx, by, radius, stroke)
val knob = radius * 0.46f
fill.color = if (held || control.clicked) heldFill else knobFill
canvas.drawCircle(bx + control.kx, by + control.ky, knob, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(bx + control.kx, by + control.ky, knob, stroke)
text.color = if (held) Color.WHITE else idleText
text.textSize = knob * 0.8f
canvas.drawText(label(control), bx + control.kx, by + control.ky + text.textSize * 0.35f, text)
} else if (control.wide) {
box.set(control.cx - control.halfW, control.cy - control.halfH, control.cx + control.halfW, control.cy + control.halfH)
val corner = control.halfH * 0.55f
fill.color = if (held) heldFill else idleFill
canvas.drawRoundRect(box, corner, corner, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawRoundRect(box, corner, corner, stroke)
text.color = if (held) Color.WHITE else idleText
text.textSize = control.halfH * 0.8f
canvas.drawText(label(control), control.cx, control.cy + text.textSize * 0.35f, text)
} else {
fill.color = if (held) heldFill else idleFill
canvas.drawCircle(control.cx, control.cy, radius, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(control.cx, control.cy, radius, stroke)
text.color = if (held) Color.WHITE else idleText
val direction = directions.indexOf(control.target)
if (direction >= 0) {
drawArrow(canvas, control.cx, control.cy, radius * 0.34f, direction)
continue
}
val name = label(control)
text.textSize = if (name.length > 2) dp(11f) else radius * if (name.length == 2) 0.62f else 0.85f
canvas.drawText(name, control.cx, control.cy + text.textSize * 0.35f, text)
}
}
if (editing) {
stroke.color = heldStroke
snapX?.let { canvas.drawLine(it, safe.top.toFloat(), it, (height - safe.bottom).toFloat(), stroke) }
snapY?.let { canvas.drawLine(safe.left.toFloat(), it, (width - safe.right).toFloat(), it, stroke) }
selected?.let { group ->
val (x, y) = centre(group)
val single = members(group).singleOrNull()
if (single?.wide == true) {
val pad = dp(6f)
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
}
} else if (!buttonsOnly) {
val (x, y) = quickCenter()
val radius = dp(18f)
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
canvas.drawCircle(x, y, radius, fill)
stroke.color = idleStroke
canvas.drawCircle(x, y, radius, stroke)
text.color = idleText
text.textSize = dp(22f)
canvas.drawText(if (quickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
}
if (!editing && settings.keyboardButton && onKeyboard != null) {
val (x, y) = keyboardCenter()
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
canvas.drawCircle(x, y, dp(18f), fill)
stroke.color = if (keyboardPressedBy == -1) idleStroke else heldStroke
canvas.drawCircle(x, y, dp(18f), stroke)
box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f))
canvas.drawRoundRect(box, dp(2f), dp(2f), stroke)
for (row in 0..1) for (col in 0..2) {
val kx = x + dp(-6f + col * 5f)
val ky = y + dp(-3f + row * 4f)
canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke)
}
canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke)
}
}
private fun quickCenter() = (safe.left + dp(34f)) to (height - safe.bottom - dp(34f))
private fun keyboardCenter() = if (buttonsOnly)
width / 2f to (height - safe.bottom - dp(44f))
else (safe.left + dp(90f)) to (height - safe.bottom - dp(34f))
private fun keyboardContains(x: Float, y: Float): Boolean {
if (!settings.keyboardButton || onKeyboard == null) return false
val (cx, cy) = keyboardCenter()
val dx = x - cx
val dy = y - cy
return dx * dx + dy * dy <= dp(26f) * dp(26f)
}
private fun quickContains(x: Float, y: Float): Boolean {
val (cx, cy) = quickCenter()
val radius = dp(26f)
val dx = x - cx
val dy = y - cy
return dx * dx + dy * dy <= radius * radius
}
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int) {
val (fx, fy) = when (direction) {
0 -> 0f to -1f
1 -> 1f to 0f
2 -> 0f to 1f
else -> -1f to 0f
}
arrow.reset()
arrow.moveTo(x + fx * size, y + fy * size)
arrow.lineTo(x - fx * size * 0.7f - fy * size, y - fy * size * 0.7f + fx * size)
arrow.lineTo(x - fx * size * 0.7f + fy * size, y - fy * size * 0.7f - fx * size)
arrow.close()
fill.color = text.color
canvas.drawPath(arrow, fill)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (editing) return onEditTouch(event)
if (event.getToolType(event.actionIndex) == MotionEvent.TOOL_TYPE_MOUSE) return false
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
val index = event.actionIndex
val x = event.getX(index)
val y = event.getY(index)
if (event.actionMasked == MotionEvent.ACTION_DOWN) forwardedPointerBits = 0
if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN && forwardMiss(event, x, y)) return true
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
if (event.actionMasked == MotionEvent.ACTION_DOWN && keyboardContains(x, y)) {
keyboardPressedBy = event.getPointerId(index)
invalidate()
return true
}
if (keyboardPressedBy != -1) return true
if (!buttonsOnly && event.actionMasked == MotionEvent.ACTION_DOWN && quickContains(x, y)) {
quickPressedBy = event.getPointerId(index)
return true
}
if (quickPressedBy != -1) return true
if (quickHidden) return false
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
if (control.pressedBy != -1) return true
control.pressedBy = event.getPointerId(index)
if (control.stick >= 0) {
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
control.downAt = event.eventTime
control.downX = event.getX(index)
control.downY = event.getY(index)
control.travel = 0f
val adaptive = settings.adaptiveSticks
control.ax = if (adaptive) event.getX(index) else control.cx
control.ay = if (adaptive) event.getY(index) else control.cy
control.drag(event.getX(index), event.getY(index))
control.dirty = true
}
if (SessionPrefs.lowLatency(context)) requestUnbufferedDispatch(event)
apply()
return true
}
MotionEvent.ACTION_MOVE -> {
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
if (keyboardPressedBy != -1) return true
if (quickPressedBy != -1) return true
var changed = false
var stickMoved = false
for (index in 0 until event.pointerCount) {
val pointer = event.getPointerId(index)
if (forwardedPointerBits and (1 shl pointer) != 0) continue
val x = event.getX(index)
val y = event.getY(index)
val stick = controls.firstOrNull { it.stick >= 0 && it.pressedBy == pointer }
if (stick != null) { stickMoved = stick.drag(x, y) || stickMoved; continue }
val over = controlAt(x, y)?.takeIf { it.stick < 0 }
for (control in controls) {
if (control.stick < 0 && control.pressedBy == pointer && control !== over) {
control.pressedBy = -1
changed = true
}
}
if (over != null && over.pressedBy == -1) {
over.pressedBy = pointer
changed = true
}
}
if (changed) apply() else if (stickMoved) apply(stickOnly = true)
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
val pointer = event.getPointerId(event.actionIndex)
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
forwardedPointerBits = 0
releaseAll()
return true
}
if (forwardedPointerBits and (1 shl pointer) != 0) {
forwardedPointerBits = forwardedPointerBits and (1 shl pointer).inv()
return true
}
if (keyboardPressedBy == pointer) {
val tapped = event.actionMasked == MotionEvent.ACTION_UP && keyboardContains(event.x, event.y)
keyboardPressedBy = -1
invalidate()
if (tapped) { releaseAll(); onKeyboard?.invoke() }
return true
}
if (quickPressedBy == pointer) {
val toggled = event.actionMasked == MotionEvent.ACTION_UP && quickContains(event.x, event.y)
quickPressedBy = -1
if (toggled) setQuickHidden(!quickHidden)
return true
}
if (quickHidden) return false
var changed = false
for (control in controls) {
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
if (control.pressedBy != -1) changed = true
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (stickTap(control, event)) event.eventTime else 0L
control.pressedBy = -1
control.clicked = false
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
control.kx = 0f; control.ky = 0f
}
}
if (changed) apply()
return true
}
}
return false
}
private fun forwardMiss(event: MotionEvent, x: Float, y: Float): Boolean {
if (onTouchMiss == null || keyboardPressedBy != -1 || quickPressedBy != -1) return false
if (!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null) return false
forwardedPointerBits = forwardedPointerBits or (1 shl event.getPointerId(event.actionIndex))
forwardPointers(event, forwardedPointerBits)
return true
}
private fun forwardPointers(event: MotionEvent, pointerIdBits: Int) {
val split = event.pointerSubset(pointerIdBits) ?: return
try {
onTouchMiss?.invoke(split)
} finally {
split.recycle()
}
}
private fun onEditTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
clearSnapGuides()
val control = controlAt(event.x, event.y)
selected = control?.group
val group = selected ?: run { invalidate(); return false }
val (x, y) = centre(group)
grabX = x - event.x
grabY = y - event.y
invalidate()
}
MotionEvent.ACTION_MOVE -> {
val group = selected ?: return true
ignoreSaved = false
val rawX = event.x + grabX
val rawY = event.y + grabY
val others = groups.filter { it != group && members(it).any(::isVisible) }.map { centre(it) }
val nextX = if (snap) closestSnap(rawX, others.map { it.first } + width / 2f) else null
val nextY = if (snap) closestSnap(rawY, others.map { it.second }) else null
put(group, nextX ?: rawX, nextY ?: rawY)
clamp(group)
val (x, y) = centre(group)
val visibleX = nextX?.takeIf { abs(x - it) <= SNAP_TOLERANCE_PX }
val visibleY = nextY?.takeIf { abs(y - it) <= SNAP_TOLERANCE_PX }
if ((visibleX != null && visibleX != snapX) || (visibleY != null && visibleY != snapY)) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
snapX = visibleX
snapY = visibleY
invalidate()
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> clearSnapGuides()
}
return true
}
private fun closestSnap(position: Float, targets: List<Float>): Float? =
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
fun takesTouchAt(x: Float, y: Float): Boolean =
editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) ||
(!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null)
private fun controlAt(x: Float, y: Float): Control? =
controls.firstOrNull {
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
}
private fun adaptiveStickAt(x: Float, y: Float): Control? {
if (!settings.adaptiveSticks || editing || buttonsOnly || quickHidden) return null
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
if (controls.any { control ->
control.stick < 0 && isVisible(control) && control.contains(x, y, control.radius, 5f)
}) return null
return controls.filter { control ->
val dx = if (control.stick == 0) max(0f, x - control.cx) else x - control.cx
val dy = y - control.cy
val reach = control.radius * 1.4f * sqrt(1.5f)
control.stick >= 0 && isVisible(control) && control.pressedBy == -1 &&
dx * dx + dy * dy <= reach * reach
}.minByOrNull { control ->
val dx = x - control.cx
val dy = y - control.cy
dx * dx + dy * dy
}
}
private fun isVisible(control: Control): Boolean = when {
!editing && control.id == "guide" && !settings.steamButton -> false
!editing && control.id == "qam" && !settings.qamButton -> false
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
else -> control.target != ControllerPrefs.OFF
}
private fun apply(stickOnly: Boolean = false) {
val bridge = pad
if (bridge == null || editing) { invalidate(); return }
val qamHeld = controls.any { it.id == "qam" && it.pressedBy != -1 }
if (buttonsOnly) {
bridge.setSystemButtons(controls.any { it.id == "guide" && it.pressedBy != -1 }, qamHeld)
} else {
bridge.setSystemButtons(false, qamHeld)
val stickWrites = controls.filter { it.stick >= 0 && it.dirty }.fold(0) { mask, control ->
mask or if (control.stick == 0) PadBridge.LEFT_STICK else PadBridge.RIGHT_STICK
}
bridge.applyTouch(stickWrites) { state ->
val used = HashSet<String>()
val held = HashSet<String>()
for (control in controls) {
when {
control.stick >= 0 -> {
val click = if (control.stick == 0) "l3" else "r3"
if (settings.stickClick) used.add(click)
if (control.clicked) held.add(click)
if (!control.dirty) continue
if (control.stick == 0) { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius }
else { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius }
control.dirty = false
}
control.id == "qam" || control.target == ControllerPrefs.OFF -> {}
else -> {
used.add(control.target)
if (control.pressedBy != -1) held.add(control.target)
}
}
}
for (target in used) write(state, target, target in held)
}
}
if (stickOnly) stickRedraw.request() else invalidate()
}
private fun write(state: PadState, target: String, down: Boolean) {
when (target) {
"a" -> state.press(PadState.A, down)
"b" -> state.press(PadState.B, down)
"x" -> state.press(PadState.X, down)
"y" -> state.press(PadState.Y, down)
"lb" -> state.press(PadState.LB, down)
"rb" -> state.press(PadState.RB, down)
"select" -> state.press(PadState.SELECT, down)
"start" -> state.press(PadState.START, down)
"l3" -> state.press(PadState.L3, down)
"r3" -> state.press(PadState.R3, down)
"guide" -> state.press(PadState.GUIDE, down)
"lt" -> state.leftTrigger = if (down) 1f else 0f
"rt" -> state.rightTrigger = if (down) 1f else 0f
"up" -> state.up = down
"right" -> state.right = down
"down" -> state.down = down
"left" -> state.left = down
}
}
fun releaseAll() {
quickPressedBy = -1
keyboardPressedBy = -1
invalidate()
if (controls.none { it.pressedBy != -1 || it.clicked } && !buttonsOnly) return
controls.forEach {
it.pressedBy = -1
it.clicked = false
it.lastUp = 0L
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
it.kx = 0f; it.ky = 0f
}
apply()
}
private companion object {
const val QUICK_CENTER_DP = 34f
const val QUICK_HIT_DP = 26f
const val QUICK_RADIUS_DP = 18f
const val PLACEMENT_CACHE = 4
const val DOUBLE_TAP_MS = 300L
const val STICK_TAP_MS = 200L
internal fun stickTap(downAt: Long, upAt: Long, travel: Float, slop: Float, clicked: Boolean, cancelled: Boolean): Boolean =
!clicked && !cancelled && upAt - downAt <= STICK_TAP_MS && travel <= slop
const val STICK_REDRAW_MS = 33L
const val MIN_STICK_MOVE_PX = 1f
const val MIN_FACE_MM = 9.5f
const val FULL_SIZE_HEIGHT_MM = 85f
const val BOTTOM_MARGIN_MM = 5f
const val DROP_DEGREES = 35.0
const val PHONE_MAX_HEIGHT_MM = 90f
const val SHOULDER_WIDTH = 1.45f
const val SHOULDER_HEIGHT = 0.8f
const val SNAP_DP = 9f
const val SNAP_TOLERANCE_PX = 1f
val shoulderIds = setOf("lb", "rb", "lt", "rt")
val directions = listOf("up", "right", "down", "left")
}
}
@@ -1,95 +0,0 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Rect
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
object OnScreenLayout {
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"
fun elements(context: Context, layout: SavedInputLayout?, width: Int, height: Int, safe: Rect,
buttonsOnly: Boolean = false, dock: Dock? = null): List<InputSurfaceElement> {
val stored = layout?.elements ?: return emptyList()
if (width <= 0 || height <= 0) return stored
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
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) || 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.left, dock.right)
else -> generator.regenerate(gamepad)
}
val byId = (placed + others).associateBy { it.id }
return stored.mapNotNull { byId[it.id] } + placed.filter { p -> stored.none { it.id == p.id } }
}
fun fit(elements: List<InputSurfaceElement>, referenceWidth: Float, referenceHeight: Float, width: Int, height: Int, safe: Rect): List<InputSurfaceElement> {
val w = width.toFloat()
val h = height.toFloat()
val scale = min(w / referenceWidth, h / referenceHeight)
return elements.map { e ->
val ew = e.width * referenceWidth * scale
val eh = e.height * referenceHeight * scale
val left = anchor(e.x, e.width, referenceWidth, w, scale).coerceIn(safe.left.toFloat(), max(safe.left.toFloat(), w - safe.right - ew))
val top = anchor(e.y, e.height, referenceHeight, h, scale).coerceIn(safe.top.toFloat(), max(safe.top.toFloat(), h - safe.bottom - eh))
e.copy(x = left / w, y = top / h, width = ew / w, height = eh / h).bounded()
}
}
private fun anchor(start: Float, size: Float, reference: Float, actual: Float, scale: Float): Float {
val centre = start + size / 2f
val scaled = size * reference * scale
return when {
centre < 1f / 3f -> start * reference * scale
centre > 2f / 3f -> actual - (1f - start - size) * reference * scale - scaled
else -> centre * actual - scaled / 2f
}
}
private fun isGamepad(e: InputSurfaceElement) = e.id.startsWith("legacy:") || e.id == KEYBOARD_ID
fun generatedSize(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair<Int, Int>? =
reference(context, layout, width, height).takeIf { it.second }?.first?.let { it.first.toInt() to it.second.toInt() }
private fun reference(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair<Pair<Float, Float>, Boolean> {
val (display, metrics) = InputLibraries.primaryDisplay(context)
val long = max(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
val short = min(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
val gamepad = layout.elements.filter(::isGamepad)
if (gamepad.any { it.id.startsWith("legacy:") }) {
val named = layout.id.removePrefix("migrated.layout.").takeIf { it != layout.id }?.split('x')?.mapNotNull { it.toIntOrNull() }
?.takeIf { it.size == 2 && it.all { size -> size > 0 } }?.let { it[0] to it[1] }
for ((w, h) in listOfNotNull(named, width to height, long to short, short to long).distinct()) {
if (matches(gamepad, LegacyGamepadLayout(display, w, h, scale = layout.scale).elements())) return (w.toFloat() to h.toFloat()) to true
}
}
val aspect = layout.aspect.takeIf { it > 0f } ?: (long.toFloat() / short)
return (aspect to 1f) to false
}
private fun matches(stored: List<InputSurfaceElement>, generated: List<InputSurfaceElement>): Boolean {
val byId = generated.associateBy { it.id }
return stored.all { e ->
val g = byId[e.id] ?: return@all false
abs(e.x - g.x) <= TOLERANCE && abs(e.y - g.y) <= TOLERANCE && abs(e.width - g.width) <= TOLERANCE && abs(e.height - g.height) <= TOLERANCE
}
}
}
@@ -59,25 +59,11 @@ public final class PadBridge {
private final int[] retainedSticks = new int[SLOTS];
private final PadState effectiveState = new PadState();
private final PadState homeGuideReport = new PadState();
private final PadState combinedState = new PadState();
private InputRouter inputRouter;
private final InputRouter.PadSource[] physicalRoutes = new InputRouter.PadSource[SLOTS];
private final java.util.IdentityHashMap<Object, TouchSource> touchSources = new java.util.IdentityHashMap<>();
private final Object defaultTouchSource = new Object();
private static final class TouchSource {
final PadState raw = new PadState();
final PadState passthrough = new PadState();
final InputRole role;
boolean routed;
InputRouter.PadSource route;
TouchSource(InputRole role) { this.role = role; }
}
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private boolean open;
private volatile boolean selectSteam;
private volatile boolean builtinPad = true;
private TouchRoute touchRoute;
private boolean touchMouseActive;
private final PadState routedTouch = new PadState();
private boolean systemGuidePressed;
private boolean systemQamPressed;
@@ -86,7 +72,6 @@ public final class PadBridge {
private int homeGuideSlot = -1;
private final PadState lastBackQamReport = new PadState();
private final PadState lastWrittenReport = new PadState();
private final PadState liveReport = new PadState();
private boolean backQamAxesBlocked;
private final java.util.Set<Runnable> backQamWrites = new java.util.HashSet<>();
private long backQamDelayMs, backQamWriteAt;
@@ -94,11 +79,6 @@ public final class PadBridge {
private int chordButton = -1;
private boolean chordButtonPressed;
private boolean qamTapPressed;
private boolean guideTapPressed;
private int guideTapGeneration;
private boolean vrStickTapPressed;
private int vrStickTapGeneration;
private boolean routedQamPressed;
private int qamChordGeneration;
private volatile Runnable onPlayerInput;
private Runnable onChordInput;
@@ -139,36 +119,8 @@ public final class PadBridge {
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
}
public synchronized void setInputRouter(InputRouter router) {
if (inputRouter == router) return;
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
inputRouter = router;
for (int slot = 0; slot < SLOTS; slot++) physicalRoutes[slot] = router == null ? null : router.pad(InputRole.PHYSICAL, stateFor(slot));
for (TouchSource source : touchSources.values()) source.route = router == null || source.routed ? null : router.pad(source.role, source.raw);
clearBackQamWrites();
if (open) publish();
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
}
public synchronized boolean hasInputRouter() { return inputRouter != null; }
public synchronized InputRouter getInputRouter() { return inputRouter; }
public synchronized void setInputProfile(InputProfile profile) {
if (inputRouter == null) return;
inputRouter.setProfile(profile);
clearBackQamWrites();
if (open) publish();
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
}
public synchronized void setInputEnabled(boolean enabled) {
if (inputRouter == null || !inputRouter.setEnabled(enabled)) return;
if (!enabled) clearBackQamWrites();
if (open) publish();
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
DeckControls.INSTANCE.refresh();
public void setSelectSteam(boolean on) {
selectSteam = on;
}
public void setExcluded(java.util.function.IntPredicate devices) {
@@ -195,18 +147,10 @@ public final class PadBridge {
homeGuidePressed = false;
clearBackQamWrites();
routedTouch.clear();
for (TouchSource source : touchSources.values()) {
source.raw.clear();
if (source.route != null) source.route.release();
}
systemGuidePressed = false;
systemQamPressed = false;
backQamPressed = false;
qamTapPressed = false;
guideTapPressed = false;
guideTapGeneration++;
vrStickTapPressed = false;
vrStickTapGeneration++;
qamChordActive = false;
chordButtonPressed = false;
qamChordGeneration++;
@@ -224,16 +168,6 @@ public final class PadBridge {
}
public synchronized void stop() {
guideTapPressed = false;
guideTapGeneration++;
vrStickTapPressed = false;
vrStickTapGeneration++;
if (inputRouter != null) inputRouter.releaseAll();
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
touchSources.clear();
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
Arrays.fill(physicalRoutes, null);
inputRouter = null;
homeGuidePressed = false;
homeGuideSlot = -1;
clearBackQamWrites();
@@ -380,7 +314,6 @@ public final class PadBridge {
if (slot < 0) return false;
int side = JoyCon.side(event.getDevice());
if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true;
long timing = InputTiming.begin();
PadState pad = stateFor(slot);
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
switch (event.getKeyCode()) {
@@ -391,6 +324,9 @@ public final class PadBridge {
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
case KeyEvent.KEYCODE_BUTTON_SELECT:
if (selectSteam) pad.press(PadState.GUIDE, pressed);
else pad.press(6, pressed);
break;
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
case KeyEvent.KEYCODE_BUTTON_START:
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
@@ -405,14 +341,8 @@ public final class PadBridge {
case KeyEvent.KEYCODE_BUTTON_C:
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.L4 : DeckControls.R4, pressed);
break;
case KeyEvent.KEYCODE_BUTTON_L2:
pad.leftTrigger = keyTrigger(pad.leftTrigger, pressed, event.getRepeatCount(),
analogTrigger(event.getDevice(), MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE));
break;
case KeyEvent.KEYCODE_BUTTON_R2:
pad.rightTrigger = keyTrigger(pad.rightTrigger, pressed, event.getRepeatCount(),
analogTrigger(event.getDevice(), MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS));
break;
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
@@ -425,7 +355,6 @@ public final class PadBridge {
statButtons++;
scheduleStats();
publish(slot);
InputTiming.end(timing);
return true;
}
@@ -493,7 +422,6 @@ public final class PadBridge {
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
int slot = slotFor(event.getDevice());
if (slot < 0) return false;
long timing = InputTiming.begin();
PadState pad = stateFor(slot);
noteDevice(event.getDevice(), slot);
statAxes++;
@@ -510,7 +438,6 @@ public final class PadBridge {
if (chordStickInput) noteChordInput(slot);
if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput();
publish(slot);
InputTiming.end(timing);
return true;
}
float lx = event.getAxisValue(MotionEvent.AXIS_X);
@@ -549,20 +476,9 @@ public final class PadBridge {
notePlayerInput();
}
publish(slot);
InputTiming.end(timing);
return true;
}
static float keyTrigger(float current, boolean pressed, int repeatCount, boolean analog) {
if (analog || repeatCount > 0) return current;
return pressed ? 1f : 0f;
}
static boolean analogTrigger(InputDevice device, int... axes) {
return wiredXboxLayout(device, InputDevice.SOURCE_JOYSTICK)
|| hasAxis(device, InputDevice.SOURCE_JOYSTICK, axes);
}
static boolean hasAxis(InputDevice device, int source, int... axes) {
if (device == null) return false;
for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true;
@@ -577,70 +493,41 @@ public final class PadBridge {
public synchronized void setTouchRoute(TouchRoute route) {
touchRoute = route;
touchMouseActive = false;
routedTouch.clear();
}
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
applyTouch(defaultTouchSource, InputRole.PRIMARY, 0, mutation);
applyTouch(0, mutation);
}
public synchronized void applyTouch(int stickWrites, java.util.function.Consumer<PadState> mutation) {
applyTouch(defaultTouchSource, InputRole.PRIMARY, stickWrites, mutation);
}
public synchronized void applyTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
applyTouch(owner, role, 0, mutation);
}
public synchronized void applyTouch(Object owner, InputRole role, int stickWrites, java.util.function.Consumer<PadState> mutation) {
if (!builtinPad) return;
long timing = InputTiming.begin();
TouchSource source = touchSources.get(owner);
if (source == null) {
source = new TouchSource(role);
if (inputRouter != null) source.route = inputRouter.pad(role);
touchSources.put(owner, source);
}
float lx = source.raw.leftX, ly = source.raw.leftY, rx = source.raw.rightX, ry = source.raw.rightY;
mutation.accept(source.raw);
if (inputRouter == null && (touchRoute == null || !touchRoute.active())) {
if ((stickWrites & LEFT_STICK) != 0 || source.raw.leftX != lx || source.raw.leftY != ly) {
retainedSticks[0] &= ~LEFT_STICK;
state.leftX = state.leftY = 0f;
if (touchRoute != null && touchRoute.active()) {
mutation.accept(routedTouch);
touchRoute.accept(routedTouch);
boolean changed = false;
for (int button : ROUTED_PASS_THROUGH) {
boolean down = routedTouch.isDown(button);
if (state.isDown(button) == down) continue;
state.press(button, down);
changed = true;
}
if ((stickWrites & RIGHT_STICK) != 0 || source.raw.rightX != rx || source.raw.rightY != ry) {
retainedSticks[0] &= ~RIGHT_STICK;
state.rightX = state.rightY = 0f;
if (changed) {
activeControllerId = NO_CONTROLLER;
slotControllers.set(0, NO_CONTROLLER);
publish();
}
return;
}
routedTouch.clear();
retainedSticks[0] &= ~stickWrites;
mutateState(mutation);
activeControllerId = NO_CONTROLLER;
slotControllers.set(0, NO_CONTROLLER);
notePlayerInput();
statOnScreen++;
scheduleStats();
publish();
InputTiming.end(timing);
}
public synchronized void releaseTouch(Object owner) {
TouchSource source = touchSources.remove(owner);
if (source == null) return;
if (source.route != null) source.route.close();
publish();
}
public synchronized void applyRoutedTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
if (!builtinPad) return;
TouchSource source = touchSources.get(owner);
if (source == null) {
source = new TouchSource(role);
source.routed = true;
touchSources.put(owner, source);
}
mutation.accept(source.raw);
notePlayerInput();
publish();
}
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
@@ -665,30 +552,16 @@ public final class PadBridge {
}
public synchronized void releaseAll() {
guideTapGeneration++;
vrStickTapPressed = false;
vrStickTapGeneration++;
routedQamPressed = false;
systemGuidePressed = false;
systemQamPressed = false;
guideTapPressed = false;
qamTapPressed = false;
qamChordActive = false;
chordButtonPressed = false;
qamChordGeneration++;
if (inputRouter != null) inputRouter.releaseAll();
if (inputRouter == null) for (TouchSource source : touchSources.values()) source.raw.clear();
routedTouch.clear();
DeckControls.INSTANCE.suspend();
if (inputRouter == null) clearPlayerState(0);
homeGuidePressed = false;
homeGuideSlot = -1;
clearBackQamWrites();
routedTouch.clear();
backQamPressed = false;
if (inputRouter == null) releaseBackPaddles();
releaseBackPaddles();
clearPlayerState(0);
publish();
for (int slot = 1; slot < SLOTS; slot++) {
if (inputRouter == null) clearPlayerState(slot);
clearPlayerState(slot);
if (playerWriters[slot] == null) continue;
publish(slot);
}
@@ -729,11 +602,7 @@ public final class PadBridge {
if (!homeGuidePressed) return;
clearBackQamWrites();
homeGuidePressed = false;
if (inputRouter == null) clearPlayerState(0);
else {
inputRouter.releaseAll();
DeckControls.INSTANCE.suspend();
}
clearPlayerState(0);
publish();
}
@@ -772,11 +641,7 @@ public final class PadBridge {
if (!backQamPressed && backQamWrites.isEmpty()) return;
clearBackQamWrites();
backQamPressed = false;
if (inputRouter == null) clearPlayerState(0);
else {
inputRouter.releaseAll();
DeckControls.INSTANCE.suspend();
}
clearPlayerState(0);
publish();
}
@@ -812,7 +677,6 @@ public final class PadBridge {
}
private void writePad(PadState report) {
liveReport.copyFrom(report);
if (backQamAxesBlocked && !hasQamAxes()) backQamAxesBlocked = false;
long at = android.os.SystemClock.uptimeMillis();
if (backQamPressed || homeGuidePressed || !backQamWrites.isEmpty()) at += backQamDelayMs;
@@ -820,11 +684,11 @@ public final class PadBridge {
}
private boolean hasQamAxes() {
boolean left = (retainedSticks[0] & 1) != 0 && liveReport.leftX == state.leftX && liveReport.leftY == state.leftY
? hasChordStickInput(rawSticks[0][0], rawSticks[0][1]) : liveReport.leftX != 0f || liveReport.leftY != 0f;
boolean right = (retainedSticks[0] & 2) != 0 && liveReport.rightX == state.rightX && liveReport.rightY == state.rightY
? hasChordStickInput(rawSticks[0][2], rawSticks[0][3]) : liveReport.rightX != 0f || liveReport.rightY != 0f;
return left || right || liveReport.leftTrigger != 0f || liveReport.rightTrigger != 0f;
boolean left = (retainedSticks[0] & 1) != 0
? hasChordStickInput(rawSticks[0][0], rawSticks[0][1]) : state.leftX != 0f || state.leftY != 0f;
boolean right = (retainedSticks[0] & 2) != 0
? hasChordStickInput(rawSticks[0][2], rawSticks[0][3]) : state.rightX != 0f || state.rightY != 0f;
return left || right || state.leftTrigger != 0f || state.rightTrigger != 0f;
}
private static void clearAxes(PadState pad) {
@@ -845,9 +709,9 @@ public final class PadBridge {
PadState live = new PadState();
live.copyFrom(report);
if (queued) {
live.leftX = liveReport.leftX; live.leftY = liveReport.leftY;
live.rightX = liveReport.rightX; live.rightY = liveReport.rightY;
live.leftTrigger = liveReport.leftTrigger; live.rightTrigger = liveReport.rightTrigger;
live.leftX = state.leftX; live.leftY = state.leftY;
live.rightX = state.rightX; live.rightY = state.rightY;
live.leftTrigger = state.leftTrigger; live.rightTrigger = state.rightTrigger;
}
boolean held = (backQamPressed && report.isDown(PadState.QAM)) || (homeGuidePressed && report.isDown(PadState.GUIDE));
if ((queued && !held) || backQamAxesBlocked) clearAxes(live);
@@ -868,34 +732,6 @@ public final class PadBridge {
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
}
public synchronized void triggerSteamMenu() {
if (!builtinPad || guideTapPressed) return;
guideTapPressed = true;
int generation = ++guideTapGeneration;
publish();
chordHandler.postDelayed(() -> {
synchronized (PadBridge.this) {
if (generation != guideTapGeneration) return;
guideTapPressed = false;
publish();
}
}, 90);
}
public synchronized void triggerVrStickSwitch() {
if (!builtinPad || vrStickTapPressed) return;
vrStickTapPressed = true;
int generation = ++vrStickTapGeneration;
publish();
chordHandler.postDelayed(() -> {
synchronized (PadBridge.this) {
if (generation != vrStickTapGeneration) return;
vrStickTapPressed = false;
publish();
}
}, 120);
}
private synchronized void releaseQamTap(int generation) {
if (generation != qamChordGeneration || !qamTapPressed) return;
qamTapPressed = false;
@@ -1020,7 +856,6 @@ public final class PadBridge {
}
private void free(int slot) {
if (physicalRoutes[slot] != null) physicalRoutes[slot].release();
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
owners[slot] = NO_CONTROLLER;
partners[slot] = NO_CONTROLLER;
@@ -1074,7 +909,6 @@ public final class PadBridge {
publish();
return;
}
PadState output = physicalRoutes[slot] == null ? playerStates[slot] : physicalRoutes[slot].route(playerStates[slot]);
FakeInputWriter player = playerWriters[slot];
if (player == null) {
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
@@ -1084,75 +918,43 @@ public final class PadBridge {
}
playerWriters[slot] = player;
}
PadState report = playerStates[slot];
if (slot == homeGuideSlot) {
homeGuideReport.copyFrom(output);
homeGuideReport.copyFrom(report);
homeGuideReport.press(PadState.GUIDE, true);
output = homeGuideReport;
report = homeGuideReport;
}
player.writePad(output);
player.writePad(report);
}
private void publish() {
if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return;
PadState output = physicalRoutes[0] == null ? state : physicalRoutes[0].route(state);
boolean mouseRoute = (!touchSources.isEmpty() || touchMouseActive) && touchRoute != null && touchRoute.active();
touchMouseActive = mouseRoute && !touchSources.isEmpty();
if (mouseRoute) routedTouch.clear();
if (!touchSources.isEmpty()) {
combinedState.copyFrom(output);
for (TouchSource source : touchSources.values()) {
PadState touch = source.route == null ? source.raw : source.route.route(source.raw);
if (mouseRoute) {
routedTouch.merge(touch);
source.passthrough.clear();
for (int button : ROUTED_PASS_THROUGH) source.passthrough.press(button, touch.isDown(button));
combinedState.merge(source.passthrough);
} else combinedState.merge(touch);
}
output = combinedState;
}
if (mouseRoute) touchRoute.accept(routedTouch);
boolean guide = systemGuidePressed || qamChordActive || guideTapPressed || homeGuidePressed;
boolean routedQam = output.isDown(PadState.QAM);
boolean routedQamStarted = routedQam && !routedQamPressed;
routedQamPressed = routedQam;
if (routedQamStarted && !nativeQam() && !qamChordActive) {
startChord(PadState.A);
return;
}
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
if (!open && !start()) {
statDropped++;
return;
}
if (!guide && !qam && !vrStickTapPressed && (!routedQam || nativeQam())) {
writePad(output);
boolean guide = systemGuidePressed || qamChordActive || homeGuidePressed;
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
if (!guide && !qam) {
writePad(state);
return;
}
effectiveState.copyFrom(output);
if (!nativeQam()) effectiveState.press(PadState.QAM, false);
effectiveState.copyFrom(state);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (chordButtonPressed) effectiveState.press(chordButton, true);
if (vrStickTapPressed) {
effectiveState.press(PadState.L3, true);
effectiveState.press(PadState.R3, true);
}
writePad(effectiveState);
}
public static boolean wiredXboxLayout(MotionEvent event) {
return wiredXboxLayout(event.getDevice(), event.getSource());
}
static boolean wiredXboxLayout(InputDevice device, int source) {
InputDevice device = event.getDevice();
return device != null
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
&& device.getMotionRange(MotionEvent.AXIS_RX, source) != null
&& device.getMotionRange(MotionEvent.AXIS_RY, source) != null
&& device.getMotionRange(MotionEvent.AXIS_Z, source) != null
&& device.getMotionRange(MotionEvent.AXIS_RZ, source) != null;
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
}
public static float leftTrigger(MotionEvent event, boolean wired) {
@@ -36,19 +36,6 @@ class PadState {
buttons = 0
}
fun merge(other: PadState) {
buttons = buttons or other.buttons
up = up || other.up; right = right || other.right; down = down || other.down; left = left || other.left
leftTrigger = maxOf(leftTrigger, other.leftTrigger)
rightTrigger = maxOf(rightTrigger, other.rightTrigger)
if (other.leftX * other.leftX + other.leftY * other.leftY > leftX * leftX + leftY * leftY) {
leftX = other.leftX; leftY = other.leftY
}
if (other.rightX * other.rightX + other.rightY * other.rightY > rightX * rightX + rightY * rightY) {
rightX = other.rightX; rightY = other.rightY
}
}
companion object {
const val A = 0
const val B = 1
@@ -1,89 +0,0 @@
package com.droiddeck.launcher.input
import android.view.InputDevice
import android.view.KeyEvent
data class PhysicalInputKey(val device: String, val name: String, val keyCode: Int, val scanCode: Int) {
val label: String get() = "$name · ${KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_")} ($scanCode)"
companion object {
fun identity(device: InputDevice) = "${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}"
fun capture(event: KeyEvent): PhysicalInputKey? {
val device = event.device ?: return null
if (device.isVirtual || PadBridge.isSystemVirtualInputName(device.name)) return null
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE,
KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP,
KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null
}
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null
return PhysicalInputKey(identity(device), device.name, event.keyCode, event.scanCode)
}
}
}
data class PhysicalInputBinding(val key: PhysicalInputKey, val action: InputAction)
class PhysicalInputBindings(private val router: InputRouter) {
private class Entry(val binding: PhysicalInputBinding, val channel: InputRouter.Channel) {
var heldDevice = -1
var suppressed = false
}
private var entries = emptyList<Entry>()
private val released = ArrayList<Entry>()
private val devices = HashMap<Int, String>()
fun configure(bindings: List<PhysicalInputBinding>) {
if (entries.map { it.binding } == bindings) return
for (entry in entries) {
entry.channel.close()
if (entry.heldDevice != -1) released.add(entry)
}
entries = bindings.map { Entry(it, router.channel(InputRole.PHYSICAL, InputControl.A, it.action)) }
}
fun onKey(event: KeyEvent, enabled: Boolean): Boolean {
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
for (i in released.indices.reversed()) {
val old = released[i]
if (old.heldDevice == event.deviceId && old.binding.key.keyCode == event.keyCode && old.binding.key.scanCode == event.scanCode) {
if (event.action == KeyEvent.ACTION_UP) released.removeAt(i)
return true
}
}
for (entry in entries) {
val key = entry.binding.key
if (key.keyCode != event.keyCode || key.scanCode != event.scanCode) continue
if (entry.heldDevice != -1 && entry.heldDevice == event.deviceId) {
if (event.action == KeyEvent.ACTION_UP) {
entry.channel.update(0f); entry.heldDevice = -1; entry.suppressed = false
}
return true
}
if (!enabled || event.action != KeyEvent.ACTION_DOWN || entry.binding.action is InputAction.Native) continue
val identity = devices[event.deviceId] ?: event.device?.let { PhysicalInputKey.identity(it) }?.also { devices[event.deviceId] = it } ?: continue
if (identity != key.device) continue
entry.heldDevice = event.deviceId
entry.suppressed = event.repeatCount != 0
if (!entry.suppressed) entry.channel.update(1f)
return true
}
return false
}
fun release() {
for (entry in entries) {
entry.channel.release()
if (entry.heldDevice != -1) entry.suppressed = true
}
}
fun disconnected(id: Int) {
devices.remove(id)
released.removeAll { it.heldDevice == id }
for (entry in entries) if (entry.heldDevice == id) { entry.channel.release(); entry.heldDevice = -1 }
}
fun close() { entries.forEach { it.channel.close() }; entries = emptyList(); released.clear(); devices.clear() }
}
@@ -105,10 +105,6 @@ object PhysicalShortcuts {
write(context, read(context).filterNot { it == binding })
}
fun restoreDefault(context: Context, action: Action) {
write(context, read(context).filterNot { it.action == action })
}
private fun write(context: Context, bindings: List<Binding>) {
val array = JSONArray()
bindings.forEach {
@@ -0,0 +1,52 @@
package com.droiddeck.launcher.input
import android.content.Context
fun screenKeyLabel(context: Context, codes: List<Int>): String =
codes.filter { it != 0 }.joinToString("+") { short(context, it) }
private fun short(context: Context, code: Int): String = when (code) {
28 -> "⏎"
14 -> "⌫"
15 -> "Tab"
57 -> "Space"
58 -> "Caps"
42, 54 -> "Shift"
29, 97 -> "Ctrl"
56 -> "Alt"
100 -> "AltGr"
125 -> "Super"
else -> pcKeyLabel(context, code)
}
const val SCREEN_KEY_PILL = 0
const val SCREEN_KEY_ROUND = 1
const val SCREEN_KEY_CIRCLE = 2
const val SCREEN_KEY_RECT = 3
const val SCREEN_KEY_TRIGGER = 4
const val SCREEN_KEY_DPAD = 5
const val SCREEN_KEY_STICK = 6
val SCREEN_KEY_DPAD_DIRS = listOf("↑", "→", "↓", "←")
val SCREEN_KEY_STICK_DIRS = listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖")
fun screenKeyDirections(shape: Int): List<String> = when (shape) {
SCREEN_KEY_DPAD -> SCREEN_KEY_DPAD_DIRS
SCREEN_KEY_STICK -> SCREEN_KEY_STICK_DIRS
else -> emptyList()
}
data class ScreenKey(
val codes: List<Int>,
val x: Float,
val y: Float,
val size: Int,
val glyph: String = "",
val text: String = "",
val image: String = "",
val opacity: Float = 1f,
val shape: Int = SCREEN_KEY_PILL,
val toggle: Boolean = false,
val labels: Boolean = true,
) {
}
@@ -1,49 +1,16 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.drawable.Drawable
import com.droiddeck.launcher.R
object InputButtonIcons {
// Persisted symbol IDs must not depend on Android resource IDs or overlap Linux key codes.
const val MENU = 1001
const val KEYBOARD = 1002
const val STEAM = 1003
const val QUICK_ACCESS = 1004
object ScreenKeyIcons {
fun has(code: Int): Boolean = code in SET
fun has(code: Int): Boolean = code in codes
private val SET = setOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
private val keyCodes = listOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
val codes = listOf(MENU, KEYBOARD, STEAM, QUICK_ACCESS) + keyCodes
fun label(context: Context, code: Int): String = when (code) {
MENU -> context.getString(R.string.touch_droiddeck_menu)
KEYBOARD -> context.getString(R.string.drawer_keyboard)
STEAM -> context.getString(R.string.drawer_steam_menu)
QUICK_ACCESS -> context.getString(R.string.drawer_open_qam)
else -> pcKeyLabel(context, code)
}
fun drawable(context: Context, code: Int): Drawable? = when (code) {
MENU -> R.drawable.ic_stat_session
KEYBOARD -> R.drawable.ic_bottom_keyboard
STEAM -> R.drawable.ic_input_steam
QUICK_ACCESS -> R.drawable.ic_input_quick_access
else -> null
}?.let { context.getDrawable(it)?.mutate() }
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint, drawable: Drawable? = null): Boolean {
if (drawable != null) {
drawable.setTint(paint.color)
val half = box / 2f
drawable.setBounds((cx - half).toInt(), (cy - half).toInt(), (cx + half).toInt(), (cy + half).toInt())
drawable.draw(canvas)
return true
}
if (code !in keyCodes) return false
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint): Boolean {
if (!has(code)) return false
val h = box / 2f
val stroke = box * 0.085f
val path = Path()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,140 @@
package com.droiddeck.launcher.input
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.roundToInt
private val ICP_NAMES: Map<Int, String> = mapOf(
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
21 to "KEY_Y", 44 to "KEY_Z",
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
)
private val ICP_CODES: Map<String, Int> = ICP_NAMES.entries.associate { (code, name) -> name to code }
private fun icpShape(shape: Int): String = when (shape) {
SCREEN_KEY_CIRCLE -> "CIRCLE"
SCREEN_KEY_RECT -> "RECT"
SCREEN_KEY_ROUND -> "ROUND_RECT"
SCREEN_KEY_DPAD -> "ROUND_RECT"
SCREEN_KEY_STICK -> "ROUND_RECT"
SCREEN_KEY_TRIGGER -> "ROUND_RECT"
else -> "ROUND_RECT"
}
private fun screenKeyShape(shape: String): Int = when (shape) {
"CIRCLE" -> SCREEN_KEY_CIRCLE
"RECT" -> SCREEN_KEY_RECT
"SQUARE" -> SCREEN_KEY_ROUND
else -> SCREEN_KEY_PILL
}
fun exportScreenLayout(keys: List<ScreenKey>, widthPx: Int, density: Float): String {
val snap = widthPx / 100f
val elements = JSONArray()
keys.forEach { key ->
val binds = JSONArray()
val directional = key.shape == SCREEN_KEY_DPAD || key.shape == SCREEN_KEY_STICK
key.codes.forEach { code ->
val name = ICP_NAMES[code]
if (name != null) binds.put(name) else if (directional) binds.put("")
}
if (binds.length() == 0) return@forEach
val shape = icpShape(key.shape)
val sizePx = key.size * density
val scale = when {
key.shape == SCREEN_KEY_DPAD -> sizePx * 2.3f / (14f * snap)
key.shape == SCREEN_KEY_STICK -> sizePx * 2.3f / (14f * snap)
shape == "CIRCLE" -> sizePx / (6f * snap)
else -> sizePx / (4f * snap)
}
elements.put(
JSONObject()
.put(
"type",
when (key.shape) {
SCREEN_KEY_DPAD -> "D_PAD"
SCREEN_KEY_STICK -> "STICK"
else -> "BUTTON"
},
)
.put("shape", shape)
.put("customColor", 0)
.put("bindings", binds)
.put("scale", scale)
.put("x", key.x)
.put("y", key.y)
.put("toggleSwitch", key.toggle)
.put("swipeable", false)
.put("text", key.text)
.put("iconId", 0)
.apply { if (key.opacity < 1f) put("opacity", key.opacity) },
)
}
return JSONObject()
.put("id", 0)
.put("name", "DroidDeck")
.put("cursorSpeed", 1.5f)
.put("elements", elements)
.toString()
}
fun importScreenLayout(raw: String, widthPx: Int, density: Float): List<ScreenKey>? {
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val elements = root.optJSONArray("elements") ?: return null
val snap = widthPx / 100f
val keys = mutableListOf<ScreenKey>()
for (i in 0 until elements.length()) {
val element = elements.optJSONObject(i) ?: continue
val type = element.optString("type", "BUTTON")
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
val binds = element.optJSONArray("bindings") ?: continue
val raw = (0 until binds.length()).map { ICP_CODES[binds.optString(it)] ?: 0 }
val codes = if (type == "D_PAD" || type == "STICK") {
raw.dropLastWhile { it == 0 }.ifEmpty { listOf(0) }
} else {
raw.filter { it != 0 }
}
if (codes.isEmpty()) continue
val dpad = type == "D_PAD"
val stick = type == "STICK"
val shape = when {
dpad -> SCREEN_KEY_DPAD
stick -> SCREEN_KEY_STICK
else -> screenKeyShape(element.optString("shape"))
}
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it > 0f } ?: 1f
val sizePx = when {
dpad || stick -> 14f * snap * scale / 2.3f
shape == SCREEN_KEY_CIRCLE -> 6f * snap * scale
else -> 4f * snap * scale
}
keys += ScreenKey(
codes = codes,
x = element.optDouble("x", 0.5).toFloat().coerceIn(0f, 1f),
y = element.optDouble("y", 0.5).toFloat().coerceIn(0f, 1f),
size = (sizePx / density).roundToInt().coerceIn(ScreenKeys.MIN_SIZE, ScreenKeys.MAX_SIZE),
text = element.optString("text").take(24),
opacity = element.optDouble("opacity", 1.0).toFloat().coerceIn(0.2f, 1f),
shape = shape,
toggle = element.optBoolean("toggleSwitch"),
)
}
return keys
}
@@ -1,34 +1,26 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
enum class SecondScreenMode(val id: String, @StringRes val label: Int, val bindings: String? = null) {
enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
NONE("none", R.string.second_mode_none),
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad, InputStockLayouts.TRACKPAD),
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
TERMINAL("terminal", R.string.second_mode_terminal),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls, InputStockLayouts.DECK),
CUSTOM_CONTROLS("custom-controls", R.string.input_profile_controls_mode),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
DASHBOARD("dashboard", R.string.second_mode_dashboard),
DESKTOP("desktop", R.string.second_mode_desktop);
val controls: Boolean get() = this == CUSTOM_CONTROLS || bindings != null
fun panel(context: Context): String? = if (this == DECK_CONTROLS) SessionPrefs.deckPanel(context).bindings else bindings
companion object {
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, bottomDesktop: Boolean = false, desktopSession: Boolean = false): SecondScreenMode {
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, desktop: Boolean = false): SecondScreenMode {
entries.firstOrNull {
it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) &&
(it != DESKTOP || bottomDesktop)
(it != DESKTOP || desktop)
}?.let { return it }
val picked = entries.any { it != NONE && it.id == remembered }
return when {
desktopSession -> KEYBOARD_TRACKPAD
deckPad -> DECK_CONTROLS
dashboard && !picked -> DASHBOARD
else -> KEYBOARD_TRACKPAD
@@ -52,7 +44,4 @@ object SecondScreenDisplays {
)
}
.toList()
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
(listOf(selected) + available).distinct().filterNot(taken)
}
@@ -20,11 +20,7 @@ import java.util.ArrayDeque
import java.util.UUID
@SuppressLint("MissingPermission")
class SteamControllerBle(private val context: Context, router: InputRouter? = null) {
private val inputLock = router ?: Any()
private var routedInput = router?.let(::TritonInputRouter)
@Volatile private var stopped = false
private var started = false
class SteamControllerBle(private val context: Context) {
private val thread = HandlerThread("steam-controller-ble").apply { start() }
private val handler = Handler(thread.looper)
private val outputReports = mutableMapOf<Int, UUID>()
@@ -40,54 +36,22 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
private var outputWritePending = false
private var featureRead = 0L
fun start(): Unit = synchronized(inputLock) {
if (stopped || started) return@synchronized
started = true
synchronized(slotLock) {
slotOwner = this
connected = false
FakeInputWriter.writeTritonState(SLOT, 0x45, ByteArray(STATE_BYTES))
FakeInputWriter.setTritonConnected(SLOT, false)
}
handler.post {
if (stopped || slotOwner !== this || running) return@post
running = true
Log.i(TAG, "looking for a paired Steam Controller")
connect()
}
fun start() = handler.post {
if (running) return@post
running = true
Log.i(TAG, "looking for a paired Steam Controller")
connect()
}
private inline fun withSlot(block: () -> Unit) = synchronized(inputLock) {
synchronized(slotLock) {
if (!stopped && slotOwner === this) block()
}
}
fun stop() {
synchronized(inputLock) {
if (stopped) return
stopped = true
routedInput?.close()
routedInput = null
synchronized(slotLock) {
if (slotOwner === this) {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
slotOwner = null
}
}
}
handler.post {
running = false
handler.removeCallbacksAndMessages(null)
disconnect("session ended")
thread.quitSafely()
}
fun stop() = handler.post {
running = false
handler.removeCallbacksAndMessages(null)
disconnect("session ended")
thread.quitSafely()
}
private fun connect() {
if (stopped || slotOwner !== this || !running || gatt != null) return
if (!running || gatt != null) return
if (SessionState.suspended) {
handler.postDelayed(::connect, RETRY_MS)
return
@@ -120,17 +84,11 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
featureRead = 0L
queuedWrites.clear()
outputReports.clear()
synchronized(inputLock) {
if (!stopped) {
release()
withSlot {
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
}
}
}
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
g.disconnect()
g.close()
release()
Log.i(TAG, "disconnected: $why")
listener?.invoke()
}
@@ -161,12 +119,10 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
withSlot {
if (g !== gatt || descriptor.characteristic.uuid != input) return
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
connected = status == BluetoothGatt.GATT_SUCCESS
FakeInputWriter.setTritonConnected(SLOT, connected)
}
if (g !== gatt || descriptor.characteristic.uuid != input) return
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
connected = status == BluetoothGatt.GATT_SUCCESS
FakeInputWriter.setTritonConnected(SLOT, connected)
listener?.invoke()
if (connected) {
refreshLizardMode()
@@ -208,7 +164,6 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
}
private fun subscribe(g: BluetoothGatt) {
if (stopped || slotOwner !== this) return
val service = g.getService(SERVICE)
if (service == null) {
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
@@ -252,31 +207,25 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
}
private fun refreshLizardMode() = withSlot {
if (!connected) return@withSlot
private fun refreshLizardMode() {
if (!connected) return
val msg = ByteArray(FEATURE_BYTES)
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
enqueueOutput(REPORT, msg)
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
}
private fun onState(data: ByteArray) = withSlot {
if (data.size < STATE_BYTES) return@withSlot
val timing = InputTiming.begin()
FakeInputWriter.writeTritonState(SLOT, reportId, routedInput?.route(data) ?: data)
InputTiming.end(timing)
private fun onState(data: ByteArray) {
if (data.size < STATE_BYTES) return
FakeInputWriter.writeTritonState(SLOT, reportId, data)
}
private fun release() = synchronized(inputLock) {
if (stopped) return@synchronized
routedInput?.release()
withSlot {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
}
private fun release() {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
}
private fun pollOutput() = withSlot {
if (!running || !connected || gatt == null) return@withSlot
private fun pollOutput() {
if (!running || !connected || gatt == null) return
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
@@ -290,7 +239,7 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
}
private fun onFeatureRead(value: ByteArray?) = withSlot {
private fun onFeatureRead(value: ByteArray?) {
outputWritePending = false
if (featureRead != 0L) {
if (value == null) Log.w(TAG, "feature read failed")
@@ -318,14 +267,14 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
writeNextOutput()
}
private fun writeNextOutput() = withSlot {
if (outputWritePending) return@withSlot
val g = gatt ?: return@withSlot
val (uuid, value) = queuedWrites.pollFirst() ?: return@withSlot
private fun writeNextOutput() {
if (outputWritePending) return
val g = gatt ?: return
val (uuid, value) = queuedWrites.pollFirst() ?: return
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
if (characteristic == null) {
Log.w(TAG, "BLE output characteristic $uuid disappeared")
return@withSlot
return
}
if (value == null) {
if (g.readCharacteristic(characteristic)) {
@@ -334,7 +283,7 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
queuedWrites.addFirst(uuid to null)
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
}
return@withSlot
return
}
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@@ -350,8 +299,6 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
}
companion object {
private val slotLock = Any()
@Volatile private var slotOwner: SteamControllerBle? = null
private const val TAG = "SteamControllerBle"
private const val SLOT = 0
private const val RETRY_MS = 5_000L
@@ -213,9 +213,6 @@ 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
}
@@ -1,93 +0,0 @@
package com.droiddeck.launcher.input
import kotlin.math.roundToInt
class TritonInputRouter(private val router: InputRouter) {
private val channels = InputControl.entries.map { router.channel(InputRole.PHYSICAL, it) }.toTypedArray()
private var last: ByteArray? = null
private var buffer = ByteArray(45)
private val snapshot: () -> Unit = { last?.let { read(it, false) } }
init { router.beforeProfileChange(snapshot) }
fun route(data: ByteArray): ByteArray = synchronized(router) {
if (data.size < 45) return@synchronized data
last = data
if (channels.all { it.nativeIdentity() }) return@synchronized data
if (buffer.size != data.size) buffer = ByteArray(data.size)
data.copyInto(buffer)
putBits(buffer, bits(data) and CONTROL_BITS.inv())
for (offset in 5..27 step 2) putShort(buffer, offset, 0)
read(data, true)
buffer
}
private fun read(data: ByteArray, emit: Boolean) {
val bits = bits(data)
for (control in InputControl.entries) {
val channel = channels[control.ordinal]
val offset = offset(control)
val pair = control.dimensions == 2
val x = if (offset != 0) axis(data, offset) else if (bits and bit(control) != 0) 1f else 0f
val y = if (pair) axis(data, offset + 2) * if (control.isTrackpad) 1f else -1f else 0f
val contact = control.isTrackpad && bits and bit(control) != 0
if (!emit) { channel.observe(x, y, contact); continue }
val target = channel.update(x, y, contact) ?: continue
val targetOffset = offset(target)
if (targetOffset != 0) {
val value = shortValue(x)
if (target.dimensions == 2) {
val second = shortValue(y * if (target.isTrackpad) 1f else -1f)
val oldX = short(buffer, targetOffset).toLong()
val oldY = short(buffer, targetOffset + 2).toLong()
if (value.toLong() * value + second.toLong() * second >= oldX * oldX + oldY * oldY) {
putShort(buffer, targetOffset, value)
putShort(buffer, targetOffset + 2, second)
}
val touch = if (control.isTrackpad) contact else bits and bit(control) != 0
if (touch) putBits(buffer, bits(buffer) or bit(target))
} else {
if (value >= short(buffer, targetOffset)) putShort(buffer, targetOffset, value)
if (bits and bit(control) != 0) putBits(buffer, bits(buffer) or bit(target))
}
} else if (x > 0.5f) putBits(buffer, bits(buffer) or bit(target))
if (target == InputControl.LEFT_CLICK || target == InputControl.RIGHT_CLICK) {
val pressureOffset = if (target == InputControl.LEFT_CLICK) 21 else 27
val from = if (control == InputControl.LEFT_CLICK) 21 else if (control == InputControl.RIGHT_CLICK) 27 else 0
if (from != 0) putShort(buffer, pressureOffset, short(data, from))
else if (x > 0.5f) putShort(buffer, pressureOffset, 32767)
}
}
}
fun release() = synchronized(router) { channels.forEach { it.release() }; last = null }
fun close() = synchronized(router) { release(); channels.forEach { it.close() }; router.removeSnapshot(snapshot) }
private fun bits(data: ByteArray): Int = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8) or
((data[3].toInt() and 255) shl 16) or ((data[4].toInt() and 255) shl 24)
private fun putBits(data: ByteArray, value: Int) { for (i in 0..3) data[i + 1] = (value ushr (i * 8)).toByte() }
private fun short(data: ByteArray, offset: Int) = ((data[offset].toInt() and 255) or (data[offset + 1].toInt() shl 8)).toShort().toInt()
private fun axis(data: ByteArray, offset: Int): Float = short(data, offset).let { it / if (it < 0) 32768f else 32767f }
private fun shortValue(value: Float) = (value * if (value < 0f) 32768f else 32767f).roundToInt().coerceIn(-32768, 32767)
private fun putShort(data: ByteArray, offset: Int, value: Int) { data[offset] = value.toByte(); data[offset + 1] = (value shr 8).toByte() }
companion object {
private const val CONTROL_BITS = 0x0fffffff
private fun bit(c: InputControl): Int = when (c) {
InputControl.A -> 0x1; InputControl.B -> 0x2; InputControl.X -> 0x4; InputControl.Y -> 0x8
InputControl.QAM -> 0x10; InputControl.R3 -> 0x20; InputControl.START -> 0x40
InputControl.R4 -> 0x80; InputControl.R5 -> 0x100; InputControl.RB -> 0x200
InputControl.DOWN -> 0x400; InputControl.RIGHT -> 0x800; InputControl.LEFT -> 0x1000; InputControl.UP -> 0x2000
InputControl.SELECT -> 0x4000; InputControl.L3 -> 0x8000; InputControl.GUIDE -> 0x10000
InputControl.L4 -> 0x20000; InputControl.L5 -> 0x40000; InputControl.LB -> 0x80000
InputControl.RS -> 0x100000; InputControl.RIGHT_PAD -> 0x200000; InputControl.RIGHT_CLICK -> 0x400000
InputControl.RT -> 0x800000; InputControl.LS -> 0x1000000; InputControl.LEFT_PAD -> 0x2000000
InputControl.LEFT_CLICK -> 0x4000000; InputControl.LT -> 0x8000000
}
private fun offset(c: InputControl): Int = when (c) {
InputControl.LT -> 5; InputControl.RT -> 7; InputControl.LS -> 9; InputControl.RS -> 13
InputControl.LEFT_PAD -> 17; InputControl.RIGHT_PAD -> 23
else -> 0
}
}
}
@@ -1,60 +0,0 @@
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) }
}
}
@@ -1,267 +0,0 @@
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,7 +15,6 @@ 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,
@@ -217,7 +216,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=$APPIMAGE_EXEC /opt/appimages/${entry.id}.AppImage\n" +
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> {
@@ -21,11 +21,9 @@ object LegacyNames {
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
)
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/"),
)
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-"
@JvmStatic
@Synchronized
@@ -97,16 +95,14 @@ object LegacyNames {
}
private fun rewriteMenuEntries(root: File) {
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")
}
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")
}
}
}
@@ -82,12 +82,8 @@ 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()) return null;
if (!marker.isFile() || !LinuxRuntime.isInstalled(context)) return null;
String v = FileUtils.readString(marker);
return v == null ? null : v.trim();
}
@@ -148,10 +144,6 @@ 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;
@@ -291,31 +283,6 @@ 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;
@@ -327,10 +294,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, () -> true, packProgress(listener, downloading))
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context)
&& !SessionPrefs.allowStockProot(context))
throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))) throw new IOException("Cannot download DirectAudio");
if (!AddonPack.DIRECTAUDIO.await(context)) 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,7 +35,6 @@ 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 }
@@ -57,7 +56,7 @@ object UserApps {
sealed class Source {
class Script(val file: File) : Source()
class AppImage(val file: File) : Source()
class GitHub(val repo: String, val asset: String? = null) : Source()
class GitHub(val repo: String) : Source()
class Flatpak(val id: String) : Source()
class FlatpakBundle(val file: File) : Source()
}
@@ -114,7 +113,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, s.asset, name, icon, onProgress)
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
}
@@ -306,26 +305,14 @@ object UserApps {
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
}
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? {
internal fun pickAsset(names: List<String>): 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, wanted: String? = null): Pair<Release?, String?> {
private fun latestRelease(context: Context, repo: String): 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)
@@ -334,7 +321,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(), wanted)?.let { asset ->
pickAsset(byName.keys.toList())?.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")))
@@ -357,11 +344,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, GITHUB_ASSET to release.asset)
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
private fun addFromGitHub(context: Context, repo: String, asset: String?, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
private fun addFromGitHub(context: Context, repo: 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, asset)
val (release, problem) = latestRelease(context, repo)
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) }
@@ -394,10 +381,9 @@ 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 dir = AppImageManager.dir(context, app.key.substringAfter(':'))
val (release, problem) = latestRelease(context, repo, FileUtils.readString(File(dir, GITHUB_ASSET))?.trim()?.takeIf { it.isNotEmpty() })
val (release, problem) = latestRelease(context, repo)
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
val published = FileUtils.readString(File(dir, GITHUB_PUBLISHED))?.trim().orEmpty()
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), 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,245 +0,0 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONArray
import java.io.File
import java.io.IOException
import java.net.URLEncoder
object WowAddonCatalog {
private const val TAG = "WowAddonCatalog"
enum class Sort { POPULAR, UPDATED, NAME, DOWNLOADS }
class Category(val id: String, val name: String)
class Client(val folder: String, val family: String?)
class Query(val text: String = "", val category: String? = null, val client: Client, val sort: Sort = Sort.POPULAR)
class Addon(
val id: String,
val name: String,
val author: String,
val summary: String,
val downloads: Long,
val updated: Long,
val thumb: String?,
)
class Page(val items: List<Addon>, val more: Boolean)
internal class Entry(val addon: Addon, val category: String, val flavors: Set<String>?, val monthly: Long = 0L)
internal class Cat(val id: String, val name: String, val parents: List<String>)
private const val WOWI = "https://api.mmoui.com/v3/game/WOW/"
private const val CURSEFORGE = "https://www.curseforge.com/wow/search?class=addons"
private const val WOWI_FILES = "wowi-filelist.json"
internal const val FRESH_MS = 6L * 60 * 60 * 1000
internal const val RETAIL = "_retail_"
internal const val ERA = "_classic_era_"
internal const val CLASSIC = "_classic_"
internal const val ANNIVERSARY = "_anniversary_"
internal const val FOREVER = "_forever_"
private val TEST_SUFFIX = Regex("_(beta|ptr|xptr)_$")
internal fun flavorOf(version: String): String? {
val major = version.substringBefore('.').trim().toIntOrNull() ?: return null
return when {
major == 1 -> ERA
major == 2 -> ANNIVERSARY
major in 3..5 -> CLASSIC
major >= 10 -> RETAIL
else -> null
}
}
internal fun baseFolder(folder: String): String = folder.replace(TEST_SUFFIX, "_")
internal fun folderFamily(folder: String): String? = when (val base = baseFolder(folder)) {
FOREVER -> RETAIL
RETAIL, ERA, CLASSIC, ANNIVERSARY -> base
else -> null
}
internal fun buildInfoVersion(buildInfo: String, product: String): String? {
val lines = buildInfo.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList()
if (lines.isEmpty()) return null
val header = lines.first().split('|').map { it.substringBefore('!').trim().lowercase() }
val productAt = header.indexOf("product")
val versionAt = header.indexOf("version")
if (productAt < 0 || versionAt < 0) return null
return lines.drop(1).map { it.split('|') }
.firstOrNull { it.getOrNull(productAt)?.trim().equals(product, ignoreCase = true) }
?.getOrNull(versionAt)?.trim()?.ifEmpty { null }
}
internal fun flavorProduct(flavorInfo: String): String? =
flavorInfo.lineSequence().map { it.trim() }.filter { it.isNotEmpty() && '!' !in it }.firstOrNull()
internal fun clientVersion(flavorDir: File): String? {
val product = runCatching { File(flavorDir, ".flavor.info").readText() }.getOrNull()?.let(::flavorProduct) ?: return null
val info = runCatching { File(flavorDir.parentFile, ".build.info").readText() }.getOrNull() ?: return null
return buildInfoVersion(info, product)
}
internal fun client(folder: String, version: String?): Client =
Client(folder, version?.let(::flavorOf) ?: folderFamily(folder))
fun client(target: WowAddons.Target): Client {
val dir = target.addOns.parentFile?.parentFile
return client(target.flavor, dir?.let(::clientVersion))
}
internal fun flavorMatches(flavors: Set<String>?, family: String?): Boolean =
flavors == null || family == null || family in flavors
internal fun parseWowiFiles(json: String): List<Entry> {
val arr = JSONArray(json)
val out = ArrayList<Entry>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val id = o.optString("UID")
if (id.isEmpty()) continue
val compat = o.optJSONArray("UICompatibility")
val flavors = if (compat == null || compat.length() == 0) null else {
val set = HashSet<String>()
for (k in 0 until compat.length()) compat.optJSONObject(k)?.optString("version")?.let(::flavorOf)?.let(set::add)
set
}
val thumbs = o.optJSONArray("UIIMG_Thumbs")
val thumb = if (thumbs != null && thumbs.length() > 0) thumbs.optString(0).ifEmpty { null } else null
val addon = Addon(
id, o.optString("UIName"), o.optString("UIAuthorName"), o.optString("UIVersion"),
o.optString("UIDownloadTotal").toLongOrNull() ?: 0L, o.optLong("UIDate"), thumb,
)
out.add(Entry(addon, o.optString("UICATID"), flavors, o.optString("UIDownloadMonthly").toLongOrNull() ?: 0L))
}
return out
}
internal fun parseWowiCategories(json: String): List<Cat> {
val arr = JSONArray(json)
val out = ArrayList<Cat>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val parents = o.optJSONArray("UICATParentIDs")
out.add(Cat(o.optString("UICATID"), o.optString("UICATTitle"), (0 until (parents?.length() ?: 0)).map { parents!!.optString(it) }))
}
return out
}
internal fun filterWowi(entries: List<Entry>, cats: List<Cat>, query: Query): List<Addon> {
val parents = cats.associate { it.id to it.parents }
val text = query.text.trim().lowercase()
val list = entries.filter { e ->
flavorMatches(e.flavors, query.client.family) &&
(query.category == null || e.category == query.category || query.category in (parents[e.category] ?: emptyList())) &&
(text.isEmpty() || e.addon.name.lowercase().contains(text) || e.addon.author.lowercase().contains(text))
}
return when (query.sort) {
Sort.POPULAR -> list.sortedWith(compareByDescending<Entry> { it.monthly }.thenByDescending { it.addon.downloads })
Sort.DOWNLOADS -> list.sortedByDescending { it.addon.downloads }
Sort.UPDATED -> list.sortedByDescending { it.addon.updated }
Sort.NAME -> list.sortedBy { it.addon.name.lowercase() }
}.map { it.addon }
}
internal fun pageOf(all: List<Addon>, page: Int, pageSize: Int): Page {
val from = (page * pageSize).coerceAtMost(all.size)
val to = (from + pageSize).coerceAtMost(all.size)
return Page(all.subList(from, to), to < all.size)
}
internal fun cached(file: File, now: Long, fetch: () -> String?): String? {
val kept = if (file.isFile) file.readText().takeIf(::isJsonArray) else null
if (kept != null && isFresh(file, now)) return kept
val body = fetch()?.takeIf(::isJsonArray)
if (body != null) {
val part = File(file.parentFile, "${file.name}.${java.util.UUID.randomUUID()}.part")
runCatching {
file.parentFile?.mkdirs()
part.writeText(body)
part.setLastModified(now)
if (!part.renameTo(file)) Log.w(TAG, "could not keep ${file.name}")
}.onFailure { Log.w(TAG, "could not keep ${file.name}", it) }
part.delete()
return body
}
return kept
}
private fun isJsonArray(body: String): Boolean =
runCatching { org.json.JSONTokener(body).nextValue() }.getOrNull() is JSONArray
private fun isFresh(file: File, now: Long) = file.isFile && now - file.lastModified() < FRESH_MS
private fun cacheFile(context: Context, name: String) = File(File(context.cacheDir, "wowaddons"), name)
private fun cachedGet(context: Context, name: String, url: String): String? =
cached(cacheFile(context, name), System.currentTimeMillis()) { StoreNet.get(url, timeoutMs = 60_000) }
private fun unreachable(context: Context) =
IOException(context.getString(R.string.addons_unreachable, context.getString(R.string.addons_source_wowi)))
fun search(context: Context, query: Query, page: Int, pageSize: Int = 40): Page =
pageOf(filterWowi(wowiEntries(context), wowiCategories(context), query), page, pageSize)
private class Memo(val stamp: Long, val size: Long, val entries: List<Entry>)
@Volatile private var wowiMemo: Memo? = null
private fun wowiEntries(context: Context): List<Entry> {
val file = cacheFile(context, WOWI_FILES)
val memo = wowiMemo
if (memo != null && isFresh(file, System.currentTimeMillis()) && memo.stamp == file.lastModified() && memo.size == file.length()) return memo.entries
val body = cachedGet(context, WOWI_FILES, WOWI + "filelist.json") ?: throw unreachable(context)
return parseWowiFiles(body).also { wowiMemo = Memo(file.lastModified(), file.length(), it) }
}
private fun wowiCategories(context: Context): List<Cat> =
cachedGet(context, "wowi-categories.json", WOWI + "categorylist.json")?.let(::parseWowiCategories) ?: emptyList()
fun categories(context: Context): List<Category> = wowiCategories(context).map { Category(it.id, it.name) }
internal fun parseWowiDetails(json: String): Pair<String, String?>? {
val o = JSONArray(json).optJSONObject(0) ?: return null
val url = o.optString("UIDownload").ifEmpty { return null }
return url to o.optString("UIMD5").ifEmpty { null }
}
internal fun isHttps(url: String) = url.startsWith("https://", ignoreCase = true)
internal fun md5Matches(file: File, expected: String?): Boolean =
expected.isNullOrBlank() || Hashes.md5(file).equals(expected.trim(), ignoreCase = true)
fun download(context: Context, addon: Addon, dest: File, progress: (Int) -> Unit): File {
val body = StoreNet.get(WOWI + "filedetails/${URLEncoder.encode(addon.id, "UTF-8")}.json", timeoutMs = 30_000)
?: throw unreachable(context)
val (url, md5) = parseWowiDetails(body) ?: throw IOException(context.getString(R.string.addons_no_file))
if (!isHttps(url)) throw IOException(context.getString(R.string.addons_no_file))
dest.parentFile?.mkdirs()
dest.delete()
val ok = Downloader.downloadFile(url, dest, false) { f -> if (f >= 0) progress((f * 100).toInt().coerceIn(0, 100)) }
if (!ok || !dest.isFile) {
dest.delete()
throw unreachable(context)
}
if (!md5Matches(dest, md5)) {
dest.delete()
throw IOException(context.getString(R.string.selfinst_checksum_mismatch))
}
progress(100)
return dest
}
internal fun curseForgeUrl(text: String): String =
text.trim().let { if (it.isEmpty()) CURSEFORGE else CURSEFORGE + "&search=" + URLEncoder.encode(it, "UTF-8") }
}
@@ -0,0 +1,60 @@
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()
}
@@ -5,17 +5,15 @@ import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.AutoComponents
import com.droiddeck.launcher.gpu.GpuInfo
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
object ComponentUpdater {
private const val TAG = "ComponentUpdater"
private const val CHECK_EVERY_MS = 3_600_000L
private const val BOOTED_KEPT = 200
private const val HANDED_BACK = "handedBack"
private const val PINNED_NOTE = "pinnedNote"
internal const val HAND_BACK_FILES = "handBackFiles"
private const val IMPORTED = "imported"
private val lock = Any()
@@ -105,13 +103,6 @@ object ComponentUpdater {
val protons = found ?: emptyList()
if (found != null) handBack(context, state, found)
judge(context, state, protons, notes)
ComponentsManager.pinnedAutoDxvk(context)?.let { pinned ->
runCatching { ComponentsManager.ensureForcedPackages(context) }
.onSuccess { if (it != null) Log.i(TAG, it) }.onFailure { Log.w(TAG, "pinned DXVK", it) }
val followers = protons.filter { ComponentUpdates.follows(it) && ComponentsManager.supports(it, "dxvk") }.map { it.id to it.version }
if (pinnedHeld(pinned, followers, ComponentsManager.activeChoices(context), pinnedBase())) state.remove(PINNED_NOTE)
else state.put(PINNED_NOTE, context.getString(R.string.comp_auto_install_failed, pinned.removeSuffix(".wcp")))
}
val due = force || System.currentTimeMillis() - state.optLong("checkedAt") >= CHECK_EVERY_MS
val wanted = ComponentsManager.COMPONENTS.filter { managed(context, it) }
if (wanted.isEmpty() || protons.isEmpty()) {
@@ -394,32 +385,7 @@ object ComponentUpdater {
}
}
internal fun pinnedHeld(pinned: String, followers: List<Pair<String, String>>, choices: Map<String, Map<String, ComponentsManager.Choice>>, base: String?): Boolean =
followers.all { (id, version) ->
val choice = choices[id]?.get("dxvk")
ownChoice(choice, version, pinned, base) || choice?.let { it.file == ComponentsManager.safeName(pinned) && it.protonVersion == version } == true
}
internal fun ownChoice(choice: ComponentsManager.Choice?, version: String, pinned: String, base: String?): Boolean =
choice != null && !choice.auto && choice.protonVersion == version && choice.file != ComponentsManager.safeName(pinned) &&
(base == null || choice.file != ComponentsManager.safeName(base))
private fun pinnedBase(): String? = ComponentsManager.forcedDxvk(GpuInfo.detect())?.file
fun status(context: Context, comp: String): List<Status> {
val pinned = if (comp == "dxvk") ComponentsManager.pinnedAutoDxvk(context) else null
if (pinned != null) {
val on = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
.filter { ComponentUpdates.follows(it) && ComponentsManager.supports(it, comp) }
if (on.isEmpty()) return emptyList()
val choices = ComponentsManager.activeChoices(context)
val base = pinnedBase()
val (own, auto) = on.partition { ownChoice(choices[it.id]?.get(comp), it.version, pinned, base) }
val held = pinnedHeld(pinned, auto.map { it.id to it.version }, choices, base)
val pinnedLine = if (auto.isEmpty()) emptyList()
else listOf(Status(comp, pinned, pinned, auto.map { it.name }, if (held) "" else load(context).optString(PINNED_NOTE)))
return pinnedLine + own.groupBy { choices.getValue(it.id).getValue(comp).file }.map { (file, ps) -> Status(comp, file, file, ps.map { it.name }, "") }
}
val state = load(context)
val lines = state.optJSONObject("lines") ?: JSONObject()
val protons = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
@@ -1065,24 +1065,14 @@ object ComponentsManager {
val file: String,
val release: String,
val models: Set<Int> = emptySet(),
val autoFile: String? = null,
val autoRelease: String? = null,
) {
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
fun autoRule(): ForcedPackage? =
if (autoFile != null && autoRelease != null) copy(file = autoFile, release = autoRelease, autoFile = null, autoRelease = null) else null
}
internal val FORCED_PACKAGES = listOf(
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux",
autoFile = "dxvk-gplasync-2.7.1-1-linux.wcp", autoRelease = "Dxvk-gplasync-Linux"),
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
)
internal fun autoDxvk(gpu: GpuInfo): ForcedPackage? = forcedDxvk(gpu)?.autoRule()
internal fun forcedDxvk(gpu: GpuInfo): ForcedPackage? = FORCED_PACKAGES.firstOrNull { it.comp == "dxvk" && it.matches(gpu) }
private val ARM64EC_PROBE = mapOf("dxvk" to "d3d11.dll", "vkd3d" to "d3d12.dll")
fun arm64ec(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64ec)
@@ -1134,12 +1124,6 @@ object ComponentsManager {
(com.droiddeck.launcher.core.DeviceSupport.vkBridge(context) && !SessionPrefs.bridgeProtonDxvk(context) &&
bridgeDxvk(AppVulkan.detect(context)) != null)
fun autoDxvkHeld(context: Context): Boolean =
hardwareDxvk(context) && (runtimeManaged(context, "dxvk") || autoDxvk(GpuInfo.detect()) == null)
fun pinnedAutoDxvk(context: Context): String? =
if (SessionPrefs.componentMode(context, "dxvk") == ComponentUpdates.AUTO) autoDxvk(GpuInfo.detect())?.file else null
data class Choice(val file: String, val auto: Boolean, val protonVersion: String)
fun activeChoices(context: Context): Map<String, Map<String, Choice>> = synchronized(lock) {
@@ -1184,14 +1168,6 @@ object ComponentsManager {
internal const val DXVK_FOR_VULKAN_1_1 = "dxvk-1.10.3-linux.wcp"
internal const val DXVK_FOR_VULKAN_1_3_NO_INT64 = "dxvk-2.6.2-linux.wcp"
private data class ForcedRule(
val pkg: ForcedPackage,
val auto: Boolean,
val covers: (Proton) -> Boolean,
val replaces: String? = null,
val label: String? = null,
)
internal fun autoDxvkUndoWanted(bridgeSession: Boolean, keepProtonDxvk: Boolean, vulkan: AppVulkan?, bridgeRule: ForcedPackage?): Boolean =
!bridgeSession || keepProtonDxvk || (vulkan != null && bridgeRule == null)
@@ -1224,7 +1200,6 @@ object ComponentsManager {
undoAuto = { id, comp -> undoAutoChoice(context, id, comp) },
keepProtonDxvk = SessionPrefs.bridgeProtonDxvk(context),
autoPossible = !com.droiddeck.launcher.core.DeviceSupport.adreno(),
dxvkAuto = pinnedAutoDxvk(context) != null,
) }
fun settleAutoDxvk(context: Context) {
@@ -1264,16 +1239,10 @@ object ComponentsManager {
undoAuto: (protonId: String, comp: String) -> String = { _, _ -> "" },
keepProtonDxvk: Boolean = false,
autoPossible: Boolean = true,
dxvkAuto: Boolean = false,
follows: (Proton) -> Boolean = ComponentUpdates::follows,
): String? {
val chooseDxvk = bridgeSession && !keepProtonDxvk
val bridgeRule = if (chooseDxvk) bridgeDxvk(vulkan) else null
val family = FORCED_PACKAGES.filter { it.matches(gpu) }
val pinned = if (dxvkAuto) family.mapNotNull { base -> base.autoRule()?.let { base to it } } else emptyList()
val rules = family.map { base -> ForcedRule(base, false, if (pinned.any { it.first == base }) { p -> !follows(p) } else { _ -> true }) } +
pinned.map { (base, rule) -> ForcedRule(rule, true, follows, replaces = base.file) } +
listOfNotNull(bridgeRule?.let { ForcedRule(it, true, { true }, label = "${vulkan?.label}: DXVK ${it.file}") })
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }.map { it to false } + listOfNotNull(bridgeRule?.let { it to true })
if (rules.isEmpty() && !autoPossible) return null
val installed = protons(context)
val lines = mutableListOf<String>()
@@ -1287,13 +1256,12 @@ object ComponentsManager {
if (autoChoiceStale(entry, null)) lines.add(each(p) { undoAuto(p.id, "dxvk") })
}
}
for ((rule, auto, covers, replaces, label) in rules) {
for ((rule, auto) in rules) {
val state = loadState(context)
val targets = installed.filter { p ->
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
val current = active?.optString("protonVersion") == p.version
covers(p) && (forcedPackageWanted(active?.optString("file"), current) ||
(auto && autoChoiceStale(active, rule.file)) || (replaces != null && current && active?.optString("file") == replaces))
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version) ||
(auto && autoChoiceStale(active, rule.file))
}
if (targets.isEmpty()) continue
val missing = missingPackage(context, rule, lookup, fetch)
@@ -1301,7 +1269,7 @@ object ComponentsManager {
lines.add(missing)
continue
}
if (label != null) lines.add(label)
if (auto) lines.add("${vulkan?.label}: DXVK ${rule.file}")
lines.add(targets.joinToString("; ") { p -> if (auto) each(p) { applyAuto(p.id, rule.file) } else apply(p.id, rule.file) })
}
return lines.filter { it.isNotEmpty() }.joinToString("; ").ifEmpty { null }
@@ -57,10 +57,8 @@ object GameStorage {
return pref.isNotEmpty() && pref != SessionPrefs.GAME_STORAGE_OFF && !attached(File(pref))
}
internal fun attached(
path: File,
stateOf: (File) -> String? = { runCatching { Environment.getExternalStorageState(it) }.getOrNull() },
): Boolean = attached(path, stateOf(File(UsbLink.storagePath(path.path))))
internal fun attached(path: File): Boolean =
attached(path, runCatching { Environment.getExternalStorageState(path) }.getOrNull())
internal fun attached(
path: File,
@@ -68,9 +66,8 @@ 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.parentFile?.isDirectory == true
state == Environment.MEDIA_UNKNOWN || state == null -> path.isDirectory || path.parentFile?.isDirectory == true
else -> false
}
@@ -69,8 +69,8 @@ object SessionDisplay {
const val UNFOLDED_MIN_DP = 600
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 unfoldedGameHeight(width: Int, height: Int): Int =
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
fun unfoldedControlsWanted(
enabled: Boolean,
@@ -40,7 +40,6 @@ 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",
@@ -113,7 +112,6 @@ 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",
@@ -23,7 +23,6 @@ object SessionPrefs {
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
private const val LOW_STORAGE_WARNING = "lowStorageWarning"
private const val SLOW_USB_WARNING_OFF = "slowUsbWarningOff"
private const val SLOW_USB_DISMISSED = "slowUsbDismissed"
@@ -111,16 +110,12 @@ object SessionPrefs {
prefs(context).edit().remove("dualScreenLauncher").putString("dualScreenMode", launcher).putString("dualScreenSessionMode", mode).apply()
}
fun secondScreenPanel(context: Context, desktop: Boolean): String? = prefs(context).let {
it.getString(secondScreenPanelKey(desktop), null) ?: if (desktop) null else it.getString("secondScreenPanel", null)?.takeIf { id -> id != "custom-controls" }
}
fun secondScreenPanel(context: Context): String? = prefs(context).getString("secondScreenPanel", null)
fun setSecondScreenPanel(context: Context, desktop: Boolean, mode: String) {
prefs(context).edit().putString(secondScreenPanelKey(desktop), mode).apply()
fun setSecondScreenPanel(context: Context, mode: String) {
prefs(context).edit().putString("secondScreenPanel", mode).apply()
}
private fun secondScreenPanelKey(desktop: Boolean) = if (desktop) "secondScreenPanel.desktop" else "secondScreenPanel.game"
/** The bottom-screen desktop takes touch as a trackpad unless the user switched it to direct touch. */
fun bottomDesktopDirectTouch(context: Context): Boolean = prefs(context).getBoolean("bottomDesktopDirectTouch", false)
@@ -189,12 +184,6 @@ object SessionPrefs {
prefs(context).edit().putBoolean("dualScreenSessionControls", on).apply()
}
fun androidTextFieldKeyboard(context: Context): Boolean = prefs(context).getBoolean("androidTextFieldKeyboard", false)
fun setAndroidTextFieldKeyboard(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("androidTextFieldKeyboard", on).apply()
}
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
fun setGameStoresEnabled(context: Context, on: Boolean) {
@@ -1555,11 +1544,8 @@ object SessionPrefs {
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
fun deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel =
com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null))
fun setDeckPanel(context: Context, panel: com.droiddeck.launcher.input.DeckPanel) {
prefs(context).edit().putString("deckPanel", panel.id).apply()
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
}
fun suspendPolicy(context: Context, mode: String): String =
@@ -1590,12 +1576,6 @@ object SessionPrefs {
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
}
fun lowStorageWarning(context: Context): Boolean = prefs(context).getBoolean(LOW_STORAGE_WARNING, true)
fun setLowStorageWarning(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(LOW_STORAGE_WARNING, on).apply()
}
fun slowUsbWarningOff(context: Context): Boolean = prefs(context).getBoolean(SLOW_USB_WARNING_OFF, false)
fun setSlowUsbWarningOff(context: Context) {
@@ -93,7 +93,6 @@ object SessionState {
@Volatile var secondScreenDisplay = -1
@Volatile var secondScreenAutoOpened = false
@Volatile var secondScreenProvisional = false
@Volatile var secondScreenAutomatic = false
@Volatile var gameOnBottom = false
@Volatile var steamLibraryMounts: Map<String, java.io.File> = emptyMap()
@@ -5,15 +5,14 @@ import android.util.Log
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.InputProfiles
import com.droiddeck.launcher.runtime.LinuxRuntime
object SettingsReset {
private const val TAG = "SettingsReset"
internal val SESSION_KEYS = setOf(
"orientation", "launcherFullscreen", "animations", "lowStorageWarning", "theme", "runSteamAtStartup", "startupSession", "addedGamesArt",
"dualScreenMode", "dualScreenSessionMode", "dualScreenLauncher", "dualScreenSessionControls", "androidTextFieldKeyboard",
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "startupSession", "addedGamesArt",
"dualScreenMode", "dualScreenSessionMode", "dualScreenLauncher", "dualScreenSessionControls",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"storesInstallLanguage",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backHold", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
@@ -44,7 +43,6 @@ object SettingsReset {
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
session.edit().apply { session.all.keys.filter { isSessionSetting(it) }.forEach { remove(it) } }.commit()
ControllerPrefs.resetAll(context)
InputProfiles.resetAll(context)
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("steam_touch", Context.MODE_PRIVATE).edit()
@@ -96,7 +96,7 @@ object SteamLibraries {
if (picked.none { overlaps(canonical(it.first), canonical(hit.first)) }) picked += hit
}
return picked.map { (dir, games) ->
val shown = File(UsbLink.storagePath(dir.path))
val shown = File(storagePath(dir.path))
Found(dir.path, shown.path, defaultLabel(context, shown), games)
}
}
@@ -155,7 +155,7 @@ object SteamLibraries {
}
fun defaultLabel(context: Context, picked: File): String {
val folder = File(UsbLink.storagePath(picked.path))
val folder = File(storagePath(picked.path))
val volume = try {
context.getSystemService(StorageManager::class.java)?.getStorageVolume(folder)?.getDescription(context)
} catch (e: Exception) {
@@ -171,7 +171,9 @@ object SteamLibraries {
internal fun overlaps(a: String, b: String): Boolean = a == b || a.startsWith("$b/") || b.startsWith("$a/")
private fun canonical(file: File): String = UsbLink.storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
private fun storagePath(path: String): String = if (path.startsWith("/mnt/media_rw/")) "/storage/" + path.removePrefix("/mnt/media_rw/") else path
private fun canonical(file: File): String = storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
private const val SCAN_DEPTH = 2
private const val SCAN_LIMIT = 4000
@@ -66,9 +66,6 @@ object StorageState {
fun lowOnSpace(context: Context): Boolean = freeBytes(context) < LOW_SPACE_BYTES
fun shouldWarnLowSpace(warningEnabled: Boolean, dismissed: Boolean, sessionRunning: Boolean, freeBytes: Long): Boolean =
warningEnabled && !dismissed && !sessionRunning && freeBytes < LOW_SPACE_BYTES
fun bytes(report: StorageReport, item: CleanItem): Long = when (item) {
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.sumOf { it.bytes }
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.sumOf { it.bytes }
@@ -65,8 +65,6 @@ 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,8 +204,7 @@ 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, "", installedAt = game.folder.lastModified(),
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
}
private fun shelves(context: Context): StoreShelves {
@@ -64,8 +64,6 @@ 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,17 +58,16 @@ 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
?: account?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable))
val named = listing != null
StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids")
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 }
@@ -134,9 +133,6 @@ 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, UserApps.githubAsset(repo)), custom, iconPath) to (custom ?: it) }
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), 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) }
@@ -1,226 +0,0 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
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.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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.runtime.WowAddonCatalog
import com.droiddeck.launcher.runtime.WowAddons
import com.droiddeck.launcher.session.SessionState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private class AddonResults {
val items = mutableStateListOf<WowAddonCatalog.Addon>()
var page by mutableIntStateOf(0)
var loading by mutableStateOf(false)
var failed by mutableStateOf<String?>(null)
var more by mutableStateOf(false)
}
@Composable
internal fun AddonBrowserDialog(targets: List<WowAddons.Target>, initial: WowAddons.Target, onDismiss: () -> Unit) {
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var target by remember { mutableStateOf(initial) }
var text by remember { mutableStateOf("") }
var category by remember { mutableStateOf<WowAddonCatalog.Category?>(null) }
var sort by remember { mutableStateOf(WowAddonCatalog.Sort.POPULAR) }
val results = remember(text, category, sort, target) { AddonResults() }
val installing = remember { mutableStateMapOf<String, Int>() }
val installed = remember { mutableStateMapOf<String, Boolean>() }
val categories by produceState(emptyList<WowAddonCatalog.Category>()) {
value = withContext(Dispatchers.IO) { runCatching { WowAddonCatalog.categories(context) }.getOrDefault(emptyList()) }
}
fun load(page: Int) {
if (results.loading) return
results.loading = true
results.failed = null
val into = target
val words = text
val cat = category?.id
val order = sort
scope.launch {
val got = withContext(Dispatchers.IO) {
runCatching { WowAddonCatalog.search(context, WowAddonCatalog.Query(words, cat, WowAddonCatalog.client(into), order), page) }
}
got.onSuccess {
if (page == 0) results.items.clear()
results.items.addAll(it.items.filter { addon -> results.items.none { seen -> seen.id == addon.id } })
results.page = page
results.more = it.more
}.onFailure { results.failed = it.message ?: it.javaClass.simpleName }
results.loading = false
}
}
LaunchedEffect(results) {
if (text.isNotEmpty()) delay(350)
load(0)
}
fun install(addon: WowAddonCatalog.Addon) {
val key = installKey(addon, target)
if (key in installing) return
if (SessionState.running) {
Toast.makeText(context, R.string.main_addons_close_session, Toast.LENGTH_LONG).show()
return
}
val into = target
installing[key] = 0
scope.launch {
val message = withContext(Dispatchers.IO) { installAddon(context, addon, into) { pct -> scope.launch { if (key in installing) installing[key] = pct } } }
installing.remove(key)
if (message.second) installed[key] = true
Toast.makeText(context, message.first, Toast.LENGTH_LONG).show()
}
}
val sourceName = stringResource(R.string.addons_source_wowi)
AppDialog(shown, close, "addonBrowser", wide = true, scrollable = false) {
DialogTitleRow(stringResource(R.string.addons_download), close)
ModeHeader(sourceName, text, { text = it }, stringResource(R.string.stores_search_catalog, sourceName)) {
BrowseChip(stringResource(R.string.addons_source_curse), false) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(WowAddonCatalog.curseForgeUrl(text))).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
}
ChipRow {
BrowseDropdown(sortLabel(sort)) { first, done ->
WowAddonCatalog.Sort.entries.forEachIndexed { index, option ->
MenuItem(sortLabel(option), sort == option, focusRequester = if (index == 0) first else null) { sort = option; done() }
}
}
if (categories.isNotEmpty()) BrowseDropdown(category?.name ?: stringResource(R.string.addons_category_all)) { first, done ->
MenuItem(stringResource(R.string.addons_category_all), category == null, focusRequester = first) { category = null; done() }
categories.forEach { option -> MenuItem(option.name, category?.id == option.id) { category = option; done() } }
}
if (targets.size > 1) BrowseDropdown(target.flavor) { first, done ->
targets.forEachIndexed { index, option ->
MenuItem(option.flavor, option.addOns == target.addOns, focusRequester = if (index == 0) first else null) { target = option; done() }
}
}
}
val height = (LocalConfiguration.current.screenHeightDp * 0.62f).dp
LazyVerticalGrid(
GridCells.Adaptive(libraryTileWidth() + 40.dp), Modifier.fillMaxWidth().height(height),
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(results.items, key = { it.id }) { addon ->
AddonCard(addon, installing[installKey(addon, target)], installed[installKey(addon, target)] == true) { install(addon) }
}
item(key = "tail", span = { GridItemSpan(maxLineSpan) }) {
Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {
when {
results.loading -> LinearProgressIndicator(Modifier.fillMaxWidth())
results.failed != null -> BrowseChip(stringResource(R.string.stores_dl_retry), false) { load(if (results.items.isEmpty()) 0 else results.page + 1) }
results.more -> BrowseChip(stringResource(R.string.stores_itch_load_more), false) { load(results.page + 1) }
results.items.isEmpty() -> Text(stringResource(R.string.addons_none_found), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
@Composable
private fun AddonCard(addon: WowAddonCatalog.Addon, progress: Int?, done: Boolean, onInstall: () -> Unit) {
val pal = LocalPalette.current
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
val hot = rememberHot(src)
val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
Column(
Modifier.fillMaxWidth().clip(shape).glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onInstall)
.controllerConfirm(onClick = onInstall),
) {
ArtCard(addon.name, addon.thumb, null, null, hot, capsule = false, chip = progress?.let { "$it%" }) {
if (done) Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.height(11.dp))
else Text(stringResource(if (progress != null) R.string.store_installing else R.string.store_install), fontSize = 10.sp, color = pal.signal, maxLines = 1)
}
Text(
listOf(addon.author, formatCount(addon.downloads)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
}
@Composable
private fun sortLabel(sort: WowAddonCatalog.Sort): String = stringResource(
when (sort) {
WowAddonCatalog.Sort.POPULAR -> R.string.stores_itch_sort_popular
WowAddonCatalog.Sort.UPDATED -> R.string.stores_itch_sort_new
WowAddonCatalog.Sort.NAME -> R.string.addons_sort_name
WowAddonCatalog.Sort.DOWNLOADS -> R.string.addons_sort_downloads
},
)
private fun formatCount(count: Long): String = when {
count <= 0 -> ""
count >= 1_000_000 -> String.format(java.util.Locale.getDefault(), "%.1fM", count / 1_000_000.0)
count >= 1_000 -> String.format(java.util.Locale.getDefault(), "%.1fK", count / 1_000.0)
else -> count.toString()
}
private fun installAddon(context: Context, addon: WowAddonCatalog.Addon, target: WowAddons.Target, progress: (Int) -> Unit): Pair<String, Boolean> {
val zip = File(File(context.cacheDir, "wowaddons"), "download-wowi-${addon.id.filter(Char::isLetterOrDigit)}-${java.util.UUID.randomUUID()}.zip")
return try {
WowAddonCatalog.download(context, addon, zip, progress)
if (SessionState.running) return context.getString(R.string.main_addons_close_session) to false
val names = WowAddons.import(zip, target)
if (names.isEmpty()) context.getString(R.string.main_addons_none, addon.name) to false
else context.resources.getQuantityString(R.plurals.main_addons_imported, names.size, names.size, target.flavor) to true
} catch (_: WowAddons.TooLarge) {
context.getString(R.string.main_addons_too_large, addon.name) to false
} catch (e: Exception) {
context.getString(R.string.stores_dl_failed_reason, e.message ?: e.javaClass.simpleName) to false
} finally {
zip.delete()
}
}
private fun installKey(addon: WowAddonCatalog.Addon, target: WowAddons.Target) = addon.id + "|" + target.addOns.path
@@ -1,99 +0,0 @@
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),
)
}
}
}
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff. Show more