mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-16 20:27:07 -04:00
Compare commits
30
Commits
exp/sd-prealloc
...
0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a58b0ac3e0 | ||
|
|
e1700ba982 | ||
|
|
736ac7db74 | ||
|
|
6e2966be43 | ||
|
|
679948e792 | ||
|
|
e0fc8362b8 | ||
|
|
9d8ff9615f | ||
|
|
1304809240 | ||
|
|
343a516e1a | ||
|
|
7c08cb58ae | ||
|
|
7e81668de2 | ||
|
|
d231564f8e | ||
|
|
37e89c5b4d | ||
|
|
e070bd72e0 | ||
|
|
9310d19857 | ||
|
|
4454a6d39a | ||
|
|
453f6f5180 | ||
|
|
6d0484941e | ||
|
|
cf47910881 | ||
|
|
4f0f0cf5b1 | ||
|
|
a78d5cb600 | ||
|
|
206c8a8846 | ||
|
|
bf17ce1151 | ||
|
|
ef38c975b9 | ||
|
|
61953c4f7b | ||
|
|
1f13285ab8 | ||
|
|
1bc23ac169 | ||
|
|
50f50b16e0 | ||
|
|
adc86fc7df | ||
|
|
f3fd3a2307 |
No files matched your search
@@ -6,6 +6,7 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/aaudio-sink/**'
|
||||
- 'tools/directaudio-relay/**'
|
||||
- '.github/workflows/build-aaudio-sink.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -25,6 +26,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/out"
|
||||
tools/directaudio-relay/build.sh "$PWD/out"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audio-sink-modules
|
||||
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
done
|
||||
if [ -f tools/gamescope/release.env ]; then
|
||||
. tools/gamescope/release.env
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
|
||||
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
@@ -345,6 +345,11 @@ jobs:
|
||||
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
rm -rf pa-bundle sink-out
|
||||
- name: Build the DirectAudio relay
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/directaudio-relay/build.sh app/src/main/jniLibs/arm64-v8a
|
||||
|
||||
- name: Cache proot
|
||||
id: proot
|
||||
|
||||
@@ -13,13 +13,28 @@ permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-release-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check the current PR state
|
||||
id: state
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .state)
|
||||
echo "closed=$([ "$state" = closed ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
@@ -27,6 +42,7 @@ jobs:
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete the test build
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -42,6 +58,19 @@ jobs:
|
||||
done
|
||||
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- name: Retire the Discord PR announcement
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: python3 tools/release/pr_discord.py retire
|
||||
|
||||
catalog:
|
||||
needs: cleanup
|
||||
if: ${{ always() }}
|
||||
|
||||
@@ -26,6 +26,7 @@ permissions:
|
||||
concurrency:
|
||||
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
@@ -178,6 +179,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-pr-signed-*
|
||||
@@ -307,39 +312,27 @@ jobs:
|
||||
$NEXT
|
||||
EOF
|
||||
)
|
||||
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
||||
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
|
||||
comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --slurp | \
|
||||
jq "[.[][] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$mark\"))] | first // {}")
|
||||
id=$(jq -r '.id // empty' <<<"$comment")
|
||||
if [ -n "$id" ]; then
|
||||
state=$(jq -r '.body | split("\n")[] | select(startswith("<!-- droiddeck-discord: ") and endswith(" -->"))' <<<"$comment")
|
||||
if [ -n "$state" ]; then body=$(printf '%s\n%s' "$body" "$state"); fi
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
|
||||
fi
|
||||
|
||||
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
|
||||
# only a PR's first test build is, and later ones update its release quietly.
|
||||
- name: Post to Discord
|
||||
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
|
||||
- name: Update the Discord PR announcement
|
||||
if: ${{ always() && steps.publish.outcome == 'success' }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$WEBHOOK" ]; then
|
||||
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
|
||||
exit 0
|
||||
fi
|
||||
short=${HEAD_SHA:0:7}
|
||||
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
|
||||
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$what" "$get")
|
||||
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
||||
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
||||
echo "Posted: $content"
|
||||
run: python3 tools/release/pr_discord.py publish
|
||||
|
||||
catalog:
|
||||
needs: publish
|
||||
|
||||
@@ -13,6 +13,7 @@ permissions:
|
||||
concurrency:
|
||||
group: update-app-catalog
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
catalog:
|
||||
|
||||
@@ -14,6 +14,7 @@ app/src/main/assets/linuxfs/
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-inputs
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
|
||||
app/src/main/assets/droiddeck-esync/
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
|
||||
+4
-2
@@ -79,8 +79,8 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 11
|
||||
versionName "0.3.1"
|
||||
versionCode 12
|
||||
versionName "0.3.2"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -165,6 +165,8 @@ android {
|
||||
testOptions {
|
||||
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
|
||||
unitTests.returnDefaultValues = true
|
||||
// Robolectric tests start activities whose text is now all string resources.
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
|
||||
@@ -11,6 +11,14 @@ import com.droiddeck.launcher.session.GpuClockPin
|
||||
* sandboxes are started through (BwrapSpawner).
|
||||
*/
|
||||
class App : Application() {
|
||||
override fun attachBaseContext(base: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
com.droiddeck.launcher.core.AppLanguage.systemChanged(newConfig)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
|
||||
@@ -63,7 +63,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
if (compBusy != null) return
|
||||
compBusy = label
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
val message = runCatching(work).getOrElse { e -> activity.getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
|
||||
ui.post {
|
||||
compBusy = null
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -79,7 +79,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
|
||||
ui.post {
|
||||
compChecking = false
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
|
||||
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
}
|
||||
}, "components-catalog").start()
|
||||
@@ -91,8 +91,8 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
|
||||
"Stored ${pkg.version}"
|
||||
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
activity.getString(R.string.comp_stored, pkg.version)
|
||||
}.getOrElse { e -> activity.getString(R.string.comp_download_failed, e.message ?: e.javaClass.simpleName) }
|
||||
ui.post {
|
||||
compDownloads = compDownloads - item.file
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
|
||||
@@ -19,6 +19,8 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
var deckyChecking by mutableStateOf(false)
|
||||
var deckyStage by mutableStateOf<String?>(null)
|
||||
var deckyPercent by mutableIntStateOf(-1)
|
||||
/** True while the stage is the plugin's binary download (DeckyManager.downloadingBinary). */
|
||||
var deckyDownloadingBinary by mutableStateOf(false)
|
||||
var deckySupervisor by mutableStateOf(false)
|
||||
var deckyReleaseRequest = 0
|
||||
|
||||
@@ -41,11 +43,11 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
|
||||
fun installDecky(release: DeckyManager.Release) {
|
||||
if (deckyStage != null || SessionState.running) return
|
||||
deckyStage = "Starting…"; deckyPercent = -1
|
||||
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
|
||||
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
}.getOrElse { error -> activity.getString(R.string.decky_install_failed, error.message ?: error.javaClass.simpleName) }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
|
||||
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -55,16 +57,17 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
|
||||
fun importPluginZip(archive: File) {
|
||||
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
|
||||
deckyStage = "Starting plugin import…"; deckyPercent = -1
|
||||
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.installPluginZip(activity, archive) { label, value ->
|
||||
ui.post { deckyStage = label; deckyPercent = value }
|
||||
val binary = DeckyManager.downloadingBinary
|
||||
ui.post { deckyStage = label; deckyPercent = value; deckyDownloadingBinary = binary }
|
||||
}
|
||||
}.getOrElse { error -> "Plugin import failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
}.getOrElse { error -> activity.getString(R.string.decky_plugin_failed, error.message ?: error.javaClass.simpleName) }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1
|
||||
val message = problem ?: "Plugin installed. Restart Steam to load it."
|
||||
deckyStage = null; deckyPercent = -1; deckyDownloadingBinary = false
|
||||
val message = problem ?: activity.getString(R.string.decky_plugin_installed)
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "import-decky-plugin").start()
|
||||
|
||||
@@ -37,7 +37,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
|
||||
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
|
||||
// Empty until the first refresh: the activity has no context yet while its fields are made.
|
||||
var releaseStatus by mutableStateOf("")
|
||||
var releaseChecking by mutableStateOf(false)
|
||||
var canRestoreBundled by mutableStateOf(false)
|
||||
/** Asset name -> download percent, while it downloads. */
|
||||
@@ -57,10 +58,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
private var autoCheckedThisProcess = false
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
|
||||
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
|
||||
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
|
||||
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus.ifEmpty { activity.getString(R.string.drivers_not_checked) }, checking = releaseChecking,
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
@@ -73,10 +74,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
val bundles = DriverBundle.all(activity)
|
||||
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
|
||||
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
|
||||
DriverRow(id, it.label, activity.getString(if (linux) R.string.drivers_bundle_sets_display else R.string.drivers_bundle_sets_runtime), true, DriverRow.BUNDLE)
|
||||
}
|
||||
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
if (id.isEmpty()) DriverRow("", activity.getString(R.string.gpu_runtime_default), activity.getString(R.string.drivers_runtime_builtin), false)
|
||||
else bundleRow(id, linux = true) ?: DriverRow(
|
||||
id, lm.getDriverName(id),
|
||||
listOfNotNull(
|
||||
@@ -92,8 +93,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val auto = td.autoId()
|
||||
androidRows = buildList {
|
||||
add(DriverRow(
|
||||
TurnipDriver.AUTO, "Auto - picked by GPU",
|
||||
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
|
||||
TurnipDriver.AUTO, activity.getString(R.string.gpu_auto_by_gpu),
|
||||
if (auto == "system") activity.getString(R.string.drivers_system_vulkan) else activity.getString(R.string.drivers_bundled, td.displayName(auto)),
|
||||
false,
|
||||
))
|
||||
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
|
||||
@@ -109,7 +110,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
|
||||
val label = activity.getString(if (mode == SessionPrefs.GPU_DRIVERS_AUTO) R.string.common_auto else R.string.drivers_manual)
|
||||
return active?.let { activity.getString(R.string.drivers_summary, label, it) } ?: label
|
||||
}
|
||||
|
||||
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
|
||||
@@ -125,7 +127,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val mb = p.assets.sumOf { it.size } / 1_048_576.0
|
||||
PairRow(
|
||||
key = p.key, name = p.name, version = p.version,
|
||||
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
|
||||
detail = if (!p.complete) activity.getString(R.string.drivers_half_published) else activity.getString(R.string.drivers_pair_size, mb),
|
||||
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
|
||||
installed = displayId != null && linuxId != null,
|
||||
active = displayId != null && linuxId != null &&
|
||||
@@ -153,11 +155,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
|
||||
autoStatus = activity.getString(R.string.drivers_none_to_set, gpu.supportText(activity).lowercase())
|
||||
return
|
||||
}
|
||||
releaseChecking = true
|
||||
autoStatus = "Checking for the latest drivers…"
|
||||
autoStatus = activity.getString(R.string.drivers_checking_latest)
|
||||
Thread({
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
@@ -172,9 +174,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val pair = pairs.firstOrNull { it.key == key }
|
||||
val row = pairRows.firstOrNull { it.key == key }
|
||||
when {
|
||||
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
|
||||
row?.active == true -> autoStatus = problem?.let { activity.getString(R.string.drivers_up_to_date_check_failed, it) }
|
||||
?: activity.getString(R.string.upd_current_status)
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
|
||||
autoStatus = problem?.let { activity.getString(R.string.upd_couldnt_check, it) } ?: activity.getString(R.string.drivers_recommended_unpublished)
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +200,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (!pair.complete) return
|
||||
pairBusy = pair.key
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
|
||||
if (auto) autoStatus = activity.getString(R.string.drivers_downloading_pair, pair.name, pair.version)
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
@@ -217,7 +220,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
val restart = displayChanged && CompositorHost.isStarted
|
||||
if (auto) {
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
@@ -230,14 +233,18 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
|
||||
autoStatus = activity.getString(
|
||||
if (downloaded) { if (restart) R.string.drivers_updated_to_restart else R.string.drivers_updated_to }
|
||||
else { if (restart) R.string.drivers_switched_to_restart else R.string.drivers_switched_to },
|
||||
pair.name, pair.version,
|
||||
)
|
||||
} else {
|
||||
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(activity, activity.getString(if (restart) R.string.drivers_using_restart else R.string.drivers_using, pair.name, pair.version), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "driver pair ${pair.key}", e)
|
||||
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
|
||||
if (auto) autoStatus = why ?: "Download failed"
|
||||
val why = if (e is IllegalArgumentException) e.message else activity.getString(R.string.comp_download_failed, e.message)
|
||||
if (auto) autoStatus = why ?: activity.getString(R.string.user_apps_download_failed)
|
||||
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refreshDrivers()
|
||||
@@ -298,7 +305,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
try {
|
||||
LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
} catch (runtime: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
|
||||
throw IllegalArgumentException(activity.getString(R.string.drivers_not_a_driver))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,10 +314,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "driver import", e)
|
||||
"Import failed: ${e.message}"
|
||||
activity.getString(R.string.drivers_import_failed, e.message)
|
||||
}
|
||||
ui.post {
|
||||
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
|
||||
val done = bundle?.let { useBundle(it) }
|
||||
?: name?.let { activity.getString(R.string.comp_imported, it) } ?: activity.getString(R.string.drivers_imported_unnamed)
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: done,
|
||||
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
@@ -340,8 +348,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
return "Using ${bundle.label} as both the runtime and display driver." +
|
||||
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
return activity.getString(
|
||||
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
|
||||
bundle.label,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,7 +364,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (bundle != null) {
|
||||
DriverBundle.remove(activity, bundle)
|
||||
TurnipReleases.forget(activity, bundle.id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted_bundle, bundle.label), android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
@@ -367,7 +377,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted, id), android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
@@ -378,26 +388,28 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
.filter { it.bundle || it.linux == linux }
|
||||
.filter { a -> installedIdOf(a) == null }
|
||||
.map { a ->
|
||||
val mb = "%.1f MB".format(a.size / 1_048_576.0)
|
||||
val mb = activity.getString(R.string.common_size_mb, a.size / 1_048_576.0)
|
||||
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
|
||||
}
|
||||
linuxDownloads = rows(linux = true)
|
||||
androidDownloads = rows(linux = false)
|
||||
if (!releaseChecking) releaseStatus = when (check) {
|
||||
null -> "Not checked yet - tap refresh to look for new drivers"
|
||||
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
|
||||
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
|
||||
" · checked ${ago(check.checkedAt)}"
|
||||
null -> activity.getString(R.string.drivers_not_checked)
|
||||
else -> {
|
||||
val latest = check.latest.joinToString(" · ") { "${it.first} ${it.second}" }
|
||||
if (check.failed.isEmpty()) activity.getString(R.string.drivers_release_status, latest, ago(check.checkedAt))
|
||||
else activity.getString(R.string.drivers_release_status_failed, latest, check.failed.joinToString(), ago(check.checkedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ago(t: Long): String {
|
||||
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
|
||||
return when {
|
||||
m < 1 -> "just now"
|
||||
m < 60 -> "$m min ago"
|
||||
m < 48 * 60 -> "${m / 60} h ago"
|
||||
else -> "${m / (24 * 60)} days ago"
|
||||
m < 1 -> activity.getString(R.string.upd_just_now)
|
||||
m < 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_minutes, m.toInt(), m)
|
||||
m < 48 * 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_hours, (m / 60).toInt(), m / 60)
|
||||
else -> activity.resources.getQuantityString(R.plurals.drivers_ago_days, (m / (24 * 60)).toInt(), m / (24 * 60))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,16 +417,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
|
||||
releaseStatus = activity.getString(R.string.drivers_checking_sources)
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: activity.getString(R.string.drivers_check_failed)
|
||||
}
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
if (problem != null) releaseStatus = activity.getString(R.string.upd_couldnt_check, problem)
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
}
|
||||
@@ -433,13 +445,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release driver download", e)
|
||||
"Download failed: ${e.message}"
|
||||
activity.getString(R.string.comp_download_failed, e.message)
|
||||
}
|
||||
ui.post {
|
||||
releaseProgress.remove(assetName)
|
||||
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
|
||||
val installed = activity.getString(
|
||||
if (asset.bundle) R.string.drivers_installed_pick_either else R.string.drivers_installed_pick,
|
||||
asset.name.removeSuffix(".zip"),
|
||||
)
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
|
||||
activity, problem ?: installed,
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
|
||||
@@ -7,6 +7,11 @@ import com.droiddeck.launcher.session.SessionService
|
||||
|
||||
/** Receives the system Home launch and routes it without consuming KEYCODE_HOME. */
|
||||
class HomeActivity : Activity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
routeHome()
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.ui.PackageRow
|
||||
import com.droiddeck.launcher.ui.exposeTestTags
|
||||
import com.droiddeck.launcher.session.OfflineMode
|
||||
import com.droiddeck.launcher.session.ProtonExtras
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
@@ -84,6 +85,11 @@ import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
* the store, downloads, settings - is the client's own job once [SessionActivity] has it on screen.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
private val ui = Handler(Looper.getMainLooper())
|
||||
private val drivers = DriverMenus(this, ui)
|
||||
private val components = ComponentsMenu(this, ui)
|
||||
@@ -107,7 +113,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var stage by mutableStateOf("")
|
||||
private var percent by mutableIntStateOf(-1)
|
||||
private var failed by mutableStateOf(false)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var frameGenLabel by mutableStateOf("")
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
@@ -154,7 +160,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(true)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
@@ -170,6 +176,16 @@ class MainActivity : ComponentActivity() {
|
||||
refreshWifiDiscovery()
|
||||
}
|
||||
}
|
||||
private val mediaRefresh = Runnable {
|
||||
refresh()
|
||||
refreshAddedGames()
|
||||
}
|
||||
private val mediaReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context?, intent: Intent?) {
|
||||
ui.removeCallbacks(mediaRefresh)
|
||||
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
|
||||
refreshWifiDiscovery()
|
||||
@@ -253,29 +269,32 @@ class MainActivity : ComponentActivity() {
|
||||
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
|
||||
private fun importSaves(name: String, game: () -> GameSaves.Game) {
|
||||
if (SessionState.running) {
|
||||
android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_saves_close_session, android.widget.Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
onSavePicked = { zip ->
|
||||
saveAction("Importing into $name") {
|
||||
val (written, backup) = GameSaves.import(game(), zip)
|
||||
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
|
||||
"Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
|
||||
saveAction(getString(R.string.main_saves_importing, name)) {
|
||||
val (written, backup) = GameSaves.import(this, game(), zip)
|
||||
val kind = GameSaves.layoutOf(zip)?.label(this) ?: getString(R.string.saves_zip)
|
||||
resources.getQuantityString(
|
||||
if (backup != null) R.plurals.main_saves_imported_backup else R.plurals.main_saves_imported,
|
||||
written, written, kind, name,
|
||||
)
|
||||
}
|
||||
}
|
||||
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path))
|
||||
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), getString(R.string.main_saves_pick_zip, name), GameSaves.savesDir().parentFile?.parentFile?.path))
|
||||
}
|
||||
|
||||
/** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */
|
||||
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
|
||||
onSavePicked = { dir ->
|
||||
saveAction("Exporting $name") {
|
||||
val (zip, count) = GameSaves.export(game(), layout, dir)
|
||||
"Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}"
|
||||
saveAction(getString(R.string.main_saves_exporting, name)) {
|
||||
val (zip, count) = GameSaves.export(this, game(), layout, dir)
|
||||
resources.getQuantityString(R.plurals.main_saves_exported, count, count, layout.label(this), zip.path.removePrefix("/storage/emulated/0/"))
|
||||
}
|
||||
}
|
||||
GameSaves.savesDir().mkdirs()
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path))
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
|
||||
}
|
||||
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
@@ -322,12 +341,13 @@ class MainActivity : ComponentActivity() {
|
||||
private var hdrReason by mutableStateOf<String?>(null)
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var renderer by mutableStateOf("vulkan")
|
||||
private var gameStorage by mutableStateOf("")
|
||||
private var storageDiagnostics by mutableStateOf(false)
|
||||
private var storageOptions by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
|
||||
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
|
||||
}
|
||||
@@ -336,6 +356,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
|
||||
private var runningLabel by mutableStateOf<String?>(null)
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
private var agentCommands by mutableStateOf(false)
|
||||
private var showRoms by mutableStateOf(false)
|
||||
private var homeAppSelected by mutableStateOf(false)
|
||||
private var homeScreenEnabled by mutableStateOf(false)
|
||||
@@ -358,15 +379,15 @@ class MainActivity : ComponentActivity() {
|
||||
if (intent?.component?.className == SessionActivity::class.java.name) {
|
||||
when {
|
||||
busy || LinuxRuntimeInstaller.isBusy() -> {
|
||||
android.widget.Toast.makeText(this, "Wait for the runtime operation to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_runtime, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
|
||||
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_proton_install, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
pkgStage != null -> {
|
||||
android.widget.Toast.makeText(this, "Wait for the desktop app install to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -402,7 +423,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun pickGameExport(game: Library.SteamGame?) {
|
||||
onSavePicked = { folder ->
|
||||
saveAction(getString(R.string.game_frontend_files)) {
|
||||
val saved = if (game != null) GameFiles.export(folder, game)
|
||||
val saved = if (game != null) GameFiles.export(this, folder, game)
|
||||
else {
|
||||
GameFileSync.enable(this, folder)
|
||||
folder
|
||||
@@ -442,7 +463,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun launchGame(game: Library.SteamGame, returnHome: Boolean = false): Boolean {
|
||||
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
|
||||
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
if (SessionState.running) {
|
||||
@@ -495,6 +516,7 @@ class MainActivity : ComponentActivity() {
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
theme = SessionPrefs.theme(this)
|
||||
frameGenLabel = getString(R.string.frame_gen_off)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
@@ -506,7 +528,7 @@ class MainActivity : ComponentActivity() {
|
||||
updates.start()
|
||||
setContent {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
sm != null -> { { ModeSettingsHost(sm) } }
|
||||
@@ -526,12 +548,13 @@ class MainActivity : ComponentActivity() {
|
||||
busy = busy, stage = stage, percent = percent,
|
||||
desktopInstalled = desktopInstalled,
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
|
||||
appScale = appScale,
|
||||
isHomeApp = homeAppSelected,
|
||||
homeScreenEnabled = homeScreenEnabled,
|
||||
@@ -637,9 +660,13 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
},
|
||||
onAgentCommands = {
|
||||
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
},
|
||||
onShareLogs = {
|
||||
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
|
||||
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
|
||||
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
},
|
||||
@@ -648,7 +675,7 @@ class MainActivity : ComponentActivity() {
|
||||
val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0)
|
||||
ui.post {
|
||||
android.widget.Toast.makeText(this,
|
||||
if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.",
|
||||
if (cleared == 0) getString(R.string.main_no_logs_to_clear) else resources.getQuantityString(R.plurals.main_logs_cleared, cleared, cleared),
|
||||
android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "clear-logs").start()
|
||||
@@ -659,6 +686,13 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onLanguage = { tag ->
|
||||
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
|
||||
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
|
||||
// Redrawn in the new language; Setup's page and tab are saved state.
|
||||
recreate()
|
||||
}
|
||||
},
|
||||
onAppScale = { percent ->
|
||||
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
@@ -691,7 +725,7 @@ class MainActivity : ComponentActivity() {
|
||||
val result = runCatching {
|
||||
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
|
||||
}
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging pairing failed" }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_pair_failed) }
|
||||
ui.post { complete(error) }
|
||||
}, "wireless-adb-pair").start()
|
||||
},
|
||||
@@ -704,7 +738,7 @@ class MainActivity : ComponentActivity() {
|
||||
onWirelessAdbApply = { host, port, enabled, complete ->
|
||||
Thread({
|
||||
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging command failed" }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_command_failed) }
|
||||
ui.post {
|
||||
if (error == null) refreshPhantomStatus()
|
||||
complete(error)
|
||||
@@ -714,7 +748,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSetPhantomProcessLimit = { enabled, complete ->
|
||||
Thread({
|
||||
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging is unavailable" }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_unavailable) }
|
||||
ui.post {
|
||||
if (error == null) refreshPhantomStatus()
|
||||
complete(error)
|
||||
@@ -723,9 +757,9 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onCopyPhantomCommand = { enabled ->
|
||||
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
||||
ClipData.newPlainText("DroidDeck child-process setting", PhantomProcessLimit.adbCommand(enabled)),
|
||||
ClipData.newPlainText(getString(R.string.main_adb_clip_label), PhantomProcessLimit.adbCommand(enabled)),
|
||||
)
|
||||
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_adb_copied, android.widget.Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onDismissPhantomGate = { showPhantomGate = false },
|
||||
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
|
||||
@@ -759,24 +793,24 @@ class MainActivity : ComponentActivity() {
|
||||
path = romsDir,
|
||||
onChoose = {
|
||||
showRoms = false
|
||||
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, "Choose the ROMs folder", romsDir))
|
||||
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_roms), romsDir))
|
||||
},
|
||||
onClear = { SessionPrefs.setRomsDir(this, ""); romsDir = null; showRoms = false },
|
||||
onDismiss = { showRoms = false },
|
||||
)
|
||||
showNonAdreno?.let { release ->
|
||||
ConfirmDialog(
|
||||
title = "Not an Adreno GPU",
|
||||
text = "Turnip supports Adreno GPUs. On ${com.droiddeck.launcher.core.DeviceSupport.gpuName()}, Steam may show a black screen. Download: ${"%.0f".format(release.size / 1e6)} MB.",
|
||||
confirm = "Install anyway",
|
||||
title = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_title),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@MainActivity), release.size / 1e6),
|
||||
confirm = androidx.compose.ui.res.stringResource(R.string.main_install_anyway),
|
||||
onConfirm = { showNonAdreno = null; install(release) },
|
||||
onDismiss = { showNonAdreno = null },
|
||||
)
|
||||
}
|
||||
if (showRemove) ConfirmDialog(
|
||||
title = "Remove Linux runtime",
|
||||
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
|
||||
confirm = "Remove",
|
||||
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
|
||||
confirm = androidx.compose.ui.res.stringResource(R.string.store_remove),
|
||||
onConfirm = { showRemove = false; removeRuntime() },
|
||||
onDismiss = { showRemove = false },
|
||||
)
|
||||
@@ -886,7 +920,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (!SessionState.running) Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
if (applied.isNotEmpty()) ui.post {
|
||||
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
ui.post { if (showComponents) components.refreshComponents() }
|
||||
}, "components-queue").start()
|
||||
@@ -909,6 +943,11 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
|
||||
registerReceiver(mediaReceiver, android.content.IntentFilter().apply {
|
||||
for (action in listOf(Intent.ACTION_MEDIA_MOUNTED, Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_REMOVED,
|
||||
Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_BAD_REMOVAL)) addAction(action)
|
||||
addDataScheme("file")
|
||||
})
|
||||
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
@@ -920,6 +959,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
unregisterReceiver(wifiLocationReceiver)
|
||||
unregisterReceiver(mediaReceiver)
|
||||
ui.removeCallbacks(mediaRefresh)
|
||||
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
|
||||
// The session covers the page by now; coming back finds it as it was.
|
||||
flood = null
|
||||
@@ -945,9 +986,13 @@ class MainActivity : ComponentActivity() {
|
||||
try {
|
||||
HomeApp.launch(this, app, displayId)
|
||||
} catch (_: Exception) {
|
||||
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
|
||||
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
|
||||
android.widget.Toast.makeText(this, "Could not open ${app.label} on $target", android.widget.Toast.LENGTH_SHORT).show()
|
||||
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
|
||||
val message = when {
|
||||
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
|
||||
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
|
||||
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
|
||||
}
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +1023,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || saveBusy != null) return
|
||||
saveBusy = label
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
val message = runCatching(work).getOrElse { e -> getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
|
||||
ui.post {
|
||||
saveBusy = null
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -1024,10 +1069,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun importComponent(uri: Uri) {
|
||||
val name = displayNameOf(uri) ?: "imported.wcp"
|
||||
components.componentAction("Importing") {
|
||||
components.componentAction(getString(R.string.comp_busy_importing)) {
|
||||
val tmp = File(cacheDir, "component-import.wcp")
|
||||
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file")
|
||||
try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() }
|
||||
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
|
||||
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,14 +1090,14 @@ class MainActivity : ComponentActivity() {
|
||||
requestInitialFocus = focusComponentsContent,
|
||||
onProton = { components.chooseProton(it) },
|
||||
onComp = { components.compComp = it },
|
||||
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
|
||||
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
|
||||
onCancelQueued = { components.compProton?.let { pid -> components.componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, components.compComp); "The waiting swap was cancelled." } } },
|
||||
onDeletePackage = { file -> components.componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
|
||||
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
|
||||
onSwap = { file -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_swapping)) { ComponentsManager.swap(this, pid, file) } } },
|
||||
onRestore = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_restoring)) { ComponentsManager.restore(this, pid, components.compComp, version) } } },
|
||||
onCancelQueued = { components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_cancelling)) { ComponentsManager.cancelQueued(this, pid, components.compComp); getString(R.string.comp_swap_cancelled) } } },
|
||||
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
|
||||
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
|
||||
onDownload = { components.downloadComponent(it) },
|
||||
onRefresh = { components.refreshComponentCatalog() },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
|
||||
onBack = { showComponents = false },
|
||||
gpu = drivers.state(),
|
||||
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
|
||||
@@ -1065,9 +1110,9 @@ class MainActivity : ComponentActivity() {
|
||||
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
|
||||
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
|
||||
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_linux))) },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_android))) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
),
|
||||
)
|
||||
@@ -1103,14 +1148,14 @@ class MainActivity : ComponentActivity() {
|
||||
private fun installPackage(id: String) {
|
||||
val entry = catalog?.firstOrNull { it.id == id } ?: return
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
|
||||
pkgId = id; pkgStage = "Starting…"; pkgPercent = -1
|
||||
pkgId = id; pkgStage = getString(R.string.store_starting); pkgPercent = -1
|
||||
Thread({
|
||||
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
|
||||
ui.post { pkgStage = stage; pkgPercent = percent }
|
||||
}
|
||||
ui.post {
|
||||
pkgStage = null; pkgId = null
|
||||
if (problem != null) android.widget.Toast.makeText(this, "${entry.name}: $problem", android.widget.Toast.LENGTH_LONG).show()
|
||||
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, entry.name, problem), android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshPackages()
|
||||
refresh()
|
||||
}
|
||||
@@ -1120,7 +1165,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun removePackage(id: String) {
|
||||
val entry = catalog?.firstOrNull { it.id == id } ?: return
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
|
||||
pkgId = id; pkgStage = if (entry.kind == "appimage") "Removing ${entry.name}…" else "Forgetting ${entry.name}…"; pkgPercent = -1
|
||||
pkgId = id; pkgStage = getString(if (entry.kind == "appimage") R.string.main_removing_named else R.string.main_forgetting_named, entry.name); pkgPercent = -1
|
||||
Thread({
|
||||
DesktopCatalog.remove(this, entry)
|
||||
ui.post {
|
||||
@@ -1142,6 +1187,7 @@ class MainActivity : ComponentActivity() {
|
||||
gpuDrivers = drivers.summary(),
|
||||
touchMode = touchMode,
|
||||
suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground,
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
@@ -1174,6 +1220,7 @@ class MainActivity : ComponentActivity() {
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
deckyDownloadingBinary = decky.deckyDownloadingBinary,
|
||||
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
|
||||
),
|
||||
ModeSettingsActions(
|
||||
@@ -1200,6 +1247,10 @@ class MainActivity : ComponentActivity() {
|
||||
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
|
||||
onSteamDownloadsInBackground = { enabled ->
|
||||
SessionPrefs.setSteamDownloadsInBackground(this, enabled)
|
||||
steamDownloadsInBackground = enabled
|
||||
},
|
||||
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onBackActionsInverted = { inverted ->
|
||||
@@ -1221,7 +1272,7 @@ class MainActivity : ComponentActivity() {
|
||||
onRenderer = { r -> SessionPrefs.setDesktopRenderer(this, r); renderer = r },
|
||||
onGameStorage = { path, label -> setGameStorage(path, label) },
|
||||
onPickGameStorageFolder = {
|
||||
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
|
||||
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
|
||||
},
|
||||
onStorageDiagnostics = { on ->
|
||||
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
|
||||
@@ -1248,13 +1299,13 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
},
|
||||
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, "Choose a folder of your own games", addedGamesDirs.lastOrNull())) },
|
||||
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_added_games), addedGamesDirs.lastOrNull())) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
|
||||
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
|
||||
onPickAddedGameExe = { folder ->
|
||||
pendingAddedGame = folder
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.main_pick_game_exe), folder))
|
||||
},
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
@@ -1265,7 +1316,7 @@ class MainActivity : ComponentActivity() {
|
||||
decky.deckySupervisor = false
|
||||
},
|
||||
onPickDeckyPluginZip = {
|
||||
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Decky plugin ZIP"))
|
||||
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
|
||||
},
|
||||
onDismiss = { settingsMode = null },
|
||||
),
|
||||
@@ -1321,7 +1372,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
|
||||
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
|
||||
private fun fetchAddedGameArt() {
|
||||
@@ -1368,6 +1419,7 @@ class MainActivity : ComponentActivity() {
|
||||
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
directAudio = SessionPrefs.directAudio(this)
|
||||
@@ -1384,7 +1436,7 @@ class MainActivity : ComponentActivity() {
|
||||
Thread({
|
||||
drivers.refreshDrivers()
|
||||
val games = scanAddedGames()
|
||||
val storage = GameStorage.options(this).map { it.label to it.path }
|
||||
val storage = GameStorage.options(this)
|
||||
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
|
||||
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
|
||||
ui.post {
|
||||
@@ -1403,9 +1455,9 @@ class MainActivity : ComponentActivity() {
|
||||
/** A second Steam library, proven writable first; "" = internal only. */
|
||||
private fun setGameStorage(path: String, label: String) {
|
||||
if (path.isNotEmpty() && path != SessionPrefs.GAME_STORAGE_OFF) {
|
||||
val problem = GameStorage.prepare(path)
|
||||
val problem = GameStorage.prepare(this, path)
|
||||
if (problem != null) {
|
||||
android.widget.Toast.makeText(this, "Not usable: $problem", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, getString(R.string.main_storage_not_usable, problem), android.widget.Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1446,10 +1498,11 @@ class MainActivity : ComponentActivity() {
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
runningLabel = if (SessionState.running) when (SessionState.mode) {
|
||||
SessionService.MODE_DESKTOP -> "Desktop"
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
|
||||
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
|
||||
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
|
||||
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: getString(R.string.main_running_program)
|
||||
else -> "Steam"
|
||||
} else null
|
||||
// The libraries, off the main thread: manifests and a folder scan.
|
||||
@@ -1516,7 +1569,10 @@ class MainActivity : ComponentActivity() {
|
||||
private fun refreshPhantomStatus() {
|
||||
phantomProcessStatus = PhantomProcessLimit.read(this)
|
||||
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
|
||||
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
|
||||
getString(
|
||||
R.string.phantom_warning, PhantomProcessLimit.title(this, phantomProcessStatus),
|
||||
PhantomProcessLimit.instructions(this, phantomProcessStatus), PhantomProcessLimit.adbCommand(),
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
@@ -1532,10 +1588,10 @@ class MainActivity : ComponentActivity() {
|
||||
} catch (error: Exception) {
|
||||
if (displayId != null) {
|
||||
runCatching { startActivity(intent) }
|
||||
android.widget.Toast.makeText(this, "Could not open Settings on the bottom screen; opened it on the main screen.", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_settings_main_screen, android.widget.Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
startActivity(Intent(android.provider.Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
android.widget.Toast.makeText(this, "Open Developer options in Settings", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_open_dev_options, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1568,7 +1624,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
|
||||
busy = true
|
||||
failed = false
|
||||
stage = if (LinuxRuntimeInstaller.isRemoving()) "Removing Linux runtime" else "Starting…"
|
||||
stage = getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
|
||||
percent = -1
|
||||
Thread({
|
||||
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
|
||||
@@ -1593,6 +1649,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val MEDIA_SETTLE_MS = 1500L
|
||||
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
|
||||
private val ZIP_EXT = listOf("zip")
|
||||
private val WCP_EXT = listOf("wcp")
|
||||
|
||||
@@ -24,7 +24,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
ProtonExtras.unqueue(activity, tool)
|
||||
protonBusyId = id
|
||||
protonStage = "Starting…"
|
||||
protonStage = activity.getString(R.string.store_starting)
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
val problem = ProtonExtras.install(activity, tool) { label, value ->
|
||||
@@ -44,7 +44,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
protonBusyId = id
|
||||
protonStage = "Removing ${tool.name}…"
|
||||
protonStage = activity.getString(R.string.main_removing_named, tool.name)
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
ProtonExtras.remove(activity, tool)
|
||||
|
||||
@@ -100,6 +100,11 @@ import kotlin.math.abs
|
||||
* the HUD line, the loading overlay, the drawer and its dialogs.
|
||||
*/
|
||||
class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
|
||||
private var pipUi by mutableStateOf(false)
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
@@ -241,6 +246,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var onScreenButtonsVisible by mutableStateOf(false)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
@@ -300,7 +306,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
|
||||
runtimeRemovalBlocked = true
|
||||
android.widget.Toast.makeText(this, "Wait for runtime removal to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.session_wait_removal, android.widget.Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
@@ -356,6 +362,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
@@ -461,6 +468,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (hud.text.isNotEmpty()) HudText(hud.text)
|
||||
if (loading.visible) LoadingOverlay(
|
||||
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
|
||||
topic = loading.topic, readable = loading.readable,
|
||||
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
|
||||
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
|
||||
onCancel = { SessionService.stop(this@SessionActivity); finish() },
|
||||
@@ -496,7 +504,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
|
||||
steam = SessionState.mode == SessionService.MODE_STEAM,
|
||||
title = if (SessionState.mode == SessionService.MODE_RUN)
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_title_game) else null,
|
||||
isHomeApp = isHomeApp,
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
@@ -504,8 +512,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
|
||||
touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct),
|
||||
fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
@@ -556,6 +565,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
suspendPolicy = policy
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onSteamDownloadsInBackground = { enabled ->
|
||||
SessionPrefs.setSteamDownloadsInBackground(this@SessionActivity, enabled)
|
||||
steamDownloadsInBackground = enabled
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
@@ -565,9 +579,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
try {
|
||||
HomeApp.launch(this@SessionActivity, app, displayId)
|
||||
} catch (_: Exception) {
|
||||
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
|
||||
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
|
||||
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
|
||||
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
|
||||
val message = when {
|
||||
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
|
||||
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
|
||||
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
|
||||
}
|
||||
Toast.makeText(this@SessionActivity, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity),
|
||||
@@ -685,7 +703,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
|
||||
uiHandler.post {
|
||||
drawerComponents = snap
|
||||
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
|
||||
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "drawer-components").start()
|
||||
}
|
||||
@@ -700,10 +718,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val current = active ?: "orig:$build"
|
||||
if (queued != null && value == current) {
|
||||
// Picking what is already in place again cancels the swap waiting for the game.
|
||||
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
|
||||
ComponentsManager.cancelQueued(this, protonId, comp); getString(R.string.comp_swap_cancelled)
|
||||
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
|
||||
else ComponentsManager.swap(this, protonId, value)
|
||||
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
}.getOrElse { e -> getString(R.string.session_swap_failed, e.message ?: e.javaClass.simpleName) }
|
||||
uiHandler.post {
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshDrawerComponents()
|
||||
@@ -717,20 +735,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
else intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
|
||||
|
||||
private fun pausedTitle(): String = when (SessionState.mode) {
|
||||
SessionService.MODE_DESKTOP -> "The desktop is paused"
|
||||
SessionService.MODE_RUN -> (com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program") + " is paused"
|
||||
else -> "Steam is paused"
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.session_desktop_paused)
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program)
|
||||
?.let { getString(R.string.session_named_paused, it) } ?: getString(R.string.session_program_paused)
|
||||
else -> getString(R.string.load_steam_paused)
|
||||
}
|
||||
|
||||
private fun loadingTitle(): String = when (loadingMode()) {
|
||||
SessionService.MODE_DESKTOP ->
|
||||
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) "Starting Steam on the desktop"
|
||||
else "Starting the desktop"
|
||||
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) getString(R.string.session_starting_steam_desktop)
|
||||
else getString(R.string.session_starting_desktop)
|
||||
SessionService.MODE_RUN -> {
|
||||
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
"Starting " + (com.droiddeck.launcher.frontend.Library.nameForProgram(program) ?: "the program")
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(program)?.let { getString(R.string.session_starting_named, it) }
|
||||
?: getString(R.string.session_starting_program)
|
||||
}
|
||||
else -> "Starting Steam"
|
||||
else -> getString(R.string.load_starting_steam)
|
||||
}
|
||||
|
||||
private fun shareCurrentSessionLogs() {
|
||||
@@ -738,17 +758,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// ended screen's Share logs is for that one.
|
||||
val folder = SessionPaths.current() ?: SessionPaths.lastEnded()
|
||||
if (folder == null || !folder.isDirectory) {
|
||||
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, R.string.session_no_logs, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, R.string.session_logs_archive_failed, Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "could not share current session logs", it)
|
||||
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, R.string.session_logs_share_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -777,6 +797,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
}
|
||||
@@ -805,8 +826,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
"${SessionState.installing}.installing",
|
||||
mapOf("component" to SessionState.installing),
|
||||
)
|
||||
loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
|
||||
else "downloading Proton Experimental (ARM64)"
|
||||
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
|
||||
loading.say(getString(first.downloading), first.topic, readable = true)
|
||||
loading.percent = -1
|
||||
Thread({
|
||||
var failedComponent: String? = null
|
||||
@@ -847,7 +868,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return@post
|
||||
}
|
||||
loading.percent = -1
|
||||
loading.step = "Starting the session…"
|
||||
loading.say(getString(R.string.session_starting_session), LoadingState.Topic.SESSION, readable = false)
|
||||
// The surface may have come and gone while the download ran; start on the live one.
|
||||
if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder)
|
||||
}
|
||||
@@ -863,14 +884,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, "session-failure-artifacts").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* The loading screen's lines for one package: downloading it (with and without its size), checking it,
|
||||
* unpacking it; the checklist [topic] they belong to, and whether the checking line is shown as it is.
|
||||
*/
|
||||
private class PackageLines(
|
||||
val downloading: Int, val downloadingMb: Int, val checking: Int, val unpacking: Int,
|
||||
val topic: LoadingState.Topic, val checkingReadable: Boolean,
|
||||
)
|
||||
|
||||
/** Reports one package's download on the loading screen: "<what> · 332 of 791 MB", checking, unpacking. */
|
||||
private fun progressFor(what: String, mb: Long) = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, p ->
|
||||
uiHandler.post {
|
||||
loading.percent = p
|
||||
loading.step = when {
|
||||
stage.startsWith("Downloading") -> if (p >= 0 && mb > 0) "downloading $what · ${p * mb / 100} of $mb MB" else "downloading $what"
|
||||
stage.startsWith("Verifying") -> "checking $what"
|
||||
else -> "unpacking $what"
|
||||
private fun progressFor(what: PackageLines, mb: Long) = object : com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener {
|
||||
override fun onProgress(stage: String, percent: Int) =
|
||||
onProgress(com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.OTHER, stage, percent)
|
||||
|
||||
override fun onProgress(step: com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
|
||||
uiHandler.post {
|
||||
loading.percent = percent
|
||||
when (step) {
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.DOWNLOADING -> loading.say(
|
||||
if (percent >= 0 && mb > 0) getString(what.downloadingMb, percent * mb / 100, mb) else getString(what.downloading),
|
||||
what.topic, readable = true,
|
||||
)
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.VERIFYING ->
|
||||
loading.say(getString(what.checking), what.topic, what.checkingReadable)
|
||||
else -> loading.say(getString(what.unpacking), what.topic, readable = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -878,29 +917,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Null when the runtime is in, else the loading screen's closing line. */
|
||||
private fun installRuntime(): String? {
|
||||
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
|
||||
?: return "Could not reach the runtime catalog. Check the connection and press Play again."
|
||||
?: return getString(R.string.session_runtime_catalog_unreachable)
|
||||
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
|
||||
progressFor("the Linux runtime", release.size / 1_000_000))
|
||||
return if (ok) null else "The Linux runtime did not install. Check the runtime in Setup and press Play again."
|
||||
progressFor(RUNTIME_LINES, release.size / 1_000_000))
|
||||
return if (ok) null else getString(R.string.session_runtime_install_failed)
|
||||
}
|
||||
|
||||
/** Null when the desktop package is in, else the loading screen's closing line. */
|
||||
private fun installDesktop(): String? {
|
||||
uiHandler.post { loading.percent = -1; loading.step = "downloading the desktop" }
|
||||
uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) }
|
||||
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" }
|
||||
?: return "Could not reach the desktop catalog. Check the connection and press Desktop again."
|
||||
?: return getString(R.string.session_desktop_catalog_unreachable)
|
||||
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry,
|
||||
progressFor("the desktop", entry.size / 1_000_000))
|
||||
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
|
||||
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
|
||||
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
|
||||
}
|
||||
|
||||
/** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
|
||||
private fun installProton(): String? {
|
||||
uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
|
||||
uiHandler.post { loading.percent = -1; loading.say(getString(PROTON_LINES.downloading), PROTON_LINES.topic, readable = true) }
|
||||
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
|
||||
?: return "catalog unreachable"
|
||||
return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
|
||||
return catalog.install(this, entry, progressFor(PROTON_LINES, entry.size / 1_000_000))
|
||||
}
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
@@ -1161,14 +1200,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
// It stays until the user leaves: a failure read against a timer is a failure not read.
|
||||
val what = when (SessionState.mode) {
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
|
||||
SessionService.MODE_DESKTOP -> "The desktop"
|
||||
else -> "Steam"
|
||||
val stopped = when (SessionState.mode) {
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program)
|
||||
?.let { getString(R.string.session_ended_named, it) } ?: getString(R.string.session_ended_program)
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
|
||||
else -> getString(R.string.session_ended_named, "Steam")
|
||||
}
|
||||
loading.showEnded(
|
||||
hint?.text ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
|
||||
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
|
||||
hint?.text ?: stopped,
|
||||
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
|
||||
)
|
||||
focusEndedScreen()
|
||||
}
|
||||
@@ -1218,8 +1258,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
|
||||
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
|
||||
} else if (enosys >= 8) {
|
||||
EndHint("The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
|
||||
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again.")
|
||||
EndHint(resources.getQuantityString(R.plurals.session_hint_enosys, enosys, enosys,
|
||||
getString(R.string.perf_title), getString(R.string.perf_seccomp), getString(R.string.perf_xalia)))
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
@@ -2013,6 +2053,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = false)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
@@ -2090,5 +2131,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Compositor scale modes (Container.FULLSCREEN_* values): 1 = fit with bars, centred. */
|
||||
private const val SCALE_FIT = 1
|
||||
private const val ALIGN_CENTER = 0
|
||||
private val RUNTIME_LINES = PackageLines(
|
||||
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
|
||||
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
|
||||
LoadingState.Topic.RUNTIME, checkingReadable = true,
|
||||
)
|
||||
private val DESKTOP_LINES = PackageLines(
|
||||
R.string.session_desktop_downloading, R.string.session_desktop_downloading_mb,
|
||||
R.string.session_desktop_checking, R.string.session_desktop_unpacking,
|
||||
LoadingState.Topic.DESKTOP, checkingReadable = false,
|
||||
)
|
||||
private val PROTON_LINES = PackageLines(
|
||||
R.string.session_proton_downloading, R.string.session_proton_downloading_mb,
|
||||
R.string.session_proton_checking, R.string.session_proton_unpacking,
|
||||
LoadingState.Topic.STEAM, checkingReadable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
onAllowInstalls = {
|
||||
askPermission = false
|
||||
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
|
||||
.onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
|
||||
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
|
||||
},
|
||||
onDismissPermission = { askPermission = false; pending = null },
|
||||
)
|
||||
@@ -77,7 +77,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
catalog = it
|
||||
follow = AppUpdates.follow(activity, it)
|
||||
error = null
|
||||
}.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}.onFailure { if (force) error = activity.getString(R.string.upd_couldnt_check, it.message ?: it.javaClass.simpleName) }
|
||||
}
|
||||
}, "app-updates").start()
|
||||
}
|
||||
@@ -90,20 +90,20 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
|
||||
private fun install(r: AppUpdates.Release) {
|
||||
if (stage != null) return
|
||||
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
|
||||
AppUpdates.installBlock(r)?.let { error = it; return }
|
||||
if (SessionState.running) { error = "Stop the running session first, then update"; return }
|
||||
val apk = r.apk ?: run { error = activity.getString(R.string.upd_no_download); return }
|
||||
AppUpdates.installBlock(r)?.let { error = it.message(activity); return }
|
||||
if (SessionState.running) { error = activity.getString(R.string.upd_stop_first); return }
|
||||
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
|
||||
error = null
|
||||
stage = "Downloading"
|
||||
stage = activity.getString(R.string.upd_stage_downloading)
|
||||
percent = 0
|
||||
Thread({
|
||||
val result = runCatching {
|
||||
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
|
||||
ui.post { stage = "Installing"; percent = -1 }
|
||||
ui.post { stage = activity.getString(R.string.upd_stage_installing); percent = -1 }
|
||||
SelfInstaller.install(activity, file)
|
||||
}
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: activity.getString(R.string.upd_failed) } }
|
||||
}, "app-update-install").start()
|
||||
}
|
||||
|
||||
@@ -114,14 +114,14 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
|
||||
stage = "Waiting for you to confirm"
|
||||
if (confirm == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
|
||||
stage = activity.getString(R.string.upd_stage_confirm)
|
||||
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> stage = null
|
||||
else -> {
|
||||
stage = null
|
||||
error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
|
||||
error = SelfInstaller.describeFailure(activity, status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Who may run code through the agent bridge. Reading state, starting and stopping sessions,
|
||||
* launching and quitting games and sending input only need the shell (the provider's DUMP
|
||||
* permission). Running commands in the guest, evaluating JavaScript in the Steam client, session
|
||||
* environment, binary overrides and preference writes reach inside the app's sandbox - the guest
|
||||
* holds the Steam login - which the shell of a stock device otherwise cannot. They require
|
||||
* Setup > Session > Debugging tools to be on, in every build. The bridge itself cannot turn it on.
|
||||
*/
|
||||
object AgentAccess {
|
||||
const val MAX_REQUEST_CHARS = 256 * 1024
|
||||
private const val PREFS = "agent"
|
||||
private const val KEY_COMMANDS = "commands"
|
||||
|
||||
/** The preferences file the bridge must never write: set-pref refuses it. */
|
||||
const val PROTECTED_PREFS = PREFS
|
||||
|
||||
fun commandsAllowed(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_COMMANDS, false)
|
||||
|
||||
/** The Setup toggle's own state. */
|
||||
fun commandsEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_COMMANDS, false)
|
||||
|
||||
fun setCommandsEnabled(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_COMMANDS, enabled).apply()
|
||||
if (!enabled) runCatching { AgentEnv.revoke(context) }
|
||||
.onFailure { android.util.Log.w("AgentAccess", "Could not finish clearing debugging experiments") }
|
||||
}
|
||||
|
||||
fun requireCommands(context: Context) {
|
||||
if (!commandsAllowed(context)) throw AgentException(
|
||||
"AGENT_COMMANDS_DISABLED",
|
||||
"Debugging tools are off: turn on Setup > Session > Debugging tools on the device",
|
||||
)
|
||||
}
|
||||
|
||||
fun requireStart(context: Context, request: org.json.JSONObject) {
|
||||
if (request.optString("mode") == "run") requireCommands(context)
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/** A refusal with a stable code, answered as {"ok": false, "error": {code, message}}. */
|
||||
class AgentException(val code: String, message: String) : RuntimeException(message)
|
||||
@@ -10,13 +10,14 @@ import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.util.Base64
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.frontend.GameLaunchLink
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
@@ -25,7 +26,14 @@ import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** A shell-only control surface, installed only by debug builds. */
|
||||
/**
|
||||
* The shell's control surface (docs/agent-control.md), in every build: the provider and its start
|
||||
* Activity need android.permission.DUMP, held by the shell and privileged or explicitly granted callers. Commands that
|
||||
* reach into the app's sandbox are further gated by [AgentAccess].
|
||||
*
|
||||
* A method's request is JSON, base64-encoded in the "request" extra; every answer is JSON in the
|
||||
* "json" key of the returned Bundle, {"ok": true, ...} or {"ok": false, "error": {code, message}}.
|
||||
*/
|
||||
class AgentBridgeProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
val appContext = context?.applicationContext ?: return false
|
||||
@@ -48,17 +56,53 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
}
|
||||
|
||||
val response = try {
|
||||
when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
val request = decode(extras?.getString(EXTRA_REQUEST))
|
||||
// Binder identity is the shell's; everything below acts as the app.
|
||||
val token = Binder.clearCallingIdentity()
|
||||
try {
|
||||
dispatch(context, method, request)
|
||||
} finally {
|
||||
Binder.restoreCallingIdentity(token)
|
||||
}
|
||||
} catch (e: AgentException) {
|
||||
error(e.code, e.message ?: e.code)
|
||||
} catch (e: Exception) {
|
||||
error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
return Bundle().apply { putString(RESULT_JSON, response.toString()) }
|
||||
val json = response.toString().let {
|
||||
if (it.length <= 384 * 1024) it else error("RESPONSE_TOO_LARGE", "Reduce the command output or select fewer fields").toString()
|
||||
}
|
||||
return Bundle().apply { putString(RESULT_JSON, json) }
|
||||
}
|
||||
|
||||
private fun dispatch(context: Context, method: String, request: JSONObject): JSONObject = when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
"launch" -> launch(context, request)
|
||||
"focus" -> guest(context, JSONObject().put("kind", "focus"), 15_000)
|
||||
"quit" -> guest(context, JSONObject().put("kind", "quit").put("appId", request.opt("appId"))
|
||||
.put("timeout", request.optDouble("timeout", 15.0)), timeoutMs(request, 15.0, 30_000))
|
||||
"input" -> AgentInput.handle(request)
|
||||
"ui" -> AgentUi.handle(request)
|
||||
"guest" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
guest(context, JSONObject(request.toString()).put("kind", "exec"), timeoutMs(request, 30.0, 10_000))
|
||||
}
|
||||
"cdp" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
guest(context, JSONObject(request.toString()).put("kind", "cdp"), timeoutMs(request, 15.0, 5_000))
|
||||
}
|
||||
"env" -> env(context, request)
|
||||
"override" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
|
||||
}
|
||||
"prefs" -> prefs(context, request)
|
||||
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
|
||||
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
}
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
@@ -91,13 +135,31 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
.put("status", SessionState.failureStatus ?: JSONObject.NULL)
|
||||
session.put("failure", failure)
|
||||
|
||||
// gamescope's focus as last published by the guest agent, without the window lists
|
||||
// (the focus command returns those, fresh).
|
||||
val focus = AgentGuest.focus(context)?.let { snap ->
|
||||
JSONObject()
|
||||
.put("focusedApp", snap.opt("focusedApp") ?: JSONObject.NULL)
|
||||
.put("focusedWindow", snap.opt("focusedWindow") ?: JSONObject.NULL)
|
||||
.put("focusableApps", snap.optJSONArray("focusableApps") ?: JSONArray())
|
||||
.put("baselayerAppIds", snap.optJSONArray("baselayerAppIds") ?: JSONArray())
|
||||
.put("seq", snap.optInt("seq"))
|
||||
.put("t", snap.optLong("t"))
|
||||
}
|
||||
session.put("focus", focus ?: JSONObject.NULL)
|
||||
|
||||
val hello = AgentGuest.hello(context)
|
||||
return JSONObject()
|
||||
.put("schema", 1)
|
||||
.put("schema", SCHEMA)
|
||||
.put("build", BuildConfig.BUILD_LABEL)
|
||||
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
|
||||
.put("runtime", JSONObject()
|
||||
.put("installed", LinuxRuntime.isInstalled(context))
|
||||
.put("version", runtimeVersion ?: JSONObject.NULL))
|
||||
.put("agent", JSONObject()
|
||||
.put("guest", hello != null)
|
||||
.put("guestFocus", hello?.optBoolean("focus") ?: false)
|
||||
.put("commands", AgentAccess.commandsAllowed(context)))
|
||||
.put("session", session)
|
||||
}
|
||||
|
||||
@@ -131,6 +193,67 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
return JSONObject().put("ok", true).put("command", "resume")
|
||||
}
|
||||
|
||||
private fun launch(context: Context, request: JSONObject): JSONObject {
|
||||
val appId = request.optString("appId")
|
||||
if (!GameLaunchLink.validId(appId)) throw AgentException("INVALID_APP_ID", "appId must be a decimal Steam app id")
|
||||
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) {
|
||||
throw AgentException("NO_STEAM_SESSION", "Games launch inside a running Steam session")
|
||||
}
|
||||
if (!SessionService.launchGame(context, appId)) throw AgentException("LAUNCH_REJECTED", "The session did not accept the launch")
|
||||
SessionEvents.record("agent.launch_requested", mapOf("appId" to appId))
|
||||
return JSONObject().put("ok", true).put("command", "launch").put("appId", appId)
|
||||
}
|
||||
|
||||
private fun env(context: Context, request: JSONObject): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
val result = when (val op = request.optString("op", "list")) {
|
||||
"list" -> AgentEnv.list(context)
|
||||
"set" -> {
|
||||
val lines = request.optJSONArray("lines") ?: JSONArray()
|
||||
AgentEnv.set(context, List(lines.length()) { lines.optString(it) }, request.optBoolean("persistent"))
|
||||
}
|
||||
"clear" -> AgentEnv.clear(context, request.optString("scope", "next"))
|
||||
else -> throw AgentException("INVALID_REQUEST", "env op '$op' is not list, set or clear")
|
||||
}
|
||||
return result.put("ok", true).put("command", "env")
|
||||
}
|
||||
|
||||
private fun prefs(context: Context, request: JSONObject): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
val file = request.optString("file")
|
||||
val result = when (val op = request.optString("op", "get")) {
|
||||
"get" -> AgentPrefs.get(context, file)
|
||||
"set" -> AgentPrefs.set(context, file, request.optString("key"), request.optString("type", "string"),
|
||||
if (request.isNull("value")) null else request.optString("value"))
|
||||
else -> throw AgentException("INVALID_REQUEST", "prefs op '$op' is not get or set")
|
||||
}
|
||||
return result.put("ok", true).put("command", "prefs")
|
||||
}
|
||||
|
||||
/** A guest agent request; its own answer already carries ok or error. */
|
||||
private fun guest(context: Context, request: JSONObject, timeoutMs: Long): JSONObject =
|
||||
AgentGuest.call(context, request, timeoutMs)
|
||||
|
||||
/** The guest's own timeout plus slack for the round trip. */
|
||||
private fun timeoutMs(request: JSONObject, defaultSeconds: Double, slackMs: Long): Long {
|
||||
val seconds = if (request.has("timeout")) request.opt("timeout")?.toString()?.toDoubleOrNull()
|
||||
?: throw AgentException("INVALID_REQUEST", "timeout must be a number") else defaultSeconds
|
||||
if (!seconds.isFinite() || seconds <= 0 || seconds > 600) {
|
||||
throw AgentException("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
|
||||
}
|
||||
return (seconds * 1000).toLong() + slackMs
|
||||
}
|
||||
|
||||
private fun decode(encoded: String?): JSONObject {
|
||||
if (encoded.isNullOrBlank()) return JSONObject()
|
||||
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) throw AgentException("INVALID_REQUEST", "The encoded request exceeds 256 KiB")
|
||||
return try {
|
||||
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
throw AgentException("INVALID_REQUEST", "The request extra is not base64 JSON")
|
||||
}
|
||||
}
|
||||
|
||||
private fun error(code: String, message: String) = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("error", JSONObject().put("code", code).put("message", message))
|
||||
@@ -143,6 +266,9 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
|
||||
companion object {
|
||||
const val RESULT_JSON = "json"
|
||||
const val EXTRA_REQUEST = "request"
|
||||
/** 2 added agent and session.focus; everything in 1 is unchanged. */
|
||||
const val SCHEMA = 2
|
||||
private val recoveryComplete = CountDownLatch(1)
|
||||
|
||||
fun awaitRecovery() {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
|
||||
/**
|
||||
* Experiment switches an agent sets without touching Download/droiddeck-env or rebuilding the apk:
|
||||
*
|
||||
* - environment lines (KEY=VALUE) for the session, applied after the user's own droiddeck-env, so
|
||||
* they win. "next" lines are taken by the next session that starts and dropped when it ends;
|
||||
* "persistent" ones stay until cleared.
|
||||
* - binary overrides: a file pushed to the app's external files (agent-inbox/<name>) is installed
|
||||
* as /opt/droiddeck-agent/bin/<name> in the rootfs and put first on the next session's PATH -
|
||||
* a gamescope build under test, say. Removed when that session ends.
|
||||
*
|
||||
* Kept in the app's own files (agent-env.json), never in a place the shell can write.
|
||||
*/
|
||||
object AgentEnv {
|
||||
private const val TAG = "AgentEnv"
|
||||
private const val FILE = "agent-env.json"
|
||||
private const val GUEST_BIN = "/opt/droiddeck-agent/bin"
|
||||
private const val BASE_PATH = "/usr/local/bin:/usr/bin:/bin"
|
||||
private val NAME = Regex("[A-Za-z0-9._-]{1,64}")
|
||||
private val KEY = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val lock = Any()
|
||||
|
||||
fun inbox(context: Context): File = File(context.getExternalFilesDir(null), "agent-inbox")
|
||||
|
||||
fun list(context: Context): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
load(context)
|
||||
}
|
||||
|
||||
/** Add [lines] for the next session ([persistent] = every session until cleared). */
|
||||
fun set(context: Context, lines: List<String>, persistent: Boolean): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (lines.any { line -> KEY.matchEntire(line.substringBefore('=', "")) == null || !line.contains('=') || line.any { it == '\n' || it == '\r' || it == '\u0000' } }) {
|
||||
throw AgentException("INVALID_ENV", "Environment entries must be single KEY=VALUE lines without NUL bytes")
|
||||
}
|
||||
val state = load(context)
|
||||
val key = if (persistent) "persistent" else "pending"
|
||||
val merged = merge(strings(state.optJSONArray(key)), lines)
|
||||
state.put(key, JSONArray(merged))
|
||||
save(context, state)
|
||||
state
|
||||
}
|
||||
|
||||
/** Drop lines: [scope] is "next", "persistent" or "all". */
|
||||
fun clear(context: Context, scope: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
val state = load(context)
|
||||
when (scope) {
|
||||
"next" -> state.put("pending", JSONArray())
|
||||
"persistent" -> state.put("persistent", JSONArray())
|
||||
"all" -> { state.put("pending", JSONArray()); state.put("persistent", JSONArray()); state.put("pendingOverrides", JSONArray()) }
|
||||
else -> throw AgentException("INVALID_SCOPE", "Scope must be next, persistent or all")
|
||||
}
|
||||
save(context, state)
|
||||
state
|
||||
}
|
||||
|
||||
/** Install agent-inbox/[name] as an override for the next session. */
|
||||
fun override(context: Context, name: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (NAME.matchEntire(name) == null || name in setOf(".", "..")) throw AgentException("INVALID_NAME", "Override names are letters, digits, '.', '_' and '-'")
|
||||
val source = File(inbox(context), name)
|
||||
if (Files.isSymbolicLink(source.toPath())) throw AgentException("INVALID_NAME", "The override must be a regular file")
|
||||
if (!source.isFile) throw AgentException("NO_SUCH_FILE", "Push the binary to ${source.path} first")
|
||||
val target = File(bin(context), name)
|
||||
target.parentFile?.mkdirs()
|
||||
val staged = File.createTempFile("override-", ".tmp", target.parentFile)
|
||||
try {
|
||||
Files.newInputStream(source.toPath(), READ, NOFOLLOW_LINKS).use { input -> staged.outputStream().use { input.copyTo(it) } }
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
|
||||
}
|
||||
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
|
||||
}
|
||||
source.delete()
|
||||
val state = load(context)
|
||||
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
|
||||
save(context, state)
|
||||
state.put("installed", "$GUEST_BIN/$name")
|
||||
}
|
||||
|
||||
/**
|
||||
* At session start: the lines this session gets, after the user's droiddeck-env. Pending lines
|
||||
* and overrides become this session's own, to be dropped by [endSession].
|
||||
*/
|
||||
fun beginSession(context: Context): List<String> = synchronized(lock) {
|
||||
if (!AgentAccess.commandsAllowed(context)) {
|
||||
revoke(context)
|
||||
return@synchronized emptyList()
|
||||
}
|
||||
val state = load(context)
|
||||
val active = strings(state.optJSONArray("pending"))
|
||||
val overrides = strings(state.optJSONArray("pendingOverrides"))
|
||||
state.put("active", JSONArray(active)).put("pending", JSONArray())
|
||||
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
|
||||
save(context, state)
|
||||
// Anything else there was left by a session that never reached endSession (the app died).
|
||||
bin(context).listFiles()
|
||||
?.filter { it.name !in overrides }?.forEach { it.delete() }
|
||||
val lines = strings(state.optJSONArray("persistent")) + active
|
||||
val withPath = if (overrides.isEmpty()) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
|
||||
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
|
||||
withPath
|
||||
}
|
||||
|
||||
fun revoke(context: Context) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) state.put(key, JSONArray())
|
||||
save(context, state)
|
||||
bin(context).listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/** At session end: this session's lines and override binaries go. */
|
||||
fun endSession(context: Context) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
val overrides = strings(state.optJSONArray("activeOverrides"))
|
||||
val pending = strings(state.optJSONArray("pendingOverrides"))
|
||||
val bin = bin(context)
|
||||
overrides.filter { it !in pending }.forEach { File(bin, it).delete() }
|
||||
state.put("active", JSONArray()).put("activeOverrides", JSONArray())
|
||||
save(context, state)
|
||||
}
|
||||
|
||||
/** A PATH= line among [lines] (the user's or an agent's) is extended rather than replaced. */
|
||||
private fun pathOf(lines: List<String>) =
|
||||
lines.lastOrNull { it.startsWith("PATH=") }?.substringAfter('=')?.takeIf { it.isNotBlank() } ?: BASE_PATH
|
||||
|
||||
private fun bin(context: Context): File {
|
||||
val root = LinuxRuntime.rootDir(context).canonicalFile.toPath()
|
||||
val directory = root.resolve(GUEST_BIN.removePrefix("/"))
|
||||
var path = directory
|
||||
while (path != root) {
|
||||
if (Files.isSymbolicLink(path)) throw AgentException("OVERRIDE_FAILED", "The override directory must not contain symbolic links")
|
||||
path = path.parent
|
||||
}
|
||||
return directory.toFile()
|
||||
}
|
||||
|
||||
private fun merge(existing: List<String>, added: List<String>): List<String> {
|
||||
val keys = added.map { it.substringBefore('=') }.toSet()
|
||||
return existing.filter { it.substringBefore('=') !in keys } + added
|
||||
}
|
||||
|
||||
private fun strings(array: JSONArray?): List<String> =
|
||||
if (array == null) emptyList() else List(array.length()) { array.optString(it) }.filter { it.isNotEmpty() }
|
||||
|
||||
private fun load(context: Context): JSONObject {
|
||||
val file = File(context.filesDir, FILE)
|
||||
val state = try { if (file.isFile) JSONObject(file.readText()) else JSONObject() } catch (e: Exception) { JSONObject() }
|
||||
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) {
|
||||
if (!state.has(key)) state.put(key, JSONArray())
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private fun save(context: Context, state: JSONObject) {
|
||||
val file = File(context.filesDir, FILE)
|
||||
val staged = File(context.filesDir, "$FILE.tmp")
|
||||
staged.writeText(state.toString())
|
||||
if (!staged.renameTo(file)) {
|
||||
staged.delete()
|
||||
throw AgentException("ENV_WRITE_FAILED", "Could not save the debugging environment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed writes to the app's SharedPreferences, for driving a settings matrix without the UI. */
|
||||
object AgentPrefs {
|
||||
fun set(context: Context, file: String, key: String, type: String, value: String?): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
validateFile(file)
|
||||
if (file == AgentAccess.PROTECTED_PREFS) throw AgentException("PROTECTED_PREFS", "The agent preferences are set on the device only")
|
||||
if (file.isBlank() || key.isBlank()) throw AgentException("INVALID_PREF", "file and key are required")
|
||||
val prefs = context.getSharedPreferences(file, Context.MODE_PRIVATE)
|
||||
val previous = prefs.all[key]
|
||||
val editor = prefs.edit()
|
||||
try {
|
||||
when (type) {
|
||||
"remove" -> editor.remove(key)
|
||||
"bool" -> editor.putBoolean(key, value?.toBooleanStrictOrNull() ?: throw NumberFormatException("not true/false"))
|
||||
"int" -> editor.putInt(key, value!!.toInt())
|
||||
"long" -> editor.putLong(key, value!!.toLong())
|
||||
"float" -> editor.putFloat(key, value!!.toFloat())
|
||||
"string" -> editor.putString(key, value ?: "")
|
||||
else -> throw AgentException("INVALID_PREF", "type must be bool, int, long, float, string or remove")
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
if (e is AgentException) throw e
|
||||
throw AgentException("INVALID_PREF", "The value is not a $type")
|
||||
}
|
||||
if (!editor.commit()) throw AgentException("PREF_WRITE_FAILED", "Could not write $file/$key")
|
||||
return JSONObject().put("file", file).put("key", key).put("type", type)
|
||||
.put("previous", previous ?: JSONObject.NULL).put("value", prefs.all[key] ?: JSONObject.NULL)
|
||||
}
|
||||
|
||||
fun get(context: Context, file: String): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
validateFile(file)
|
||||
val all = context.getSharedPreferences(file, Context.MODE_PRIVATE).all
|
||||
return JSONObject().put("file", file).put("values", JSONObject(all.mapValues { it.value ?: JSONObject.NULL }))
|
||||
}
|
||||
|
||||
private fun validateFile(file: String) {
|
||||
if (!Regex("[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}").matches(file)) {
|
||||
throw AgentException("INVALID_PREF", "Preference files must be simple names")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.FileObserver
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The app's side of the guest agent (tools/linuxfs/overlay/usr/local/bin/droiddeck-agent): requests
|
||||
* go in as files under the session root's agent directory and answers come back the same way, so
|
||||
* whatever runs does so inside the live session with its own environment.
|
||||
*/
|
||||
object AgentGuest {
|
||||
private const val TAG = "AgentGuest"
|
||||
private const val POLL_MS = 50L
|
||||
private val lock = Any()
|
||||
private var observer: FileObserver? = null
|
||||
private var lastFocusKey: String? = null
|
||||
|
||||
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "agent")
|
||||
|
||||
/**
|
||||
* Before each session: nothing of the last one's daemon, answers or focus may be read as
|
||||
* current. Then watch for the new daemon's hello and focus snapshots, which go into the
|
||||
* session's events.jsonl.
|
||||
*/
|
||||
fun reset(context: Context) {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
lastFocusKey = null
|
||||
val dir = dir(context)
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
@Suppress("DEPRECATION")
|
||||
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
when (path) {
|
||||
"hello.json" -> readJson(File(dir, path))?.let {
|
||||
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
|
||||
}
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
}
|
||||
}
|
||||
}.also { it.startWatching() }
|
||||
}
|
||||
}
|
||||
|
||||
/** At the end of a session: no more events for a folder that is being closed. */
|
||||
fun stop() {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordFocus(snapshot: JSONObject) {
|
||||
val fields = linkedMapOf<String, Any?>(
|
||||
"focusedApp" to snapshot.opt("focusedApp"),
|
||||
"focusableApps" to snapshot.optJSONArray("focusableApps")?.toString(),
|
||||
"baselayerAppIds" to snapshot.optJSONArray("baselayerAppIds")?.toString(),
|
||||
)
|
||||
val key = fields.values.joinToString("|")
|
||||
synchronized(lock) {
|
||||
if (key == lastFocusKey) return
|
||||
lastFocusKey = key
|
||||
}
|
||||
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
/** The daemon's hello, when it is running in the current session. */
|
||||
fun hello(context: Context): JSONObject? {
|
||||
if (!SessionState.running) return null
|
||||
val hello = readJson(File(dir(context), "hello.json")) ?: return null
|
||||
val pid = hello.optInt("pid", -1)
|
||||
return hello.takeIf { pid > 1 && File("/proc/$pid").exists() }
|
||||
}
|
||||
|
||||
/** The newest focus snapshot the daemon published, when a session is running. */
|
||||
fun focus(context: Context): JSONObject? =
|
||||
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
|
||||
|
||||
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
|
||||
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
|
||||
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
||||
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
|
||||
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
|
||||
val dir = dir(context)
|
||||
val requests = File(dir, "req").apply { mkdirs() }
|
||||
val answers = File(dir, "resp").apply { mkdirs() }
|
||||
val id = UUID.randomUUID().toString()
|
||||
val staged = File(requests, "$id.json.tmp")
|
||||
val published = File(requests, "$id.json")
|
||||
val answer = File(answers, "$id.json")
|
||||
staged.writeText(request.toString())
|
||||
if (!staged.renameTo(published)) {
|
||||
staged.delete()
|
||||
throw AgentException("AGENT_UNAVAILABLE", "Could not hand the request to the guest agent")
|
||||
}
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (answer.isFile) {
|
||||
if (answer.length() > 1024 * 1024) {
|
||||
answer.delete()
|
||||
throw AgentException("RESPONSE_TOO_LARGE", "The guest response exceeds 1 MiB")
|
||||
}
|
||||
val result = readJson(answer)
|
||||
answer.delete()
|
||||
return result ?: throw AgentException("INVALID_AGENT_RESPONSE", "The guest agent's answer was not JSON")
|
||||
}
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
published.delete()
|
||||
Log.w(TAG, "no answer to ${request.optString("kind")} in ${timeoutMs}ms")
|
||||
throw AgentException("TIMEOUT", "The guest agent did not answer within ${timeoutMs / 1000}s")
|
||||
}
|
||||
|
||||
private fun readJson(file: File): JSONObject? = try {
|
||||
if (file.isFile && file.length() <= 1024 * 1024) JSONObject(file.readText()) else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Input into the running session the way a player gives it: keys and touches through the
|
||||
* compositor (what the session screen sends), and the virtual pad through the same PadBridge the
|
||||
* on-screen controls drive, so games that ignore touch still see a controller.
|
||||
*/
|
||||
object AgentInput {
|
||||
/** Out of the way of real fingers, which Android numbers from 0. */
|
||||
private const val TOUCH_ID = 9
|
||||
private const val TOUCH_SCALE_X = 1919f
|
||||
private const val TOUCH_SCALE_Y = 1079f
|
||||
|
||||
fun handle(request: JSONObject): JSONObject {
|
||||
if (!SessionState.running || !SessionState.firstFrameSeen) {
|
||||
throw AgentException("SESSION_NOT_READY", "Input needs a running session that has drawn its first frame")
|
||||
}
|
||||
return when (val type = request.optString("type")) {
|
||||
"key" -> key(request)
|
||||
"text" -> text(request)
|
||||
"tap" -> tap(request)
|
||||
"swipe" -> swipe(request)
|
||||
"pad" -> pad(request)
|
||||
"stick" -> stick(request)
|
||||
"trigger" -> trigger(request)
|
||||
else -> throw AgentException("INVALID_INPUT", "Unknown input type '$type'")
|
||||
}.put("ok", true).put("command", "input").put("type", request.optString("type"))
|
||||
}
|
||||
|
||||
private fun key(request: JSONObject): JSONObject {
|
||||
val name = request.optString("key")
|
||||
val code = KEYS[name.uppercase()] ?: name.toIntOrNull()
|
||||
?: throw AgentException("INVALID_INPUT", "Unknown key '$name' (a name such as ENTER or an evdev code)")
|
||||
val modifiers = request.optJSONArray("modifiers")?.let { array ->
|
||||
List(array.length()) { i ->
|
||||
KEYS[array.optString(i).uppercase()] ?: throw AgentException("INVALID_INPUT", "Unknown modifier '${array.optString(i)}'")
|
||||
}
|
||||
}.orEmpty()
|
||||
when (val action = request.optString("action", "tap")) {
|
||||
"down" -> WaylandCompositor.nativeSendKey(code, 1)
|
||||
"up" -> WaylandCompositor.nativeSendKey(code, 0)
|
||||
"tap" -> {
|
||||
modifiers.forEach { WaylandCompositor.nativeSendKey(it, 1) }
|
||||
WaylandCompositor.nativeSendKey(code, 1)
|
||||
hold(request, 40)
|
||||
WaylandCompositor.nativeSendKey(code, 0)
|
||||
modifiers.asReversed().forEach { WaylandCompositor.nativeSendKey(it, 0) }
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "Key action '$action' is not tap, down or up")
|
||||
}
|
||||
return JSONObject().put("code", code)
|
||||
}
|
||||
|
||||
/** Typed as key presses (US layout), which reaches X programs that have no text-input support. */
|
||||
private fun text(request: JSONObject): JSONObject {
|
||||
val text = request.optString("text")
|
||||
if (request.optBoolean("commit")) {
|
||||
WaylandCompositor.textInputCommit(text)
|
||||
return JSONObject().put("length", text.length).put("via", "text-input")
|
||||
}
|
||||
val missing = text.filter { TYPED[it] == null }
|
||||
if (missing.isNotEmpty()) throw AgentException("INVALID_INPUT", "Cannot type '${missing.toSet().joinToString("")}' as keys; use commit")
|
||||
val delay = request.optLong("delayMs", 15)
|
||||
for (ch in text) {
|
||||
val (code, shift) = TYPED.getValue(ch)
|
||||
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 1)
|
||||
WaylandCompositor.nativeSendKey(code, 1)
|
||||
WaylandCompositor.nativeSendKey(code, 0)
|
||||
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 0)
|
||||
Thread.sleep(delay)
|
||||
}
|
||||
return JSONObject().put("length", text.length).put("via", "keys")
|
||||
}
|
||||
|
||||
private fun tap(request: JSONObject): JSONObject {
|
||||
val (x, y) = point(request, "x", "y")
|
||||
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x, y)
|
||||
hold(request, 60)
|
||||
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x, y)
|
||||
return JSONObject().put("touch", org.json.JSONArray().put(x).put(y))
|
||||
}
|
||||
|
||||
private fun swipe(request: JSONObject): JSONObject {
|
||||
val (x1, y1) = point(request, "x", "y")
|
||||
val (x2, y2) = point(request, "x2", "y2")
|
||||
val duration = request.optLong("durationMs", 300).coerceIn(16, 10_000)
|
||||
val steps = (duration / 16).toInt().coerceAtLeast(2)
|
||||
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x1, y1)
|
||||
for (i in 1..steps) {
|
||||
Thread.sleep(duration / steps)
|
||||
WaylandCompositor.nativeSendTouch(1, TOUCH_ID, x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
|
||||
}
|
||||
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x2, y2)
|
||||
return JSONObject().put("from", org.json.JSONArray().put(x1).put(y1)).put("to", org.json.JSONArray().put(x2).put(y2))
|
||||
}
|
||||
|
||||
/**
|
||||
* A point in the compositor's touch space (0..1919 x 0..1079 whatever the session's size), from
|
||||
* the session's output pixels (space "output", the default: what the guest draws at) or 0..1
|
||||
* fractions (space "fraction").
|
||||
*/
|
||||
private fun point(request: JSONObject, xKey: String, yKey: String): Pair<Int, Int> {
|
||||
if (!request.has(xKey) || !request.has(yKey)) throw AgentException("INVALID_INPUT", "$xKey and $yKey are required")
|
||||
val x = request.getDouble(xKey)
|
||||
val y = request.getDouble(yKey)
|
||||
val (fx, fy) = when (val space = request.optString("space", "output")) {
|
||||
"fraction" -> x to y
|
||||
"output" -> {
|
||||
val (w, h) = SessionState.outputSize
|
||||
x / (w - 1).coerceAtLeast(1) to y / (h - 1).coerceAtLeast(1)
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "space '$space' is not output or fraction")
|
||||
}
|
||||
return (fx.coerceIn(0.0, 1.0) * TOUCH_SCALE_X).toInt() to (fy.coerceIn(0.0, 1.0) * TOUCH_SCALE_Y).toInt()
|
||||
}
|
||||
|
||||
private fun pad(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val name = request.optString("button").lowercase()
|
||||
if (name == "qam") {
|
||||
bridge.triggerQam()
|
||||
return JSONObject().put("button", name)
|
||||
}
|
||||
val button = BUTTONS[name]
|
||||
if (button == null && name !in DPAD) throw AgentException("INVALID_INPUT", "Unknown pad button '$name'")
|
||||
val apply = { s: PadState, down: Boolean ->
|
||||
when (name) {
|
||||
"up" -> s.up = down
|
||||
"down" -> s.down = down
|
||||
"left" -> s.left = down
|
||||
"right" -> s.right = down
|
||||
else -> s.press(button!!, down)
|
||||
}
|
||||
}
|
||||
when (val action = request.optString("action", "tap")) {
|
||||
"down" -> bridge.applyTouch { apply(it, true) }
|
||||
"up" -> bridge.applyTouch { apply(it, false) }
|
||||
"tap" -> {
|
||||
bridge.applyTouch { apply(it, true) }
|
||||
hold(request, 120)
|
||||
bridge.applyTouch { apply(it, false) }
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "Pad action '$action' is not tap, down or up")
|
||||
}
|
||||
return JSONObject().put("button", name)
|
||||
}
|
||||
|
||||
private fun stick(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val right = request.optString("stick", "left") == "right"
|
||||
val x = request.optDouble("x", 0.0).toFloat().coerceIn(-1f, 1f)
|
||||
val y = request.optDouble("y", 0.0).toFloat().coerceIn(-1f, 1f)
|
||||
bridge.applyTouch { if (right) { it.rightX = x; it.rightY = y } else { it.leftX = x; it.leftY = y } }
|
||||
hold(request, 300)
|
||||
bridge.applyTouch { if (right) { it.rightX = 0f; it.rightY = 0f } else { it.leftX = 0f; it.leftY = 0f } }
|
||||
return JSONObject().put("stick", if (right) "right" else "left")
|
||||
}
|
||||
|
||||
private fun trigger(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val right = request.optString("trigger", "right") == "right"
|
||||
val value = request.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)
|
||||
bridge.applyTouch { if (right) it.rightTrigger = value else it.leftTrigger = value }
|
||||
hold(request, 200)
|
||||
bridge.applyTouch { if (right) it.rightTrigger = 0f else it.leftTrigger = 0f }
|
||||
return JSONObject().put("trigger", if (right) "right" else "left")
|
||||
}
|
||||
|
||||
private fun hold(request: JSONObject, defaultMs: Long) =
|
||||
Thread.sleep(request.optLong("holdMs", defaultMs).coerceIn(0, 10_000))
|
||||
|
||||
private val DPAD = setOf("up", "down", "left", "right")
|
||||
|
||||
private val BUTTONS = mapOf(
|
||||
"a" to PadState.A, "b" to PadState.B, "x" to PadState.X, "y" to PadState.Y,
|
||||
"lb" to PadState.LB, "rb" to PadState.RB, "select" to PadState.SELECT, "back" to PadState.SELECT,
|
||||
"start" to PadState.START, "menu" to PadState.START, "l3" to PadState.L3, "r3" to PadState.R3,
|
||||
"guide" to PadState.GUIDE, "steam" to PadState.GUIDE,
|
||||
)
|
||||
|
||||
/** Linux evdev codes (input-event-codes.h) by name. */
|
||||
private val KEYS: Map<String, Int> = buildMap {
|
||||
"1234567890".forEachIndexed { i, c -> put(c.toString(), 2 + i) }
|
||||
"QWERTYUIOP".forEachIndexed { i, c -> put(c.toString(), 16 + i) }
|
||||
"ASDFGHJKL".forEachIndexed { i, c -> put(c.toString(), 30 + i) }
|
||||
"ZXCVBNM".forEachIndexed { i, c -> put(c.toString(), 44 + i) }
|
||||
(1..10).forEach { put("F$it", 58 + it) }
|
||||
put("F11", 87); put("F12", 88)
|
||||
putAll(mapOf(
|
||||
"ESC" to 1, "MINUS" to 12, "EQUAL" to 13, "BACKSPACE" to 14, "TAB" to 15, "LEFTBRACE" to 26,
|
||||
"RIGHTBRACE" to 27, "ENTER" to 28, "LEFTCTRL" to 29, "SEMICOLON" to 39, "APOSTROPHE" to 40,
|
||||
"GRAVE" to 41, "LEFTSHIFT" to 42, "BACKSLASH" to 43, "COMMA" to 51, "DOT" to 52, "SLASH" to 53,
|
||||
"RIGHTSHIFT" to 54, "LEFTALT" to 56, "SPACE" to 57, "CAPSLOCK" to 58, "RIGHTCTRL" to 97,
|
||||
"RIGHTALT" to 100, "HOME" to 102, "UP" to 103, "PAGEUP" to 104, "LEFT" to 105, "RIGHT" to 106,
|
||||
"END" to 107, "DOWN" to 108, "PAGEDOWN" to 109, "INSERT" to 110, "DELETE" to 111, "LEFTMETA" to 125,
|
||||
"CTRL" to 29, "SHIFT" to 42, "ALT" to 56, "META" to 125, "SUPER" to 125, "RETURN" to 28, "ESCAPE" to 1,
|
||||
))
|
||||
}
|
||||
|
||||
/** US-layout characters as (evdev code, shift). */
|
||||
private val TYPED: Map<Char, Pair<Int, Boolean>> = buildMap {
|
||||
for (c in 'a'..'z') put(c, KEYS.getValue(c.uppercase()) to false)
|
||||
for (c in 'A'..'Z') put(c, KEYS.getValue(c.toString()) to true)
|
||||
for (c in '0'..'9') put(c, KEYS.getValue(c.toString()) to false)
|
||||
"!@#$%^&*()".forEachIndexed { i, c -> put(c, KEYS.getValue(((i + 1) % 10).toString()) to true) }
|
||||
putAll(mapOf(
|
||||
' ' to (57 to false), '\n' to (28 to false), '\t' to (15 to false),
|
||||
'-' to (12 to false), '_' to (12 to true), '=' to (13 to false), '+' to (13 to true),
|
||||
'[' to (26 to false), '{' to (26 to true), ']' to (27 to false), '}' to (27 to true),
|
||||
';' to (39 to false), ':' to (39 to true), '\'' to (40 to false), '"' to (40 to true),
|
||||
'`' to (41 to false), '~' to (41 to true), '\\' to (43 to false), '|' to (43 to true),
|
||||
',' to (51 to false), '<' to (51 to true), '.' to (52 to false), '>' to (52 to true),
|
||||
'/' to (53 to false), '?' to (53 to true),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ class AgentStartActivity : Activity() {
|
||||
Log.e(TAG, "missing agent start request")
|
||||
return null
|
||||
}
|
||||
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) {
|
||||
Log.w(TAG, "agent start request is too large")
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
@@ -47,6 +51,13 @@ class AgentStartActivity : Activity() {
|
||||
}
|
||||
|
||||
private fun startSession(request: JSONObject) {
|
||||
try {
|
||||
AgentAccess.requireStart(this, request)
|
||||
} catch (e: AgentException) {
|
||||
Log.w(TAG, e.code)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val requestedMode = request.optString("mode")
|
||||
val sessionMode = when (requestedMode) {
|
||||
"steam" -> SessionService.MODE_STEAM
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inspector.WindowInspector
|
||||
import androidx.compose.ui.platform.ViewRootForTest
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsConfiguration
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The app's own UI as an agent sees it: every Compose node in every window of the process (popups
|
||||
* and menus included), read from Compose's semantics rather than through UI Automator, which on a
|
||||
* dual-screen device dumps whichever window has focus - often not this app's. Nodes carry their
|
||||
* test tag (ui/TestTags.kt), text, content description, role, state and on-screen bounds; click
|
||||
* and set-text act on a node by tag or text, so a test needs no coordinates.
|
||||
*/
|
||||
object AgentUi {
|
||||
private const val TIMEOUT_MS = 5_000L
|
||||
/** Setup's Debugging tools toggle: the bridge never acts on its own gate (AgentAccess). */
|
||||
private const val GATE_TAG = "setting-agent-commands"
|
||||
|
||||
fun handle(request: JSONObject): JSONObject = when (val op = request.optString("op", "dump")) {
|
||||
"dump" -> onMain { dump(request.optBoolean("all")) }
|
||||
"click" -> onMain { act(request) { node -> perform(node, SemanticsActions.OnClick) } }
|
||||
"set-text" -> onMain {
|
||||
val text = request.optString("text")
|
||||
act(request) { node ->
|
||||
node.config.getOrNull(SemanticsActions.SetText)?.action?.invoke(AnnotatedString(text))
|
||||
?: throw AgentException("NOT_EDITABLE", "That node takes no text")
|
||||
}
|
||||
}
|
||||
else -> throw AgentException("INVALID_REQUEST", "ui op '$op' is not dump, click or set-text")
|
||||
}.put("ok", true).put("command", "ui")
|
||||
|
||||
private fun roots(): List<Pair<View, ViewRootForTest>> {
|
||||
if (Build.VERSION.SDK_INT < 29) throw AgentException("UNSUPPORTED", "The UI dump needs Android 10 or later")
|
||||
val found = mutableListOf<Pair<View, ViewRootForTest>>()
|
||||
fun walk(view: View) {
|
||||
if (view is ViewRootForTest) found += view to view
|
||||
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
|
||||
}
|
||||
WindowInspector.getGlobalWindowViews().filter { it.isAttachedToWindow && it.isShown }.forEach(::walk)
|
||||
return found
|
||||
}
|
||||
|
||||
private fun dump(all: Boolean): JSONObject {
|
||||
val windows = JSONArray()
|
||||
for ((view, root) in roots()) {
|
||||
val nodes = JSONArray()
|
||||
val location = IntArray(2).also { view.getLocationOnScreen(it) }
|
||||
fun visit(node: SemanticsNode) {
|
||||
val json = describe(node, location)
|
||||
if (all || json.has("tag") || json.has("text") || json.has("description") || json.optBoolean("clickable")) nodes.put(json)
|
||||
node.children.forEach(::visit)
|
||||
}
|
||||
visit(root.semanticsOwner.rootSemanticsNode)
|
||||
windows.put(JSONObject()
|
||||
.put("display", view.display?.displayId ?: JSONObject.NULL)
|
||||
.put("window", view.rootView.javaClass.simpleName)
|
||||
.put("nodes", nodes))
|
||||
}
|
||||
return JSONObject().put("windows", windows)
|
||||
}
|
||||
|
||||
private fun describe(node: SemanticsNode, origin: IntArray): JSONObject {
|
||||
val config = node.config
|
||||
val json = JSONObject().put("id", node.id)
|
||||
config.getOrNull(SemanticsProperties.TestTag)?.let { json.put("tag", it) }
|
||||
val text = textFields(config)
|
||||
text.keys().forEach { json.put(it, text.get(it)) }
|
||||
config.getOrNull(SemanticsProperties.Role)?.let { json.put("role", it.toString()) }
|
||||
config.getOrNull(SemanticsProperties.ToggleableState)?.let { json.put("toggle", it.name) }
|
||||
config.getOrNull(SemanticsProperties.Selected)?.let { json.put("selected", it) }
|
||||
config.getOrNull(SemanticsProperties.Focused)?.let { json.put("focused", it) }
|
||||
if (config.contains(SemanticsProperties.Disabled)) json.put("disabled", true)
|
||||
if (config.contains(SemanticsActions.OnClick)) json.put("clickable", true)
|
||||
if (config.contains(SemanticsActions.SetText)) json.put("editable", true)
|
||||
val b = node.boundsInWindow
|
||||
json.put("bounds", JSONArray()
|
||||
.put(origin[0] + b.left.toInt()).put(origin[1] + b.top.toInt())
|
||||
.put(origin[0] + b.right.toInt()).put(origin[1] + b.bottom.toInt()))
|
||||
return json
|
||||
}
|
||||
|
||||
internal fun textFields(config: SemanticsConfiguration): JSONObject {
|
||||
val password = config.contains(SemanticsProperties.Password)
|
||||
val json = JSONObject()
|
||||
for ((key, value) in listOf(
|
||||
"text" to config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text },
|
||||
"editableText" to config.getOrNull(SemanticsProperties.EditableText)?.text,
|
||||
"description" to config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" "),
|
||||
"state" to config.getOrNull(SemanticsProperties.StateDescription),
|
||||
)) value?.let { json.put(key, if (password) "<redacted>" else it) }
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* The node a request names by "tag" (exact) or "match" (text or description containing it,
|
||||
* ignoring case); "index" picks among several, in tree order.
|
||||
*/
|
||||
private fun act(request: JSONObject, action: (SemanticsNode) -> Unit): JSONObject {
|
||||
val tag = request.optString("tag").takeIf { it.isNotEmpty() }
|
||||
val text = if (tag == null) request.optString("match").takeIf { it.isNotEmpty() } else null
|
||||
if (tag == null && text == null) throw AgentException("INVALID_REQUEST", "Name the node by tag or match")
|
||||
val matches = mutableListOf<Pair<SemanticsNode, IntArray>>()
|
||||
for ((view, root) in roots()) {
|
||||
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
|
||||
fun visit(node: SemanticsNode) {
|
||||
val config = node.config
|
||||
val hit = if (tag != null) config.getOrNull(SemanticsProperties.TestTag) == tag
|
||||
else (config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text }
|
||||
?: config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" ") ?: "")
|
||||
.contains(text!!, ignoreCase = true)
|
||||
if (hit) matches += node to origin
|
||||
node.children.forEach(::visit)
|
||||
}
|
||||
visit(root.semanticsOwner.rootSemanticsNode)
|
||||
}
|
||||
val index = request.optInt("index", 0)
|
||||
val (node, origin) = matches.getOrNull(index)
|
||||
?: throw AgentException("NO_SUCH_NODE", "No node with ${if (tag != null) "tag '$tag'" else "text '$text'"} (${matches.size} found)")
|
||||
if (generateSequence(node) { it.parent }.any { it.config.getOrNull(SemanticsProperties.TestTag) == GATE_TAG }) {
|
||||
throw AgentException("PROTECTED_CONTROL", "Debugging tools are switched on the device only")
|
||||
}
|
||||
action(node)
|
||||
return JSONObject().put("matches", matches.size).put("node", describe(node, origin))
|
||||
}
|
||||
|
||||
private fun perform(node: SemanticsNode, key: androidx.compose.ui.semantics.SemanticsPropertyKey<androidx.compose.ui.semantics.AccessibilityAction<() -> Boolean>>) {
|
||||
val action = node.config.getOrNull(key)?.action ?: throw AgentException("NOT_CLICKABLE", "That node has no click action")
|
||||
action()
|
||||
}
|
||||
|
||||
private fun <T> onMain(block: () -> T): T {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) return block()
|
||||
val result = AtomicReference<Result<T>>()
|
||||
val done = CountDownLatch(1)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
result.set(runCatching(block))
|
||||
done.countDown()
|
||||
}
|
||||
if (!done.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) throw AgentException("TIMEOUT", "The UI thread did not answer")
|
||||
return result.get().getOrThrow()
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
} else {
|
||||
// The classic sink: the adaptive AAudio module 0.1.5 shipped, unchanged.
|
||||
config.add("load-module module-aaudio-classic-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("load-module module-aaudio-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink AAudioSink");
|
||||
}
|
||||
if (micFifoPath != null && !micFifoPath.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The language the app shows itself in.
|
||||
*
|
||||
* Nothing chosen (the default) means the system's: Android's own language, or the per-app language
|
||||
* Android 13+ lets a user set for DroidDeck in its settings, exactly as Android resolves it. Picking
|
||||
* one in Setup overrides that until the user picks "System default" again. Each activity and service
|
||||
* applies the choice to its own context ([wrap], [applyTo]), so it works on every Android version
|
||||
* the app runs on rather than only where the platform's per-app language API exists.
|
||||
*
|
||||
* Steam follows the same choice: [SteamLanguage] turns [effective] into the client's language.
|
||||
*/
|
||||
object AppLanguage {
|
||||
/** The stored value meaning "follow the system". */
|
||||
const val SYSTEM = ""
|
||||
|
||||
/** The languages the app ships, as BCP 47 tags, in the order the picker lists them. */
|
||||
val supported: List<String> = listOf("en", "es", "fr", "ru", "ja", "ko", "zh-CN", "zh-TW", "zh-HK")
|
||||
|
||||
private const val PREFS = "language"
|
||||
private const val KEY = "app_language"
|
||||
|
||||
/** The locale Android gave the process before any choice here was applied. */
|
||||
@Volatile private var systemLocale: Locale? = null
|
||||
|
||||
/** The stored choice: one of [supported], or [SYSTEM]. */
|
||||
fun chosen(context: Context): String =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, SYSTEM)
|
||||
?.takeIf { it in supported } ?: SYSTEM
|
||||
|
||||
fun setChosen(context: Context, tag: String) {
|
||||
val value = if (tag in supported) tag else SYSTEM
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, value).commit()
|
||||
val locale = value.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
|
||||
Locale.setDefault(locale)
|
||||
// Code that reads strings through the application context sees the new language too.
|
||||
val app = context.applicationContext
|
||||
@Suppress("DEPRECATION")
|
||||
app.resources.updateConfiguration(
|
||||
Configuration(app.resources.configuration).apply { setLocales(LocaleList(locale)) },
|
||||
app.resources.displayMetrics,
|
||||
)
|
||||
}
|
||||
|
||||
/** The locale Android chose for the app, ignoring the choice made here. */
|
||||
fun system(context: Context): Locale =
|
||||
systemLocale ?: context.resources.configuration.locales[0] ?: Locale.getDefault()
|
||||
|
||||
/** The locale the app is shown in: the chosen language, or else the system's. */
|
||||
fun effective(context: Context): Locale =
|
||||
chosen(context).takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
|
||||
|
||||
/** Remembers the system's locale; call with the untouched base context, before [wrap]. */
|
||||
fun noteSystem(base: Context) {
|
||||
if (systemLocale == null) systemLocale = base.resources.configuration.locales[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* [base], showing the chosen language: for an Application's or a Service's attachBaseContext.
|
||||
* Only the locale is overridden, so screen size, density and night mode keep following the
|
||||
* device as they change.
|
||||
*/
|
||||
fun wrap(base: Context): Context {
|
||||
noteSystem(base)
|
||||
val tag = chosen(base).ifEmpty { return base }
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
Locale.setDefault(locale)
|
||||
return base.createConfigurationContext(override(locale))
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the chosen language to an activity; call from its attachBaseContext, after super.
|
||||
* applyOverrideConfiguration keeps the locale through rotation and window resizes, which an
|
||||
* activity here handles itself rather than being recreated.
|
||||
*/
|
||||
fun applyTo(activity: Activity, base: Context) {
|
||||
noteSystem(base)
|
||||
val tag = chosen(base).ifEmpty { return }
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
Locale.setDefault(locale)
|
||||
activity.applyOverrideConfiguration(override(locale))
|
||||
}
|
||||
|
||||
/** Follows a change of the system's language while the process lives (Application.onConfigurationChanged). */
|
||||
fun systemChanged(config: Configuration) {
|
||||
config.locales[0]?.let { systemLocale = it }
|
||||
}
|
||||
|
||||
// A new Configuration's fontScale is 1, which as an override would undo the user's font size;
|
||||
// 0 leaves it undefined, so only the locale is changed.
|
||||
private fun override(locale: Locale) = Configuration().apply { fontScale = 0f; setLocales(LocaleList(locale)) }
|
||||
|
||||
/** Which of the app's languages [locale] reads as, or null when the app does not ship it. */
|
||||
fun supportedTag(locale: Locale): String? = when (locale.language) {
|
||||
"en", "es", "fr", "ru", "ja", "ko" -> locale.language
|
||||
"zh" -> when {
|
||||
locale.script == "Hans" -> "zh-CN"
|
||||
locale.country == "HK" || locale.country == "MO" -> "zh-HK"
|
||||
locale.script == "Hant" || locale.country == "TW" -> "zh-TW"
|
||||
else -> "zh-CN"
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** [locale]'s language named in itself, without its region unless the app tells them apart. */
|
||||
fun displayName(locale: Locale): String =
|
||||
supportedTag(locale)?.let(::nativeName) ?: locale.getDisplayLanguage(locale).replaceFirstChar { it.titlecase(locale) }
|
||||
|
||||
/** A language's name in that language, for the picker: what a reader of it looks for. */
|
||||
fun nativeName(tag: String): String = when (tag) {
|
||||
"en" -> "English"
|
||||
"es" -> "Español"
|
||||
"fr" -> "Français"
|
||||
"ru" -> "Русский"
|
||||
"ja" -> "日本語"
|
||||
"ko" -> "한국어"
|
||||
"zh-CN" -> "简体中文"
|
||||
"zh-TW" -> "繁體中文(台灣)"
|
||||
"zh-HK" -> "繁體中文(香港)"
|
||||
else -> Locale.forLanguageTag(tag).let { it.getDisplayName(it) }
|
||||
}
|
||||
}
|
||||
@@ -181,8 +181,14 @@ object DeviceReport {
|
||||
h("Android process limits")
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
h("Device switch files in Download")
|
||||
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
|
||||
h("Device switch files")
|
||||
SessionService.envSwitchFile(context)?.takeIf { it.isFile }?.let { f ->
|
||||
k("droiddeck-env", FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
}
|
||||
if (File(Environment.getExternalStorageDirectory(), "Download/droiddeck-env").isFile) {
|
||||
k("Download/droiddeck-env", "(ignored - it moved to Android/data/${context.packageName}/files)")
|
||||
}
|
||||
for (name in listOf("droiddeck-tu-debug", "droiddeck-driver",
|
||||
"droiddeck-osc", "droiddeck-no-pad",
|
||||
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
|
||||
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -17,8 +19,8 @@ object DeviceSupport {
|
||||
File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
|
||||
/** The chip as the device names it, for the card that explains the refusal. */
|
||||
fun gpuName(): String {
|
||||
fun gpuName(context: Context): String {
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN } else null
|
||||
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { "this GPU" }
|
||||
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { context.getString(R.string.gpu_this_gpu) }
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ object HostProcess {
|
||||
if (onLine == null) builder.redirectOutput(File("/dev/null"))
|
||||
val process = builder.start()
|
||||
val pid = pidOf(process)
|
||||
Log.i(TAG, "started pid $pid: $command")
|
||||
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
|
||||
if (onLine != null) {
|
||||
Thread({
|
||||
try {
|
||||
@@ -59,7 +59,7 @@ object HostProcess {
|
||||
}
|
||||
pid
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "could not start: $command", e)
|
||||
Log.e(TAG, "could not start: ${argv.first()}", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,15 @@ object LogRedactor {
|
||||
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
|
||||
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
|
||||
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
|
||||
private val MAC = Regex("(?i)(?<![0-9a-z:-])[0-9a-f]{2}([:-])(?:[0-9a-f]{2}\\1){4}[0-9a-f]{2}(?![0-9a-z:-])")
|
||||
private val MAC_FIELD = Regex(
|
||||
"(?i)(\\bmac(?:[_ ]?address)?[\"']?\\s*[:=]\\s*[\"']?)[0-9a-f]{2}([:-])" +
|
||||
"(?:[0-9a-f]{2}\\2){4}[0-9a-f]{2}(?![0-9a-z:-])"
|
||||
)
|
||||
private val DEVICE_IDENTIFIER = Regex(
|
||||
"(?i)(\\b(?:android[_ ]?id|(?:device|controller)[_ ]?serial|serial(?:[_ ]?number)?|build\\.serial)" +
|
||||
"[\"']?\\s*[:=]\\s*)(\"[^\"\\r\\n<>]+\"|'[^'\\r\\n<>]+'|[^\\s\"'<>&;,\\[\\]{}]+)"
|
||||
)
|
||||
|
||||
/**
|
||||
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
|
||||
@@ -196,7 +205,7 @@ object LogRedactor {
|
||||
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
|
||||
* out.
|
||||
*/
|
||||
const val RULES_VERSION = 1
|
||||
const val RULES_VERSION = 2
|
||||
|
||||
/** [src]'s lines, scrubbed, to [out]. */
|
||||
fun scrubTo(src: java.io.File, out: java.io.Writer) {
|
||||
@@ -208,6 +217,13 @@ object LogRedactor {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
var out = line
|
||||
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
|
||||
out = MAC.replace(out, "<redacted:mac>")
|
||||
out = DEVICE_IDENTIFIER.replace(out) {
|
||||
val value = it.groupValues[2]
|
||||
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
|
||||
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
|
||||
}
|
||||
out = GUID.replace(out, "<redacted:guid>")
|
||||
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
|
||||
out = JWT_BASE64.replace(out, "<redacted:jwt>")
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
/** Android's phantom process monitor can kill the many child processes used by a Steam session. */
|
||||
enum class PhantomProcessStatus {
|
||||
@@ -98,29 +99,30 @@ object PhantomProcessLimit {
|
||||
fun blocksSteam(status: PhantomProcessStatus): Boolean =
|
||||
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
|
||||
|
||||
fun title(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
|
||||
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
|
||||
PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked"
|
||||
PhantomProcessStatus.DISABLED -> "Child-process limit is off"
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required"
|
||||
fun title(context: Context, status: PhantomProcessStatus): String = context.getString(when (status) {
|
||||
PhantomProcessStatus.ENABLED -> R.string.phantom_title_on
|
||||
PhantomProcessStatus.UNSET -> R.string.phantom_title_unset
|
||||
PhantomProcessStatus.UNREADABLE -> R.string.phantom_title_unreadable
|
||||
PhantomProcessStatus.DISABLED -> R.string.phantom_title_off
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> R.string.phantom_title_not_required
|
||||
})
|
||||
|
||||
fun instructions(context: Context, status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE ->
|
||||
context.getString(R.string.phantom_instructions_fix, fixSentence(context))
|
||||
PhantomProcessStatus.DISABLED -> context.getString(R.string.phantom_instructions_off)
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> context.getString(R.string.phantom_instructions_not_applicable)
|
||||
}
|
||||
|
||||
fun instructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
|
||||
fun gateInstructions(context: Context, status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence(context)
|
||||
else -> instructions(context, status)
|
||||
}
|
||||
|
||||
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
|
||||
else -> instructions(status)
|
||||
}
|
||||
|
||||
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
|
||||
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
|
||||
fun fixSentence(context: Context, sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
context.getString(if (hasDeveloperToggle(sdk)) R.string.phantom_fix_toggle else R.string.phantom_fix_no_toggle)
|
||||
|
||||
/** English: for the device report. */
|
||||
fun reportValue(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
|
||||
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import com.droiddeck.launcher.R
|
||||
import com.flyfishxu.kadb.Kadb
|
||||
import com.flyfishxu.kadb.cert.KadbCert
|
||||
import java.io.File
|
||||
@@ -37,21 +38,21 @@ object WirelessAdbFix {
|
||||
|
||||
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
|
||||
KadbCert.set(identity.first, identity.second)
|
||||
writeAtomically(cert, identity.first)
|
||||
writeAtomically(key, identity.second)
|
||||
writeAtomically(context, cert, identity.first)
|
||||
writeAtomically(context, key, identity.second)
|
||||
}
|
||||
|
||||
private fun writeAtomically(destination: File, contents: ByteArray) {
|
||||
private fun writeAtomically(context: Context, destination: File, contents: ByteArray) {
|
||||
val temporary = File(destination.parentFile, "${destination.name}.tmp")
|
||||
temporary.writeBytes(contents)
|
||||
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
|
||||
check(temporary.renameTo(destination)) { context.getString(R.string.adbfix_save_failed) }
|
||||
}
|
||||
|
||||
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.pair(host, port, pairingCode, "DroidDeck")
|
||||
writeAtomically(File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
|
||||
writeAtomically(File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
|
||||
writeAtomically(context, File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
|
||||
writeAtomically(context, File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
|
||||
}
|
||||
|
||||
private fun savedConnection(context: Context): SavedConnection? {
|
||||
@@ -73,6 +74,7 @@ object WirelessAdbFix {
|
||||
|
||||
private fun saveConnection(context: Context, host: String, port: Int) {
|
||||
writeAtomically(
|
||||
context,
|
||||
File(context.applicationContext.noBackupFilesDir, CONNECTION_FILE),
|
||||
"$host\n$port".toByteArray(),
|
||||
)
|
||||
@@ -171,11 +173,11 @@ object WirelessAdbFix {
|
||||
Kadb.create(host, port).use { adb ->
|
||||
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
|
||||
val change = adb.shell(command)
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { context.getString(R.string.adbfix_command_failed, change.exitCode) } }
|
||||
}
|
||||
val result = adb.shell(PhantomProcessLimit.verifyCommand())
|
||||
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
|
||||
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
|
||||
context.getString(R.string.adbfix_not_confirmed, result.allOutput.trim())
|
||||
}
|
||||
}
|
||||
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
|
||||
@@ -186,7 +188,7 @@ object WirelessAdbFix {
|
||||
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
|
||||
val appContext = context.applicationContext
|
||||
val saved = savedConnection(appContext)
|
||||
?: error("No Wireless debugging pairing is saved")
|
||||
?: error(appContext.getString(R.string.adbfix_no_pairing))
|
||||
val cachedPort = saved.port
|
||||
if (cachedPort != null) {
|
||||
val cachedResult = runCatching {
|
||||
@@ -201,7 +203,7 @@ object WirelessAdbFix {
|
||||
}
|
||||
} else {
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
?: error("Enter the current Wireless debugging IP address & Port")
|
||||
?: error(appContext.getString(R.string.adbfix_enter_address))
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class WirelessAdbPairingService : Service() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
|
||||
|
||||
sealed interface Stage {
|
||||
data object Idle : Stage
|
||||
data object Waiting : Stage
|
||||
@@ -35,7 +38,7 @@ class WirelessAdbPairingService : Service() {
|
||||
private var pairingHost: String? = null
|
||||
private var pairingPort: Int? = null
|
||||
@Volatile private var working = false
|
||||
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
|
||||
private val timeout = Runnable { finish(Stage.Failed(getString(R.string.adbpair_timed_out))) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@@ -75,13 +78,13 @@ class WirelessAdbPairingService : Service() {
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
|
||||
override fun onDiscoveryStopped(serviceType: String) = Unit
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
|
||||
main.post { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed_code, errorCode))) }
|
||||
}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
|
||||
}
|
||||
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
|
||||
.onSuccess { nsd = manager; discovery = listener }
|
||||
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
|
||||
.onFailure { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed, it.localizedMessage.toString()))) }
|
||||
}
|
||||
|
||||
private fun resolveListener() = object : NsdManager.ResolveListener {
|
||||
@@ -107,34 +110,34 @@ class WirelessAdbPairingService : Service() {
|
||||
return
|
||||
}
|
||||
if (code.length != 6) {
|
||||
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
|
||||
show(Stage.CodeNeeded(getString(R.string.adbpair_six_digits)))
|
||||
return
|
||||
}
|
||||
working = true
|
||||
show(Stage.Working("Pairing…"))
|
||||
show(Stage.Working(getString(R.string.adbpair_pairing)))
|
||||
Thread({
|
||||
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
|
||||
if (paired.isFailure) {
|
||||
main.post {
|
||||
working = false
|
||||
pairingPort = null
|
||||
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
|
||||
show(Stage.CodeNeeded(getString(R.string.adbpair_code_rejected)))
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
main.post {
|
||||
stopDiscovery()
|
||||
show(Stage.Working("Paired. Connecting…"))
|
||||
show(Stage.Working(getString(R.string.adbpair_connecting)))
|
||||
}
|
||||
val result = runCatching {
|
||||
val connectPort = WirelessAdbFix.localConnectPort(this)
|
||||
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
|
||||
main.post { show(Stage.Working("Applying the setting…")) }
|
||||
?: error(getString(R.string.adbpair_port_not_found))
|
||||
main.post { show(Stage.Working(getString(R.string.adbpair_applying))) }
|
||||
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
|
||||
}
|
||||
main.post {
|
||||
working = false
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: getString(R.string.adbpair_command_failed)) } ?: Stage.Done)
|
||||
}
|
||||
}, "wireless-adb-pairing").start()
|
||||
}
|
||||
@@ -174,9 +177,9 @@ class WirelessAdbPairingService : Service() {
|
||||
|
||||
private fun notification(stage: Stage): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.adbpair_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Asks for the Wireless debugging pairing code while Settings is open"
|
||||
description = getString(R.string.adbpair_channel_description)
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
@@ -193,41 +196,40 @@ class WirelessAdbPairingService : Service() {
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
when (stage) {
|
||||
Stage.Waiting, Stage.Idle -> builder
|
||||
.setContentTitle("Pair Wireless debugging")
|
||||
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
|
||||
.setStyle(Notification.BigTextStyle().bigText(
|
||||
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
|
||||
.setContentTitle(getString(R.string.adbpair_title_pair))
|
||||
.setContentText(getString(R.string.adbpair_waiting_text))
|
||||
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_waiting_long)))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
|
||||
is Stage.CodeNeeded -> {
|
||||
val reply = PendingIntent.getService(this, 2,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
|
||||
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
else PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
|
||||
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel(getString(R.string.adbpair_code_label)).build()
|
||||
val text = stage.error ?: getString(R.string.adbpair_code_hint)
|
||||
builder
|
||||
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
|
||||
.setContentTitle(getString(if (stage.error == null) R.string.adbpair_title_code else R.string.adbpair_title_retry))
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setOngoing(true)
|
||||
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.adbpair_enter_code), reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
|
||||
}
|
||||
is Stage.Working -> builder
|
||||
.setContentTitle("Setting up Steam")
|
||||
.setContentTitle(getString(R.string.adbpair_title_working))
|
||||
.setContentText(stage.step)
|
||||
.setProgress(0, 0, true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
Stage.Done -> builder
|
||||
.setContentTitle("Steam is ready")
|
||||
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
|
||||
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
|
||||
.setContentTitle(getString(R.string.adbpair_title_done))
|
||||
.setContentText(getString(R.string.adbpair_done_text))
|
||||
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_done_text)))
|
||||
.setAutoCancel(true)
|
||||
is Stage.Failed -> builder
|
||||
.setContentTitle("Wireless debugging setup stopped")
|
||||
.setContentTitle(getString(R.string.adbpair_title_failed))
|
||||
.setContentText(stage.error)
|
||||
.setStyle(Notification.BigTextStyle().bigText(stage.error))
|
||||
.setAutoCancel(true)
|
||||
|
||||
@@ -56,6 +56,8 @@ data class RailItem(
|
||||
val selected: Boolean,
|
||||
/** >0 shows a small accent count badge on the item's icon (expanded and collapsed). */
|
||||
val badge: Int = 0,
|
||||
/** The under-icon label when the rail is collapsed, for names too long to fit; null derives one. */
|
||||
val shortLabel: String? = null,
|
||||
// onClick stays LAST so existing call sites can pass it as a trailing lambda; badge is an
|
||||
// optional named param before it (default 0).
|
||||
val onClick: () -> Unit,
|
||||
@@ -73,7 +75,6 @@ private fun collapsedLabel(label: String): String {
|
||||
when (label) {
|
||||
"ENVIROMENT" -> return "ENVIRON" // the app's existing (mis)spelling, kept uppercase
|
||||
"WIN COMPONENTS" -> return "WIN COMP"
|
||||
"Downloads" -> return "Downlds"
|
||||
}
|
||||
if (label.contains(' ')) return label // multi-word → wraps to 2 lines
|
||||
return if (label.length > 9) label.take(8) + "…" else label
|
||||
@@ -317,7 +318,7 @@ fun CollapsibleRail(
|
||||
RailItemIcon(item, iconTint)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
collapsedLabel(item.label),
|
||||
item.shortLabel ?: collapsedLabel(item.label),
|
||||
color = labelColor,
|
||||
fontSize = 7.5.sp,
|
||||
lineHeight = 8.5.sp,
|
||||
|
||||
@@ -15,6 +15,11 @@ import java.io.File
|
||||
|
||||
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
|
||||
class FileManagerActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
@@ -61,6 +62,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -107,7 +109,7 @@ private fun FileContextMenuItems(
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
DropdownMenuItem(
|
||||
text = { Text("Select") },
|
||||
text = { Text(stringResource(R.string.fm_select)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onSelect() },
|
||||
)
|
||||
@@ -115,7 +117,7 @@ private fun FileContextMenuItems(
|
||||
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
|
||||
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Properties") },
|
||||
text = { Text(stringResource(R.string.fm_properties)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onProperties() },
|
||||
)
|
||||
@@ -123,7 +125,7 @@ private fun FileContextMenuItems(
|
||||
// Favorites are directories - only folders get the pin toggle.
|
||||
if (isDir) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
|
||||
text = { Text(if (isFavorite) stringResource(R.string.fm_favorite_remove) else stringResource(R.string.fm_favorite_add)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
@@ -136,25 +138,25 @@ private fun FileContextMenuItems(
|
||||
MenuItemDivider()
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
text = { Text(stringResource(R.string.fm_rename)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onRename() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
text = { Text(stringResource(R.string.fm_copy)) },
|
||||
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCopy() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cut") },
|
||||
text = { Text(stringResource(R.string.fm_cut)) },
|
||||
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCut() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
text = { Text(stringResource(R.string.common_delete)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onDelete() },
|
||||
)
|
||||
@@ -184,6 +186,7 @@ internal fun FileItemRow(
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
val context = LocalContext.current
|
||||
val isDir = file.isDirectory
|
||||
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
|
||||
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
|
||||
@@ -276,7 +279,7 @@ internal fun FileItemRow(
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
|
||||
if (!isDir) append(FileOps.formatBytes(context, file.length())).append(" \u2022 ")
|
||||
append(dateFormat.format(Date(file.lastModified())))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -285,7 +288,7 @@ internal fun FileItemRow(
|
||||
}
|
||||
if (showActions) Box {
|
||||
IconButton(onClick = onMenu) {
|
||||
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
Icon(Icons.Filled.MoreVert, stringResource(R.string.fm_actions), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
@@ -310,6 +313,47 @@ internal fun FileItemRow(
|
||||
}
|
||||
}
|
||||
|
||||
// The ".." row at the top of a folder: one level up, styled like a folder row so it reads as part
|
||||
// of the list and takes controller focus the same way.
|
||||
@Composable
|
||||
internal fun ParentFolderRow(compact: Boolean = false, onTap: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.clickable(onClick = onTap),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowUpward,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "..",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fm_up_one_level),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Favorites list that replaces the file list while the star toggle is on.
|
||||
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
|
||||
@Composable
|
||||
@@ -338,7 +382,7 @@ internal fun FavoritesList(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
text = stringResource(R.string.fm_favorites),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -351,7 +395,7 @@ internal fun FavoritesList(
|
||||
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Pin current folder",
|
||||
text = stringResource(R.string.fm_pin_current),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
@@ -366,7 +410,7 @@ internal fun FavoritesList(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
|
||||
text = stringResource(R.string.fm_no_favorites),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
@@ -431,7 +475,7 @@ private fun FavoriteCard(
|
||||
// Origin line: coloured drive badge + source description.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = loc.driveLabel,
|
||||
text = stringResource(loc.driveLabel),
|
||||
color = badgeFg,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -443,9 +487,9 @@ private fun FavoriteCard(
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (loc.storage) {
|
||||
FavStorage.INTERNAL -> "Internal storage"
|
||||
FavStorage.SD -> "SD card"
|
||||
FavStorage.OTHER -> "Storage"
|
||||
FavStorage.INTERNAL -> stringResource(R.string.games_internal)
|
||||
FavStorage.SD -> stringResource(R.string.fm_drive_sd)
|
||||
FavStorage.OTHER -> stringResource(R.string.fm_drive_storage)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
@@ -464,7 +508,7 @@ private fun FavoriteCard(
|
||||
IconButton(onClick = onUnpin) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = "Remove from favorites",
|
||||
contentDescription = stringResource(R.string.fm_unpin_favorite),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
|
||||
@@ -77,7 +79,7 @@ enum class FavStorage { INTERNAL, SD, OTHER }
|
||||
|
||||
data class FavLocation(
|
||||
val storage: FavStorage,
|
||||
val driveLabel: String, // "Internal", "SD card", or "Storage"
|
||||
@StringRes val driveLabel: Int, // Internal, SD card, or Storage
|
||||
val displayPath: String // the unix absolute path
|
||||
)
|
||||
|
||||
@@ -87,15 +89,20 @@ fun describeLocation(file: File): FavLocation {
|
||||
|
||||
val internal = "/storage/emulated/0"
|
||||
if (abs == internal || abs.startsWith("$internal/")) {
|
||||
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
|
||||
return FavLocation(FavStorage.INTERNAL, R.string.fm_drive_internal, abs)
|
||||
}
|
||||
|
||||
if (abs.startsWith("/storage/")) {
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") {
|
||||
return FavLocation(FavStorage.SD, "SD card", abs)
|
||||
return FavLocation(FavStorage.SD, R.string.fm_drive_sd, abs)
|
||||
}
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, "Storage", abs)
|
||||
// The Linux system's root (the app's files/linuxfs, the sessions' /) and anything under it.
|
||||
if (Regex("/files/linuxfs(/|$)").containsMatchIn(abs)) {
|
||||
return FavLocation(FavStorage.OTHER, R.string.fm_drive_linux, abs)
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, R.string.fm_drive_storage, abs)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.foundation.lazy.rememberLazyListState
|
||||
@@ -41,6 +42,7 @@ import androidx.compose.material.icons.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Smartphone
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
@@ -76,6 +78,9 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -140,7 +145,14 @@ fun FileManagerScreen(
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") File("/storage/$name") else rootDir
|
||||
}
|
||||
else -> rootDir
|
||||
// A game's own folder or Proton prefix lives in the app's private storage, which has no
|
||||
// volume above it: floor at the app's files dir so up/back still reach the library roots.
|
||||
else -> {
|
||||
val appFiles = runCatching { context.filesDir.canonicalPath }.getOrNull()
|
||||
generateSequence(rootDir) { it.parentFile }
|
||||
.firstOrNull { appFiles != null && runCatching { it.canonicalPath }.getOrNull() == appFiles }
|
||||
?: rootDir
|
||||
}
|
||||
}
|
||||
mutableStateOf(vol)
|
||||
}
|
||||
@@ -229,7 +241,9 @@ fun FileManagerScreen(
|
||||
loadDirectory(dir)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { openDrive(rootDir) }
|
||||
// Open the start dir without openDrive(): that pins the floor to the start dir itself, which
|
||||
// greyed out up/back whenever the manager was opened at a game's folder.
|
||||
LaunchedEffect(Unit) { loadDirectory(rootDir) }
|
||||
|
||||
// System/gesture Back: while the Favorites view is open it closes that first; otherwise
|
||||
// it goes up one directory. Only at the current drive's root with Favorites closed is it
|
||||
@@ -243,6 +257,14 @@ fun FileManagerScreen(
|
||||
if (parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
// One level up, never above the current drive's root: shared by the toolbar arrow and the
|
||||
// ".." row at the top of the list.
|
||||
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
|
||||
fun goUp() {
|
||||
val parent = currentDir.parentFile
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
// Resolve a non-colliding destination in [dir] for [name] (foo.txt -> "foo (1).txt").
|
||||
fun uniqueDestination(dir: File, name: String): File {
|
||||
var candidate = File(dir, name)
|
||||
@@ -261,11 +283,11 @@ fun FileManagerScreen(
|
||||
fun performDelete(file: File) {
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = "Deleting..."
|
||||
operationLabel = context.getString(R.string.fm_op_deleting)
|
||||
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, "Delete failed", Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +310,7 @@ fun FileManagerScreen(
|
||||
operationJob = scope.launch {
|
||||
operationProgress = 0f
|
||||
operationDeterminate = true
|
||||
operationLabel = if (cut) "Moving..." else "Copying..."
|
||||
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
|
||||
isOperationRunning = true
|
||||
|
||||
var applyToAll: ConflictChoice? = null
|
||||
@@ -324,10 +346,10 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
// Progress is per item; with a batch the label carries the overall position.
|
||||
operationLabel = buildString {
|
||||
append(if (cut) "Moving" else "Copying")
|
||||
if (sources.size > 1) append(" ${done + 1}/${sources.size}")
|
||||
append(" - ").append(src.name)
|
||||
operationLabel = if (sources.size > 1) {
|
||||
context.getString(if (cut) R.string.fm_op_moving_batch else R.string.fm_op_copying_batch, done + 1, sources.size, src.name)
|
||||
} else {
|
||||
context.getString(if (cut) R.string.fm_op_moving_item else R.string.fm_op_copying_item, src.name)
|
||||
}
|
||||
var lastPct = -1
|
||||
val onProgress = FileOps.ProgressCallback { copied, total ->
|
||||
@@ -355,9 +377,9 @@ fun FileManagerScreen(
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
|
||||
val message = when {
|
||||
failed > 0 -> "$done done, $failed failed"
|
||||
skipped > 0 -> "$done done, $skipped skipped"
|
||||
sources.size > 1 -> "$done items ${if (cut) "moved" else "copied"}"
|
||||
failed > 0 -> context.getString(R.string.fm_paste_failed, done, failed)
|
||||
skipped > 0 -> context.getString(R.string.fm_paste_skipped, done, skipped)
|
||||
sources.size > 1 -> context.resources.getQuantityString(if (cut) R.plurals.fm_paste_moved else R.plurals.fm_paste_copied, done, done)
|
||||
else -> null
|
||||
}
|
||||
if (message != null) Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
@@ -367,32 +389,32 @@ fun FileManagerScreen(
|
||||
fun performRename(file: File, newName: String) {
|
||||
val target = File(file.parentFile, newName)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, "\"$newName\" already exists", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = "Renaming..."
|
||||
operationLabel = context.getString(R.string.fm_op_renaming)
|
||||
val ok = withContext(Dispatchers.IO) { file.renameTo(target) }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, "Rename failed", Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun createFolder(parent: File, name: String) {
|
||||
val target = File(parent, name)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, "\"$name\" already exists", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = "Creating folder..."
|
||||
operationLabel = context.getString(R.string.fm_op_creating_folder)
|
||||
val ok = withContext(Dispatchers.IO) { target.mkdirs() }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, "Could not create folder", Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +431,9 @@ fun FileManagerScreen(
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val drives = remember(storageTick) { StorageRoots.list(context) }
|
||||
val linuxRoot = remember(storageTick) {
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
|
||||
}
|
||||
|
||||
// ── Dialogs ──
|
||||
|
||||
@@ -416,12 +441,12 @@ fun FileManagerScreen(
|
||||
var folderName by remember { mutableStateOf("") }
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { showNewFolderDialog = false },
|
||||
title = { Text("New Folder") },
|
||||
title = { Text(stringResource(R.string.fm_new_folder)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = folderName,
|
||||
onValueChange = { folderName = it },
|
||||
label = { Text("Folder name") },
|
||||
label = { Text(stringResource(R.string.fm_folder_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
@@ -429,9 +454,9 @@ fun FileManagerScreen(
|
||||
TextButton(onClick = {
|
||||
showNewFolderDialog = false
|
||||
if (folderName.isNotBlank()) createFolder(currentDir, folderName)
|
||||
}) { Text("Create") }
|
||||
}) { Text(stringResource(R.string.fm_create)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,12 +464,12 @@ fun FileManagerScreen(
|
||||
var newName by remember(renameTarget) { mutableStateOf(renameTarget?.name ?: "") }
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { renameTarget = null },
|
||||
title = { Text("Rename") },
|
||||
title = { Text(stringResource(R.string.fm_rename)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("New name") },
|
||||
label = { Text(stringResource(R.string.fm_new_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
@@ -453,9 +478,9 @@ fun FileManagerScreen(
|
||||
val file = renameTarget
|
||||
renameTarget = null
|
||||
if (file != null && newName.isNotBlank()) performRename(file, newName)
|
||||
}) { Text("Rename") }
|
||||
}) { Text(stringResource(R.string.fm_rename)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,15 +498,15 @@ fun FileManagerScreen(
|
||||
val file = selectedEntry ?: return
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { selectedEntry = null },
|
||||
title = { Text("Delete?") },
|
||||
text = { Text("Delete \"${file.name}\" permanently?") },
|
||||
title = { Text(stringResource(R.string.fm_delete_title)) },
|
||||
text = { Text(stringResource(R.string.fm_delete_text, file.name)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
selectedEntry = null
|
||||
performDelete(file)
|
||||
}) { Text("Delete") }
|
||||
}) { Text(stringResource(R.string.common_delete)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -489,10 +514,10 @@ fun FileManagerScreen(
|
||||
val victims = pendingBulkDelete
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { pendingBulkDelete = emptyList() },
|
||||
title = { Text("Delete ${victims.size} item${if (victims.size == 1) "" else "s"}?") },
|
||||
title = { Text(pluralStringResource(R.plurals.fm_delete_items_title, victims.size, victims.size)) },
|
||||
text = {
|
||||
Column {
|
||||
Text("This can't be undone.")
|
||||
Text(stringResource(R.string.fm_cannot_undo))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Name a few so an accidental Select-All is obvious before it's too late.
|
||||
victims.take(5).forEach {
|
||||
@@ -500,7 +525,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
if (victims.size > 5) {
|
||||
Text(
|
||||
"…and ${victims.size - 5} more",
|
||||
pluralStringResource(R.plurals.fm_and_more, victims.size - 5, victims.size - 5),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
@@ -516,19 +541,19 @@ fun FileManagerScreen(
|
||||
isOperationRunning = true
|
||||
var failed = 0
|
||||
victims.forEachIndexed { i, f ->
|
||||
operationLabel = "Deleting ${i + 1}/${victims.size} - ${f.name}"
|
||||
operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name)
|
||||
if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
|
||||
}
|
||||
isOperationRunning = false
|
||||
operationJob = null
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (failed > 0) {
|
||||
Toast.makeText(context, "$failed couldn't be deleted", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
|
||||
}) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -537,12 +562,12 @@ fun FileManagerScreen(
|
||||
val isDir = conflict.isDirectory
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { pendingConflict = null },
|
||||
title = { Text("\"${conflict.name}\" already exists") },
|
||||
title = { Text(stringResource(R.string.fm_already_exists, conflict.name)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (isDir) "Merge adds and replaces files inside the existing folder."
|
||||
else "Overwrite replaces the existing file.",
|
||||
if (isDir) stringResource(R.string.fm_conflict_merge_hint)
|
||||
else stringResource(R.string.fm_conflict_overwrite_hint),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
@@ -553,15 +578,15 @@ fun FileManagerScreen(
|
||||
checked = conflictApplyToAll,
|
||||
onCheckedChange = { conflictApplyToAll = it },
|
||||
)
|
||||
Text("Apply to all conflicts", fontSize = 12.sp)
|
||||
Text(stringResource(R.string.fm_conflict_apply_all), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
listOf(
|
||||
(if (isDir) ConflictChoice.MERGE else ConflictChoice.OVERWRITE) to
|
||||
(if (isDir) "Merge" else "Overwrite"),
|
||||
ConflictChoice.KEEP_BOTH to "Keep both",
|
||||
ConflictChoice.SKIP to "Skip",
|
||||
(if (isDir) stringResource(R.string.fm_conflict_merge) else stringResource(R.string.fm_conflict_overwrite)),
|
||||
ConflictChoice.KEEP_BOTH to stringResource(R.string.fm_conflict_keep_both),
|
||||
ConflictChoice.SKIP to stringResource(R.string.fm_conflict_skip),
|
||||
).forEach { (choice, label) ->
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -573,7 +598,7 @@ fun FileManagerScreen(
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { conflictChoice = ConflictChoice.SKIP; pendingConflict = null }) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -603,7 +628,7 @@ fun FileManagerScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.Folder, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Select this folder")
|
||||
Text(stringResource(R.string.fm_select_this_folder))
|
||||
}
|
||||
}
|
||||
// ── Path bar ──
|
||||
@@ -614,15 +639,11 @@ fun FileManagerScreen(
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val parent = currentDir.parentFile
|
||||
// Don't climb above the current drive's root.
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
}, enabled = currentDir != currentRoot) {
|
||||
Icon(Icons.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.primary)
|
||||
IconButton(onClick = { goUp() }, enabled = canGoUp) {
|
||||
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
val currentDriveLabel = describeLocation(currentDir).driveLabel
|
||||
val currentDriveLabel = stringResource(describeLocation(currentDir).driveLabel)
|
||||
// Dim the drive chip while the Favorites list is open (it's not the active context).
|
||||
val driveChipAlpha = if (showFavorites) 0.45f else 1f
|
||||
Box {
|
||||
@@ -666,13 +687,23 @@ fun FileManagerScreen(
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"${drive.label} is mounted but not readable right now",
|
||||
context.getString(R.string.fm_drive_not_readable, drive.label),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
linuxRoot?.let { root ->
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_drive_linux)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
onClick = { showDriveMenu = false; openDrive(root) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,7 +711,7 @@ fun FileManagerScreen(
|
||||
|
||||
if (showFavorites) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
text = stringResource(R.string.fm_favorites),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -718,7 +749,7 @@ fun FileManagerScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.CreateNewFolder, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("New Folder", color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
|
||||
Text(stringResource(R.string.fm_new_folder), color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
@@ -727,19 +758,19 @@ fun FileManagerScreen(
|
||||
}) {
|
||||
Icon(
|
||||
if (gridView) Icons.Filled.ViewList else Icons.Filled.GridView,
|
||||
if (gridView) "List view" else "Grid view",
|
||||
if (gridView) stringResource(R.string.fm_list_view) else stringResource(R.string.fm_grid_view),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showSearch = !showSearch; if (!showSearch) searchQuery = "" }) {
|
||||
Icon(Icons.Filled.Search, "Search", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.Search, stringResource(R.string.fm_search), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showSortMenu = true }) {
|
||||
Icon(Icons.Filled.Sort, "Sort", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.Sort, stringResource(R.string.fm_sort), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
|
||||
listOf("name" to "Name", "date" to "Date modified", "size" to "Size")
|
||||
listOf("name" to stringResource(R.string.fm_sort_name), "date" to stringResource(R.string.fm_sort_date), "size" to stringResource(R.string.fm_prop_size))
|
||||
.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
@@ -758,7 +789,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (compactRows) "Comfortable rows" else "Compact rows") },
|
||||
text = { Text(if (compactRows) stringResource(R.string.fm_rows_comfortable) else stringResource(R.string.fm_rows_compact)) },
|
||||
onClick = {
|
||||
compactRows = !compactRows
|
||||
browsePrefs.edit().putBoolean("fmCompactRows", compactRows).apply()
|
||||
@@ -766,7 +797,7 @@ fun FileManagerScreen(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (showHidden) "Hide hidden files" else "Show hidden files") },
|
||||
text = { Text(if (showHidden) stringResource(R.string.fm_hide_hidden) else stringResource(R.string.fm_show_hidden)) },
|
||||
onClick = {
|
||||
showHidden = !showHidden
|
||||
browsePrefs.edit().putBoolean("fmShowHidden", showHidden).apply()
|
||||
@@ -781,9 +812,9 @@ fun FileManagerScreen(
|
||||
// Star toggle: open/close the dedicated Favorites list.
|
||||
IconButton(onClick = { showFavorites = !showFavorites }) {
|
||||
if (showFavorites) {
|
||||
Icon(Icons.Filled.Star, "Hide favorites", tint = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Filled.Star, stringResource(R.string.fm_hide_favorites), tint = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
Icon(Icons.Filled.StarBorder, "Show favorites", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -794,7 +825,7 @@ fun FileManagerScreen(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
singleLine = true,
|
||||
placeholder = { Text("Filter this folder", fontSize = 13.sp) },
|
||||
placeholder = { Text(stringResource(R.string.fm_filter_folder), fontSize = 13.sp) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
@@ -821,7 +852,7 @@ fun FileManagerScreen(
|
||||
)
|
||||
if (freeSpace > 0) {
|
||||
Text(
|
||||
"${FileOps.formatBytes(freeSpace)} free",
|
||||
stringResource(R.string.fm_free_space, FileOps.formatBytes(context, freeSpace)),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
@@ -842,7 +873,7 @@ fun FileManagerScreen(
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
"${selectedPaths.size} selected",
|
||||
stringResource(R.string.fm_selected_count, selectedPaths.size),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -855,7 +886,7 @@ fun FileManagerScreen(
|
||||
else entries.map { it.absolutePath }.toSet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text(if (selectedPaths.size == entries.size) "None" else "All", fontSize = 12.sp) }
|
||||
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
@@ -866,7 +897,7 @@ fun FileManagerScreen(
|
||||
selectedPaths = emptySet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text("Copy", fontSize = 12.sp) }
|
||||
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
@@ -877,19 +908,19 @@ fun FileManagerScreen(
|
||||
selectedPaths = emptySet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text("Cut", fontSize = 12.sp) }
|
||||
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
|
||||
contentPadding = selBarPadding,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||
) { Text("Delete", color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
|
||||
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { selectionMode = false; selectedPaths = emptySet() },
|
||||
contentPadding = selBarPadding,
|
||||
) { Text("Done", fontSize = 12.sp) }
|
||||
) { Text(stringResource(R.string.fm_done), fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,16 +934,16 @@ fun FileManagerScreen(
|
||||
.clickable { performPaste() }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.ContentPaste, "Paste", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val what = if (clipboardFiles.size == 1) clipboardFiles.first().name
|
||||
else "${clipboardFiles.size} items"
|
||||
else pluralStringResource(R.plurals.fm_items, clipboardFiles.size, clipboardFiles.size)
|
||||
Text(
|
||||
"Paste $what${if (isCutOperation) " (move)" else ""} here",
|
||||
if (isCutOperation) stringResource(R.string.fm_paste_move_here, what) else stringResource(R.string.fm_paste_here, what),
|
||||
color = MaterialTheme.colorScheme.onBackground, fontSize = 13.sp, modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { clipboardFiles = emptyList(); isCutOperation = false }) {
|
||||
Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
|
||||
Text(stringResource(R.string.common_cancel), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -944,7 +975,7 @@ fun FileManagerScreen(
|
||||
isOperationRunning = false
|
||||
operationDeterminate = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
}) { Text("Cancel", fontSize = 12.sp) }
|
||||
}) { Text(stringResource(R.string.common_cancel), fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -970,38 +1001,40 @@ fun FileManagerScreen(
|
||||
// shown in pick mode (the themed picker keeps its slim layout). Built each recompose (cheap)
|
||||
// so it tracks the current drive/favourites without stale click lambdas.
|
||||
val fmRailState = rememberRailState("filemanager")
|
||||
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File) =
|
||||
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath) {
|
||||
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File, shortLabel: String? = null) =
|
||||
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath, shortLabel = shortLabel) {
|
||||
showFavorites = false; openDrive(dir)
|
||||
}
|
||||
val storageItems = buildList {
|
||||
add(locItem("Internal", Icons.Filled.Smartphone, File("/storage/emulated/0")))
|
||||
add(locItem(stringResource(R.string.fm_drive_internal), Icons.Filled.Smartphone, File("/storage/emulated/0")))
|
||||
drives.filter { it.removable }.forEach { d ->
|
||||
add(RailItem(d.label, Icons.Filled.SdStorage, !showFavorites && currentRoot.absolutePath == d.dir.absolutePath) {
|
||||
showFavorites = false; if (d.readable) openDrive(d.dir)
|
||||
})
|
||||
}
|
||||
// The Linux system the sessions run in: its own / (the desktop's root), once installed.
|
||||
linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) }
|
||||
}
|
||||
val quickItems = buildList {
|
||||
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem("Downloads", Icons.Filled.Download, it)) }
|
||||
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
|
||||
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
|
||||
?.let { add(locItem("ROMs", Icons.Filled.SportsEsports, it)) }
|
||||
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem("Session logs", Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem("Pictures", Icons.Filled.Image, it)) }
|
||||
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
|
||||
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
|
||||
}
|
||||
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
|
||||
.map { d -> RailItem(d.name, Icons.Filled.Star, false) { showFavorites = false; openDrive(d) } }
|
||||
val locationSections = buildList {
|
||||
add(RailSection("STORAGE", storageItems))
|
||||
if (quickItems.isNotEmpty()) add(RailSection("QUICK", quickItems))
|
||||
if (favItems.isNotEmpty()) add(RailSection("FAVORITES", favItems))
|
||||
add(RailSection(stringResource(R.string.fm_rail_storage), storageItems))
|
||||
if (quickItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_quick), quickItems))
|
||||
if (favItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_favorites), favItems))
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
// The slim picker keeps its layout: no rail.
|
||||
if (!pickMode) {
|
||||
CollapsibleRail(state = fmRailState, title = "Files", sections = locationSections, outlinedItems = true)
|
||||
CollapsibleRail(state = fmRailState, title = stringResource(R.string.fm_rail_title), sections = locationSections, outlinedItems = true)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
||||
// ── Favorites list OR file list ──
|
||||
@@ -1012,7 +1045,7 @@ fun FileManagerScreen(
|
||||
onPinCurrent = {
|
||||
FavoritesStore.add(context, currentDir.absolutePath)
|
||||
favTick++
|
||||
Toast.makeText(context, "Added \"${currentDir.name}\" to Favorites", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.fm_favorite_added, currentDir.name), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onJump = { dir ->
|
||||
showFavorites = false
|
||||
@@ -1021,7 +1054,7 @@ fun FileManagerScreen(
|
||||
onUnpin = { dir ->
|
||||
FavoritesStore.remove(context, dir.absolutePath)
|
||||
favTick++
|
||||
Toast.makeText(context, "Removed \"${dir.name}\" from Favorites", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.fm_favorite_removed, dir.name), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -1041,6 +1074,9 @@ fun FileManagerScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
}
|
||||
items(shownEntries, key = { it.absolutePath }) { file ->
|
||||
val isFav = remember(file.absolutePath, favTick) {
|
||||
FavoritesStore.isFavorite(context, file.absolutePath)
|
||||
@@ -1095,8 +1131,8 @@ fun FileManagerScreen(
|
||||
showMenuFor = null
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (nowFav) "Added \"${file.name}\" to Favorites"
|
||||
else "Removed \"${file.name}\" from Favorites",
|
||||
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
|
||||
else context.getString(R.string.fm_favorite_removed, file.name),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
@@ -1106,13 +1142,16 @@ fun FileManagerScreen(
|
||||
}
|
||||
} else
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
if (canGoUp) item(key = "..") {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("Empty directory", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.fm_empty_directory), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1173,8 +1212,8 @@ fun FileManagerScreen(
|
||||
showMenuFor = null
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (nowFav) "Added \"${file.name}\" to Favorites"
|
||||
else "Removed \"${file.name}\" from Favorites",
|
||||
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
|
||||
else context.getString(R.string.fm_favorite_removed, file.name),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
@@ -19,9 +21,9 @@ object FileOps {
|
||||
/** Byte-accurate copy progress (copied/total bytes), reported as the copy proceeds. */
|
||||
fun interface ProgressCallback { fun onProgress(copiedBytes: Long, totalBytes: Long) }
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes <= 0) return "0 bytes"
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB")
|
||||
fun formatBytes(context: Context, bytes: Long): String {
|
||||
if (bytes <= 0) return context.getString(R.string.fm_size_zero)
|
||||
val units = arrayOf(context.getString(R.string.fm_unit_bytes), "KB", "MB", "GB", "TB")
|
||||
val group = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt().coerceIn(0, units.lastIndex)
|
||||
return String.format(Locale.ENGLISH, "%.2f", bytes / Math.pow(1024.0, group.toDouble())) + " " + units[group]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import java.io.File
|
||||
|
||||
@@ -26,6 +27,11 @@ import java.io.File
|
||||
* - [EXTRA_PICKER_TITLE] the header
|
||||
*/
|
||||
class FilePickerActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -50,7 +56,7 @@ class FilePickerActivity : ComponentActivity() {
|
||||
pickDirMode = pickDir,
|
||||
pickExtensions = extensions,
|
||||
initialDir = initialDir,
|
||||
pickerTitle = title ?: if (pickDir) "Choose a folder" else "Choose a file",
|
||||
pickerTitle = title ?: getString(if (pickDir) R.string.fm_choose_folder else R.string.fm_choose_file),
|
||||
onPick = { file ->
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
|
||||
finish()
|
||||
|
||||
@@ -25,6 +25,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -66,7 +68,7 @@ internal fun FilePropertiesDialog(
|
||||
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Properties") },
|
||||
title = { Text(stringResource(R.string.fm_properties)) },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
// ── Basic info ──
|
||||
@@ -79,14 +81,14 @@ internal fun FilePropertiesDialog(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PropertyLine("Location", file.parent ?: "-")
|
||||
PropertyLine(stringResource(R.string.fm_prop_location), file.parent ?: "-")
|
||||
PropertyLine(
|
||||
"Type",
|
||||
if (file.isDirectory) "Folder"
|
||||
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
|
||||
stringResource(R.string.fm_prop_type),
|
||||
if (file.isDirectory) stringResource(R.string.fm_type_folder)
|
||||
else file.extension.uppercase().let { if (it.isBlank()) stringResource(R.string.fm_type_file) else stringResource(R.string.fm_type_ext_file, it) },
|
||||
)
|
||||
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
|
||||
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
|
||||
if (!file.isDirectory) PropertyLine(stringResource(R.string.fm_prop_size), FileOps.formatBytes(context, file.length()))
|
||||
PropertyLine(stringResource(R.string.fm_prop_modified), dateFormat.format(Date(file.lastModified())))
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
@@ -95,8 +97,8 @@ internal fun FilePropertiesDialog(
|
||||
val state = attrs
|
||||
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
|
||||
AttributeToggleRow(
|
||||
label = "Read-only",
|
||||
description = "Stops games (and Wine) from overwriting or deleting this file.",
|
||||
label = stringResource(R.string.fm_read_only),
|
||||
description = stringResource(R.string.fm_read_only_hint),
|
||||
checked = state?.readOnly == true,
|
||||
enabled = state != null && !busy,
|
||||
onToggle = { want ->
|
||||
@@ -108,7 +110,7 @@ internal fun FilePropertiesDialog(
|
||||
attrs = attrs?.copy(readOnly = want)
|
||||
onChanged()
|
||||
} else {
|
||||
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, R.string.fm_read_only_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -117,10 +119,10 @@ internal fun FilePropertiesDialog(
|
||||
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
|
||||
val hiddenSupported = state?.hiddenSupported == true
|
||||
AttributeToggleRow(
|
||||
label = "Hidden",
|
||||
label = stringResource(R.string.fm_hidden),
|
||||
description = if (state != null && !hiddenSupported)
|
||||
"This storage can't store the hidden flag."
|
||||
else "Marks the file hidden in Windows (Wine's hidden attribute).",
|
||||
stringResource(R.string.fm_hidden_unsupported)
|
||||
else stringResource(R.string.fm_hidden_hint),
|
||||
checked = state?.hidden == true,
|
||||
enabled = state != null && hiddenSupported && !busy,
|
||||
onToggle = { want ->
|
||||
@@ -134,14 +136,14 @@ internal fun FilePropertiesDialog(
|
||||
} else {
|
||||
// The write failed after all - disable the toggle rather than lie.
|
||||
attrs = attrs?.copy(hiddenSupported = false)
|
||||
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, R.string.fm_hidden_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.fm_done)) } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -63,7 +64,7 @@ object StorageRoots {
|
||||
|
||||
// Internal storage is never removable and never absent; seed it first so it heads the menu.
|
||||
volumeOf(PRIMARY_KEY).apply {
|
||||
label = "Internal"
|
||||
label = context.getString(R.string.fm_drive_internal)
|
||||
removable = false
|
||||
candidates += File(INTERNAL_PATH)
|
||||
}
|
||||
@@ -109,13 +110,13 @@ object StorageRoots {
|
||||
entry.seeds += dir
|
||||
}
|
||||
|
||||
val roots = volumes.values.map { it.toRoot() }
|
||||
val roots = volumes.values.map { it.toRoot(context) }
|
||||
val internal = roots.filter { !it.removable }
|
||||
val removable = roots.filter { it.removable }.sortedBy { it.label }
|
||||
return disambiguate(internal + removable)
|
||||
}
|
||||
|
||||
private fun Volume.toRoot(): StorageRoot {
|
||||
private fun Volume.toRoot(context: Context): StorageRoot {
|
||||
// Prefer a path we can actually list; a readable child beats an unreadable root.
|
||||
val dir = candidates.firstOrNull(::canBrowse)
|
||||
?: candidates.firstNotNullOfOrNull { root -> seeds.firstNotNullOfOrNull { deepestReadable(root, it) } }
|
||||
@@ -125,14 +126,14 @@ object StorageRoots {
|
||||
?: File("/storage/$key")
|
||||
|
||||
return StorageRoot(
|
||||
label = label ?: defaultLabel(),
|
||||
label = label ?: defaultLabel(context),
|
||||
dir = dir,
|
||||
removable = removable,
|
||||
readable = canBrowse(dir),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Volume.defaultLabel(): String = if (removable) "SD card" else key
|
||||
private fun Volume.defaultLabel(context: Context): String = if (removable) context.getString(R.string.fm_drive_sd) else key
|
||||
|
||||
/** Appends the volume id to any label used by more than one volume, so entries stay tellable apart. */
|
||||
private fun disambiguate(roots: List<StorageRoot>): List<StorageRoot> {
|
||||
@@ -183,12 +184,12 @@ object StorageRoots {
|
||||
* name like "android", which tells the user nothing about which card they are looking at.
|
||||
*/
|
||||
private fun labelFor(context: Context, volume: StorageVolume, uuid: String?): String {
|
||||
if (volume.isPrimary) return "Internal"
|
||||
if (volume.isRemovable) return "SD card"
|
||||
if (volume.isPrimary) return context.getString(R.string.fm_drive_internal)
|
||||
if (volume.isRemovable) return context.getString(R.string.fm_drive_sd)
|
||||
val description = volume.getDescription(context)?.trim()
|
||||
return description?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
|
||||
?: uuid
|
||||
?: "Storage"
|
||||
?: context.getString(R.string.fm_drive_storage)
|
||||
}
|
||||
|
||||
private fun isEmulated(dir: File): Boolean = dir.absolutePath.startsWith(EMULATED_PREFIX)
|
||||
|
||||
@@ -98,7 +98,7 @@ object AddedGames {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
val steamInstalls = steamInstallDirs(dir)
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
if (folder.name.lowercase() in steamInstalls) continue
|
||||
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
scanGame(context, folder, out)
|
||||
}
|
||||
}
|
||||
@@ -126,32 +126,59 @@ object AddedGames {
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
run {
|
||||
val candidates = candidates(folder)
|
||||
val chosen = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = chosen ?: candidates.firstOrNull() ?: return
|
||||
val guestExe = guestPath(context, exe)
|
||||
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val found = chosen ?: candidates.firstOrNull() ?: return
|
||||
val foundGuest = guestPath(context, found)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
|
||||
val name = folder.name
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
|
||||
val record = config?.let { shortcutRecord(it, appId) }
|
||||
val rev = record?.optInt("rev", 0) ?: 0
|
||||
val adopted = guestPath(context, folder)
|
||||
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
|
||||
val exe = adopted ?: found
|
||||
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val steamId = config?.let { steamRoute(it, appId) }
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
|
||||
val users = File(root, "config/loginusers.vdf").readText()
|
||||
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
|
||||
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
?: return@runCatching null
|
||||
val account = recent.groupValues[1].toLong() - 76561197960265728L
|
||||
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
|
||||
.optInt(appId.toString()).takeIf { it > 0 }
|
||||
private fun accountConfig(root: File): File? {
|
||||
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
|
||||
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
|
||||
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
val account = recent?.groupValues?.get(1)?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
|
||||
?: File(root, "userdata").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) && it != "0" }?.singleOrNull()
|
||||
return account?.let { File(root, "userdata/$it/config") }
|
||||
}
|
||||
|
||||
private fun steamRoute(config: File, appId: Long): Int? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
|
||||
}.getOrNull()
|
||||
|
||||
internal fun steamTarget(folder: File, folderGuest: String, record: JSONObject?, guestExe: String, seen: Int): File? {
|
||||
if (record == null || record.optInt("rev", 0) == seen) return null
|
||||
val asked = unquote(record.optJSONObject("app")?.optString("Exe"))
|
||||
val steam = unquote(record.optJSONObject("steam")?.optString("Exe"))
|
||||
if (asked != guestExe || steam.isEmpty() || steam == asked || !steam.startsWith("$folderGuest/")) return null
|
||||
val file = File(folder, steam.removePrefix("$folderGuest/"))
|
||||
val inside = runCatching { file.canonicalPath.startsWith(folder.canonicalPath + File.separator) }.getOrDefault(false)
|
||||
return file.takeIf { inside && it.isFile }
|
||||
}
|
||||
|
||||
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
@@ -161,6 +188,7 @@ object AddedGames {
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
val art = AddedGameArt.resolve(context, g)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -37,18 +38,18 @@ object GameFileSync {
|
||||
}
|
||||
|
||||
@Synchronized private fun sync(context: Context, folder: File) {
|
||||
check(LinuxRuntime.isInstalled(context)) { "Install the runtime first" }
|
||||
check(LinuxRuntime.isInstalled(context)) { context.getString(R.string.game_sync_err_runtime) }
|
||||
// A disconnected library must not be interpreted as a mass uninstall.
|
||||
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { "Steam library is unavailable" }
|
||||
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { context.getString(R.string.game_sync_err_steam_library) }
|
||||
val storage = GameStorage.effective(context)
|
||||
val storageSetting = SessionPrefs.gameStorage(context)
|
||||
val previousStorage = prefs(context).getString("storagePath", null)
|
||||
if (storageSetting.isEmpty() && prefs(context).getString("storageSetting", null) == storageSetting && previousStorage != null) {
|
||||
check(File(previousStorage).isDirectory) { "Previous SD library is unavailable" }
|
||||
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
|
||||
}
|
||||
storage?.let { check(File(it.path).listFiles() != null) { "Game storage is unavailable" } }
|
||||
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { "Added games folder is unavailable" } }
|
||||
GameFiles.sync(folder, Library.launchableGames(context, strictRead = true))
|
||||
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
|
||||
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
|
||||
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
|
||||
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AtomicFile
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.core.util.readText
|
||||
import androidx.core.util.writeBytes
|
||||
import androidx.core.util.writeText
|
||||
@@ -63,28 +64,28 @@ object GameFiles {
|
||||
return id
|
||||
}
|
||||
|
||||
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(folder, game, filename(game))
|
||||
@Synchronized fun export(context: Context, folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(context, folder, game, filename(game))
|
||||
|
||||
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
private fun exportNamed(context: Context, folder: File, game: Library.SteamGame, name: String): File {
|
||||
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
|
||||
val text = contents(game.gameIdString)
|
||||
val target = exportTarget(folder, game, name)
|
||||
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
|
||||
val target = exportTarget(context, folder, game, name)
|
||||
require(!target.exists() || matches(target, text)) { context.getString(R.string.gfiles_name_taken, target.name) }
|
||||
if (!target.exists()) write(target, text)
|
||||
exportArt(folder, game)
|
||||
exportArt(context, folder, game)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
|
||||
val target = child(folder, name)
|
||||
private fun exportTarget(context: Context, folder: File, game: Library.SteamGame, name: String): File {
|
||||
val target = child(context, folder, name)
|
||||
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
|
||||
child(folder, filename(game, disambiguate = true))
|
||||
child(context, folder, filename(game, disambiguate = true))
|
||||
} else target
|
||||
}
|
||||
|
||||
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
|
||||
private fun exportArt(folder: File, game: Library.SteamGame) {
|
||||
private fun exportArt(context: Context, folder: File, game: Library.SteamGame) {
|
||||
val art = listOfNotNull(game.icon, game.art).firstOrNull {
|
||||
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
|
||||
} ?: return
|
||||
@@ -93,19 +94,19 @@ object GameFiles {
|
||||
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
|
||||
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
|
||||
if (title.isEmpty()) return
|
||||
val images = child(folder, "images")
|
||||
check(images.isDirectory || images.mkdir()) { "Artwork folder is unavailable" }
|
||||
val target = child(images, "$title-icon.${art.extension.lowercase()}")
|
||||
val images = child(context, folder, "images")
|
||||
check(images.isDirectory || images.mkdir()) { context.getString(R.string.gfiles_art_unavailable) }
|
||||
val target = child(context, images, "$title-icon.${art.extension.lowercase()}")
|
||||
if (!target.exists()) AtomicFile(target).writeBytes(art.readBytes())
|
||||
}
|
||||
|
||||
/** Only remove files in our inventory whose content is still exactly what we wrote. */
|
||||
@Synchronized fun sync(folder: File, games: List<Library.SteamGame>) {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
val inventory = child(folder, INVENTORY)
|
||||
@Synchronized fun sync(context: Context, folder: File, games: List<Library.SteamGame>) {
|
||||
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
|
||||
val inventory = child(context, folder, INVENTORY)
|
||||
val previous = if (inventory.exists()) {
|
||||
val record = JSONObject(AtomicFile(inventory).readText())
|
||||
require(record.optString("format") == FORMAT) { "Folder has an unrelated sync inventory" }
|
||||
require(record.optString("format") == FORMAT) { context.getString(R.string.gfiles_unrelated_inventory) }
|
||||
record.getJSONObject("games")
|
||||
} else JSONObject()
|
||||
val next = JSONObject()
|
||||
@@ -113,15 +114,15 @@ object GameFiles {
|
||||
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
|
||||
val exports = unique.map { game ->
|
||||
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
|
||||
val preferred = child(folder, name)
|
||||
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
|
||||
else exportTarget(folder, game, name)
|
||||
val preferred = child(context, folder, name)
|
||||
val target = if (inventoryOwns(context, folder, previous, preferred, game.gameIdString)) preferred
|
||||
else exportTarget(context, folder, game, name)
|
||||
val text = contents(game.gameIdString)
|
||||
val existed = target.exists()
|
||||
// Keep an edited export without letting it block the rest of the library sync.
|
||||
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
|
||||
val owned = inventoryOwns(context, folder, previous, target, game.gameIdString)
|
||||
val file = if (owned && target.exists() &&
|
||||
!matches(target, text)) target else exportNamed(folder, game, target.name)
|
||||
!matches(target, text)) target else exportNamed(context, folder, game, target.name)
|
||||
if (!existed || owned) next.put(file.name, game.gameIdString)
|
||||
file
|
||||
}
|
||||
@@ -129,32 +130,32 @@ object GameFiles {
|
||||
for (name in previous.keys()) {
|
||||
val id = previous.getString(name)
|
||||
if (!next.has(name) && name.endsWith(".$EXTENSION") && GameLaunchLink.validId(id)) {
|
||||
val file = child(folder, name)
|
||||
val file = child(context, folder, name)
|
||||
if (matches(file, contents(id))) {
|
||||
// Android shared storage is often case insensitive. A case-only rename may
|
||||
// still resolve to the live export, which must not be pruned as an old file.
|
||||
val retained = exports.any { java.nio.file.Files.isSameFile(it.toPath(), file.toPath()) }
|
||||
if (!retained) check(file.delete()) { "Could not remove $name" }
|
||||
if (!retained) check(file.delete()) { context.getString(R.string.gfiles_remove_failed, name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
|
||||
}
|
||||
|
||||
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
|
||||
private fun inventoryOwns(context: Context, folder: File, record: JSONObject, file: File, id: String): Boolean {
|
||||
if (record.optString(file.name) == id) return true
|
||||
if (!file.exists()) return false
|
||||
return record.keys().asSequence().any { name ->
|
||||
record.optString(name) == id && child(folder, name).let { old ->
|
||||
record.optString(name) == id && child(context, folder, name).let { old ->
|
||||
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun child(folder: File, name: String): File {
|
||||
private fun child(context: Context, folder: File, name: String): File {
|
||||
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
|
||||
val file = File(folder, name)
|
||||
require(file.canonicalFile.parentFile == folder.canonicalFile) { "File leaves the export folder" }
|
||||
require(file.canonicalFile.parentFile == folder.canonicalFile) { context.getString(R.string.gfiles_outside_folder) }
|
||||
return file
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ object Library {
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
/** The shown name of [system] when it is words rather than a platform name, else 0. */
|
||||
val systemRes: Int get() = if (id == "retroarch") R.string.content_system_many else 0
|
||||
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
|
||||
val iconRes: Int get() = when (id) {
|
||||
"rpcs3" -> R.drawable.emu_rpcs3; "armsx2" -> R.drawable.emu_pcsx2; "dolphin" -> R.drawable.emu_dolphin
|
||||
@@ -84,17 +86,20 @@ object Library {
|
||||
return listOfNotNull(
|
||||
root to "internal",
|
||||
GameStorage.effective(context)?.let { File(it.path) to it.label },
|
||||
)
|
||||
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
|
||||
}
|
||||
|
||||
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
|
||||
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
|
||||
fun protonPrefix(
|
||||
context: Context, appId: Long, preferredLibrary: File? = null,
|
||||
libraries: List<File> = steamLibraries(context).map { it.first },
|
||||
): File? {
|
||||
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
|
||||
val secondary = GameStorage.effective(context)?.let { File(it.path) }
|
||||
fun prefixRoot(root: File): File = if (secondary != null && root.canonicalPath == secondary.canonicalPath)
|
||||
val secondary = libraries.drop(1).mapNotNull { runCatching { it.canonicalPath }.getOrNull() }.toSet()
|
||||
fun prefixRoot(root: File): File = if (runCatching { root.canonicalPath }.getOrNull() in secondary)
|
||||
SecondaryLibrary.privateRoot(context.filesDir, root).takeIf { File(it, "steamapps/compatdata").isDirectory } ?: root
|
||||
else root
|
||||
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first }).map(::prefixRoot)
|
||||
val roots = (listOfNotNull(preferredLibrary) + libraries).map(::prefixRoot)
|
||||
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
|
||||
return roots.asSequence()
|
||||
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
|
||||
@@ -105,11 +110,12 @@ object Library {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val libraries = steamLibraries(context)
|
||||
val roots = libraries.map { it.first }
|
||||
val out = LinkedHashMap<Int, SteamGame>()
|
||||
for ((library, label) in libraries) {
|
||||
val steamapps = File(library, "steamapps")
|
||||
val manifests = steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
|
||||
if (strictRead && steamapps.isDirectory) check(manifests != null) { "Steam manifests are unavailable" }
|
||||
if (strictRead && steamapps.isDirectory) check(manifests != null) { context.getString(R.string.game_sync_err_manifests) }
|
||||
manifests?.sortedBy { it.name }?.forEach { manifest ->
|
||||
val appId = manifest.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return@forEach
|
||||
if (appId in NOT_GAMES || out.containsKey(appId)) return@forEach
|
||||
@@ -130,7 +136,7 @@ object Library {
|
||||
out[appId] = SteamGame(
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library),
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
|
||||
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
|
||||
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
|
||||
)
|
||||
@@ -140,16 +146,19 @@ object Library {
|
||||
}
|
||||
|
||||
/** The same installed-game inventory used for links, shortcuts and file exports. */
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> = (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
|
||||
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, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
|
||||
val roots = steamLibraries(context).map { it.first }
|
||||
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
|
||||
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, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.gpu
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
@@ -84,7 +85,7 @@ object DriverBundle {
|
||||
unzip(context, uri, work)
|
||||
val manifest = File(work, MANIFEST).takeIf { it.isFile }
|
||||
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
|
||||
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
|
||||
?: throw IllegalArgumentException(context.getString(R.string.bundle_no_manifest))
|
||||
val androidDir = File(work, manifest.androidPath)
|
||||
val linuxDir = File(work, manifest.linuxPath)
|
||||
|
||||
@@ -135,7 +136,7 @@ object DriverBundle {
|
||||
for (entry in generateSequence { zip.nextEntry }) {
|
||||
if (entry.isDirectory) continue
|
||||
val out = File(root, entry.name).canonicalFile
|
||||
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
|
||||
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException(context.getString(R.string.bundle_entry_outside, entry.name))
|
||||
out.parentFile?.mkdirs()
|
||||
out.outputStream().use { sink ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
@@ -143,7 +144,7 @@ object DriverBundle {
|
||||
val r = zip.read(buf)
|
||||
if (r <= 0) break
|
||||
total += r
|
||||
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
|
||||
if (total > MAX_BYTES) throw IllegalArgumentException(context.getString(R.string.bundle_too_large, MAX_BYTES shr 20))
|
||||
sink.write(buf, 0, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
|
||||
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
|
||||
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
|
||||
* may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
* support level is what the app has actually been tested on (Adreno 650, 725 and newer) - an
|
||||
* Adreno 610 may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
*/
|
||||
data class GpuInfo(
|
||||
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
|
||||
@@ -33,7 +35,10 @@ data class GpuInfo(
|
||||
A6XX("Adreno 6xx"),
|
||||
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
|
||||
ADRENO_UNKNOWN("Adreno"),
|
||||
NOT_ADRENO("Not an Adreno GPU"),
|
||||
NOT_ADRENO("Not an Adreno GPU");
|
||||
|
||||
/** [label] in the app's language: the Adreno families are names, the rest is words. */
|
||||
fun label(context: Context): String = if (this == NOT_ADRENO) context.getString(R.string.gpuinfo_not_adreno) else label
|
||||
}
|
||||
|
||||
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
|
||||
@@ -41,21 +46,35 @@ data class GpuInfo(
|
||||
val support: Support
|
||||
get() = when {
|
||||
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
|
||||
family == Family.A6XX && model == 650 -> Support.TESTED
|
||||
family == Family.A8XX -> Support.TESTED
|
||||
family == Family.A7XX && model >= 725 -> Support.TESTED
|
||||
else -> Support.UNTESTED
|
||||
}
|
||||
|
||||
/** One line for the device card and the system check. */
|
||||
/** One line for the device card and the system check, in English for the device report. */
|
||||
val supportText: String
|
||||
get() = when (support) {
|
||||
Support.TESTED -> "Supported"
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
|
||||
else "Below tested hardware (Adreno 725 and newer): it may not run"
|
||||
else "Outside tested hardware (Adreno 650, 725 and newer): it may not run"
|
||||
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
|
||||
}
|
||||
|
||||
/** [supportText] in the app's language. */
|
||||
fun supportText(context: Context): String = context.getString(when (support) {
|
||||
Support.TESTED -> R.string.gpuinfo_supported
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) R.string.gpuinfo_experimental else R.string.gpuinfo_below_tested
|
||||
Support.UNSUPPORTED -> R.string.gpuinfo_unsupported
|
||||
})
|
||||
|
||||
/** [name] for the screen: the stand-in for a GPU the device does not name is in the app's language. */
|
||||
fun displayName(context: Context): String = if (name == UNNAMED) context.getString(R.string.gpu_this_gpu) else name
|
||||
|
||||
companion object {
|
||||
/** [name] when the device names no GPU; English, as the device report shows it. */
|
||||
private const val UNNAMED = "this GPU"
|
||||
|
||||
fun detect(): GpuInfo {
|
||||
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
|
||||
@@ -78,7 +97,7 @@ data class GpuInfo(
|
||||
val family = familyOf(adreno, model)
|
||||
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
return GpuInfo(
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { UNNAMED } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
model = model, family = family, soc = soc,
|
||||
oneUi8Gen2 = samsung && model == 740,
|
||||
kgslName = raw.orEmpty(), modelSource = source,
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.R;
|
||||
import com.droiddeck.launcher.core.FileUtils;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -181,20 +182,17 @@ public class LinuxVulkanDriverManager {
|
||||
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
|
||||
try {
|
||||
if (soName == null) {
|
||||
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
|
||||
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
|
||||
throw new IllegalArgumentException(context.getString(R.string.lvd_no_library));
|
||||
}
|
||||
File so = new File(tmpDir, LIB_NAME);
|
||||
if (!isAarch64Elf(so)) {
|
||||
throw new IllegalArgumentException(soName + " is not a 64-bit AArch64 ELF shared library.");
|
||||
throw new IllegalArgumentException(context.getString(R.string.lvd_not_aarch64, soName));
|
||||
}
|
||||
// The one check that actually separates this from the bionic builds. Both sonames
|
||||
// are in the driver's .dynstr, so a byte scan is enough and needs no ELF parsing:
|
||||
// glibc's is versioned ("libc.so.6"), bionic's is not ("libc.so").
|
||||
if (!containsAscii(so, "libc.so.6")) {
|
||||
throw new IllegalArgumentException(soName + " is not a glibc driver - it links Android's libc. "
|
||||
+ "The Linux runtime needs a \"-Linux\" zip; a plain or \"-Wayland\" Turnip cannot be "
|
||||
+ "loaded by the Steam client at all.");
|
||||
throw new IllegalArgumentException(context.getString(R.string.lvd_not_glibc, soName));
|
||||
}
|
||||
String kind = zipMeta != null ? zipMeta.optString("kind", "") : "";
|
||||
if (!kind.isEmpty() && !"linux-vulkan-icd".equals(kind)) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.abs
|
||||
@@ -82,11 +84,15 @@ data class ScreenEffects(
|
||||
* SessionPrefs.upscalerChoices mode the Look switches to.
|
||||
*/
|
||||
data class Look(
|
||||
/** The Look's id: what the row picks by and the compositor logs. */
|
||||
val name: String,
|
||||
val effects: ScreenEffects,
|
||||
val scalingMode: Int? = null,
|
||||
/** One line under the row while this Look is picked. */
|
||||
val desc: String,
|
||||
/** [name] and [desc] in the app's language, for the menu. */
|
||||
@StringRes val label: Int,
|
||||
@StringRes val descRes: Int,
|
||||
)
|
||||
|
||||
object ScreenEffectLooks {
|
||||
@@ -96,29 +102,29 @@ object ScreenEffectLooks {
|
||||
|
||||
/** Index 0 is the neutral state a fresh session matches, so the row always names something. */
|
||||
val LOOKS: List<Look> = listOf(
|
||||
Look("Off", ScreenEffects.OFF, desc = "Nothing applied."),
|
||||
Look("Off", ScreenEffects.OFF, desc = "Nothing applied.", label = R.string.frame_gen_off, descRes = R.string.look_off_desc),
|
||||
Look("Game Clarity", ScreenEffects(brightness = 2, contrast = 12, saturation = 108, cas = true, casLevel = 55),
|
||||
desc = "Sharpening with a touch of contrast - the everyday one."),
|
||||
desc = "Sharpening with a touch of contrast - the everyday one.", label = R.string.look_game_clarity, descRes = R.string.look_game_clarity_desc),
|
||||
Look("Vivid", ScreenEffects(brightness = 3, contrast = 10, gamma = 0.98f, saturation = 145, cas = true, casLevel = 30, deband = true),
|
||||
desc = "Colour pushed hard. Deband on so skies don't stripe."),
|
||||
desc = "Colour pushed hard. Deband on so skies don't stripe.", label = R.string.look_vivid, descRes = R.string.look_vivid_desc),
|
||||
Look("Cinematic", ScreenEffects(brightness = -4, contrast = 22, gamma = 1.10f, saturation = 88, cas = true, casLevel = 20, fxaa = true, deband = true),
|
||||
desc = "Deeper contrast, cooler colour, edges smoothed."),
|
||||
desc = "Deeper contrast, cooler colour, edges smoothed.", label = R.string.look_cinematic, descRes = R.string.look_cinematic_desc),
|
||||
Look("Competitive", ScreenEffects(brightness = 16, contrast = 18, gamma = 0.92f, saturation = 82, cas = true, casLevel = 75),
|
||||
desc = "Shadows lifted and heavy sharpening - spot people first."),
|
||||
desc = "Shadows lifted and heavy sharpening - spot people first.", label = R.string.look_competitive, descRes = R.string.look_competitive_desc),
|
||||
Look("Adaptive Sharpen", ScreenEffects(cas = true, casLevel = 85),
|
||||
desc = "Sharpening only, colour untouched."),
|
||||
desc = "Sharpening only, colour untouched.", label = R.string.look_adaptive_sharpen, descRes = R.string.look_adaptive_sharpen_desc),
|
||||
Look("Filmic", ScreenEffects(brightness = -2, contrast = 16, gamma = 1.14f, saturation = 92, cas = true, casLevel = 15, fxaa = true, deband = true),
|
||||
desc = "Film-like curve, slightly muted."),
|
||||
desc = "Film-like curve, slightly muted.", label = R.string.look_filmic, descRes = R.string.look_filmic_desc),
|
||||
Look("Arcade", ScreenEffects(brightness = 6, contrast = 20, gamma = 0.95f, saturation = 170, cas = true, casLevel = 45, deband = true),
|
||||
desc = "Loud and punchy."),
|
||||
desc = "Loud and punchy.", label = R.string.look_arcade, descRes = R.string.look_arcade_desc),
|
||||
Look("Retro CRT", ScreenEffects(brightness = 6, contrast = 14, gamma = 1.05f, saturation = 115, crt = true, ntsc = true),
|
||||
desc = "The CRT tube and analogue signal passes together."),
|
||||
desc = "The CRT tube and analogue signal passes together.", label = R.string.look_retro_crt, descRes = R.string.look_retro_crt_desc),
|
||||
Look("Upscale Sharp", ScreenEffects(contrast = 6, saturation = 104, cas = true, casLevel = 70), scalingMode = SCALING_FSR,
|
||||
desc = "For running below panel resolution."),
|
||||
desc = "For running below panel resolution.", label = R.string.look_upscale_sharp, descRes = R.string.look_upscale_sharp_desc),
|
||||
Look("Pixel Clean", ScreenEffects(contrast = 8, deband = true), scalingMode = SCALING_NEAREST,
|
||||
desc = "Crisp pixels for 2D and old titles."),
|
||||
desc = "Crisp pixels for 2D and old titles.", label = R.string.look_pixel_clean, descRes = R.string.look_pixel_clean_desc),
|
||||
Look("Anime Edge", ScreenEffects(brightness = 2, contrast = 10, saturation = 125, cas = true, casLevel = 50, toon = true, deband = true),
|
||||
desc = "Toon outlines plus sharpening."),
|
||||
desc = "Toon outlines plus sharpening.", label = R.string.look_anime_edge, descRes = R.string.look_anime_edge_desc),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri;
|
||||
import android.os.Environment;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.R;
|
||||
import com.droiddeck.launcher.core.FileUtils;
|
||||
import com.droiddeck.launcher.core.TarZst;
|
||||
import com.droiddeck.launcher.session.SessionPrefs;
|
||||
@@ -218,7 +219,7 @@ public final class TurnipDriver {
|
||||
*/
|
||||
String adopt(File tmpDir, String displayName) throws IOException {
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
if (rejected != null) throw new IllegalArgumentException(context.getString(R.string.turnip_not_adrenotools, rejected));
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
@@ -245,31 +246,31 @@ public final class TurnipDriver {
|
||||
* Why a just-extracted zip is not something adrenotools can be pointed at, or null when it is.
|
||||
* The kinds refused by name are the other driver lists' zips, which carry a meta.json too.
|
||||
*/
|
||||
private static String rejectionReason(File dir) {
|
||||
private String rejectionReason(File dir) {
|
||||
File metaFile = new File(dir, "meta.json");
|
||||
if (!metaFile.isFile()) return "no meta.json";
|
||||
if (!metaFile.isFile()) return context.getString(R.string.turnip_reject_no_meta);
|
||||
String libraryName, kind;
|
||||
try {
|
||||
JSONObject meta = new JSONObject(FileUtils.readString(metaFile));
|
||||
libraryName = meta.optString("libraryName", "");
|
||||
kind = meta.optString("kind", "");
|
||||
} catch (Exception e) {
|
||||
return "meta.json is unreadable (" + e.getMessage() + ")";
|
||||
return context.getString(R.string.turnip_reject_meta_unreadable, e.getMessage());
|
||||
}
|
||||
if ("linux-vulkan-icd".equals(kind))
|
||||
return "this is a Linux runtime driver (import it under \"Linux runtime driver\")";
|
||||
return context.getString(R.string.turnip_reject_linux_driver);
|
||||
if ("wayland-game-driver".equals(kind))
|
||||
return "this is a Wayland game driver, which this app has no use for";
|
||||
return context.getString(R.string.turnip_reject_wayland);
|
||||
if (libraryName.isEmpty())
|
||||
return "meta.json names no libraryName, so nothing could be handed to AdrenoTools";
|
||||
return context.getString(R.string.turnip_reject_no_library_name);
|
||||
File library = new File(dir, libraryName);
|
||||
if (!library.isFile())
|
||||
return "meta.json names " + libraryName + ", which is not in the zip";
|
||||
return context.getString(R.string.turnip_reject_library_missing, libraryName);
|
||||
if (!LinuxVulkanDriverManager.isAarch64Elf(library))
|
||||
return libraryName + " is not a 64-bit AArch64 ELF shared library";
|
||||
return context.getString(R.string.turnip_reject_not_aarch64, libraryName);
|
||||
// A glibc build that happens to carry a libraryName would load nothing in this process.
|
||||
if (LinuxVulkanDriverManager.containsAscii(library, "libc.so.6"))
|
||||
return libraryName + " links glibc - a \"-Linux\" Turnip, which belongs under \"Linux runtime driver\"";
|
||||
return context.getString(R.string.turnip_reject_glibc, libraryName);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.R
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONArray
|
||||
@@ -95,7 +96,7 @@ object TurnipReleases {
|
||||
var lastError: Exception? = null
|
||||
for (src in SOURCES) {
|
||||
try {
|
||||
val releases = JSONArray(get("https://api.github.com/repos/${src.repo}/releases?per_page=15"))
|
||||
val releases = JSONArray(get(context, "https://api.github.com/repos/${src.repo}/releases?per_page=15"))
|
||||
val seen = HashSet<String>()
|
||||
var newest: String? = null
|
||||
for (i in 0 until releases.length()) {
|
||||
@@ -135,7 +136,7 @@ object TurnipReleases {
|
||||
return parse(stored)
|
||||
}
|
||||
|
||||
private fun get(url: String): String {
|
||||
private fun get(context: Context, url: String): String {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 20_000
|
||||
@@ -143,8 +144,8 @@ object TurnipReleases {
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-app")
|
||||
try {
|
||||
val code = c.responseCode
|
||||
if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
|
||||
if (code != 200) throw IOException("GitHub answered HTTP $code")
|
||||
if (code == 403 || code == 429) throw IOException(context.getString(R.string.turnip_rate_limited))
|
||||
if (code != 200) throw IOException(context.getString(R.string.turnip_github_http, code))
|
||||
return c.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
c.disconnect()
|
||||
@@ -194,7 +195,7 @@ object TurnipReleases {
|
||||
|
||||
/** Download an asset into the cache; the caller imports it and deletes the file. */
|
||||
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
|
||||
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
|
||||
if (asset.sha256.isEmpty()) throw IOException(context.getString(R.string.turnip_list_predates_checksums))
|
||||
val target = File(context.cacheDir, asset.name)
|
||||
val c = URL(asset.url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
@@ -202,7 +203,7 @@ object TurnipReleases {
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-app")
|
||||
c.instanceFollowRedirects = true
|
||||
try {
|
||||
if (c.responseCode != 200) throw IOException("download answered HTTP ${c.responseCode}")
|
||||
if (c.responseCode != 200) throw IOException(context.getString(R.string.turnip_download_http, c.responseCode))
|
||||
val total = c.contentLengthLong.takeIf { it > 0 } ?: asset.size
|
||||
c.inputStream.use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
@@ -220,7 +221,7 @@ object TurnipReleases {
|
||||
}
|
||||
}
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
throw IOException("Checksum mismatch - the download was discarded")
|
||||
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
|
||||
}
|
||||
return target
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -24,11 +24,18 @@ 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?) {
|
||||
@@ -48,10 +55,10 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
|
||||
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
|
||||
) {
|
||||
Text("Drag to move", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
EditorButton("Reset", false) { controls.resetLayout() }
|
||||
EditorButton("Cancel", false) { finish() }
|
||||
EditorButton("Save", true) {
|
||||
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object ControllerPrefs {
|
||||
const val STEAM_BLUE = 0xFF1A9FFF.toInt()
|
||||
const val OFF = "off"
|
||||
|
||||
val tints = listOf(
|
||||
STEAM_BLUE to "Steam blue",
|
||||
0xFF66C0F4.toInt() to "Sky",
|
||||
0xFFE8EEF4.toInt() to "White",
|
||||
0xFFC77DFF.toInt() to "Violet",
|
||||
0xFF3DDC84.toInt() to "Green",
|
||||
0xFFFFA726.toInt() to "Amber",
|
||||
0xFFFF5252.toInt() to "Red",
|
||||
0xFFFF6FB5.toInt() to "Pink",
|
||||
STEAM_BLUE to R.string.ctrl_tint_steam_blue,
|
||||
0xFF66C0F4.toInt() to R.string.ctrl_tint_sky,
|
||||
0xFFE8EEF4.toInt() to R.string.ctrl_tint_white,
|
||||
0xFFC77DFF.toInt() to R.string.ctrl_tint_violet,
|
||||
0xFF3DDC84.toInt() to R.string.ctrl_tint_green,
|
||||
0xFFFFA726.toInt() to R.string.ctrl_tint_amber,
|
||||
0xFFFF5252.toInt() to R.string.ctrl_tint_red,
|
||||
0xFFFF6FB5.toInt() to R.string.ctrl_tint_pink,
|
||||
)
|
||||
|
||||
val opacities = listOf(40, 60, 80, 100)
|
||||
@@ -22,17 +23,17 @@ object ControllerPrefs {
|
||||
val sizes = listOf(80, 90, 100, 110, 125)
|
||||
|
||||
val mappable = listOf(
|
||||
"a" to "A button", "b" to "B button", "x" to "X button", "y" to "Y button",
|
||||
"lb" to "Left bumper", "rb" to "Right bumper", "lt" to "Left trigger", "rt" to "Right trigger",
|
||||
"select" to "View button", "start" to "Menu button",
|
||||
"a" to R.string.ctrl_button_a, "b" to R.string.ctrl_button_b, "x" to R.string.ctrl_button_x, "y" to R.string.ctrl_button_y,
|
||||
"lb" to R.string.ctrl_button_lb, "rb" to R.string.ctrl_button_rb, "lt" to R.string.ctrl_button_lt, "rt" to R.string.ctrl_button_rt,
|
||||
"select" to R.string.ctrl_button_view, "start" to R.string.ctrl_button_menu,
|
||||
)
|
||||
|
||||
val targets = listOf(
|
||||
"a" to "A", "b" to "B", "x" to "X", "y" to "Y",
|
||||
"lb" to "LB", "rb" to "RB", "lt" to "LT", "rt" to "RT",
|
||||
"l3" to "L3", "r3" to "R3", "select" to "View", "start" to "Menu", "guide" to "Steam",
|
||||
"up" to "D-pad up", "down" to "D-pad down", "left" to "D-pad left", "right" to "D-pad right",
|
||||
OFF to "Hidden",
|
||||
"a" to R.string.ctrl_target_a, "b" to R.string.ctrl_target_b, "x" to R.string.ctrl_target_x, "y" to R.string.ctrl_target_y,
|
||||
"lb" to R.string.ctrl_target_lb, "rb" to R.string.ctrl_target_rb, "lt" to R.string.ctrl_target_lt, "rt" to R.string.ctrl_target_rt,
|
||||
"l3" to R.string.ctrl_target_l3, "r3" to R.string.ctrl_target_r3, "select" to R.string.ctrl_target_view, "start" to R.string.ctrl_target_menu, "guide" to R.string.ctrl_target_steam,
|
||||
"up" to R.string.ctrl_target_up, "down" to R.string.ctrl_target_down, "left" to R.string.ctrl_target_left, "right" to R.string.ctrl_target_right,
|
||||
OFF to R.string.ctrl_target_hidden,
|
||||
)
|
||||
|
||||
class Settings(
|
||||
|
||||
@@ -2,14 +2,16 @@ package com.droiddeck.launcher.input
|
||||
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
/** The control surface shown on a secondary Android display during a Linux session. */
|
||||
enum class SecondScreenMode(val id: String, val label: String) {
|
||||
NONE("none", "None"),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
|
||||
TERMINAL("terminal", "Terminal"),
|
||||
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),
|
||||
TERMINAL("terminal", R.string.second_mode_terminal),
|
||||
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
|
||||
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
|
||||
}
|
||||
|
||||
data class SecondScreenDisplay(val id: Int, val label: String)
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.graphics.BitmapFactory
|
||||
import android.os.StatFs
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
@@ -27,6 +29,11 @@ object AppImageManager {
|
||||
val guestDir: String get() = "$GUEST_DIR/$id"
|
||||
}
|
||||
|
||||
/** Why a file cannot be imported: [text] with its [args]. */
|
||||
internal class Problem(@StringRes val text: Int, vararg val args: Any) {
|
||||
fun describe(context: Context): String = context.getString(text, *args)
|
||||
}
|
||||
|
||||
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
|
||||
|
||||
fun list(context: Context): List<Item> =
|
||||
@@ -44,22 +51,22 @@ object AppImageManager {
|
||||
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
|
||||
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
|
||||
*/
|
||||
internal fun problem(file: File): String? {
|
||||
if (!file.isFile) return "The file is gone"
|
||||
internal fun problem(file: File): Problem? {
|
||||
if (!file.isFile) return Problem(R.string.user_apps_file_gone)
|
||||
val head = ByteArray(20)
|
||||
try {
|
||||
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
|
||||
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return Problem(R.string.appimg_too_small) }
|
||||
} catch (e: Exception) {
|
||||
return "The file cannot be read: ${e.message}"
|
||||
return Problem(R.string.appimg_unreadable, e.message.toString())
|
||||
}
|
||||
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
|
||||
return "This is not an AppImage (it is not a Linux program)"
|
||||
return Problem(R.string.appimg_not_linux)
|
||||
}
|
||||
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
|
||||
return when {
|
||||
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> "This AppImage is built for neither ARM64 (aarch64) nor x86 PCs"
|
||||
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is a Linux program but not an AppImage"
|
||||
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
|
||||
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> Problem(R.string.appimg_wrong_arch)
|
||||
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> Problem(R.string.appimg_not_appimage)
|
||||
head[10].toInt() != 2 -> Problem(R.string.appimg_wrong_type, head[10].toInt())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -83,12 +90,12 @@ object AppImageManager {
|
||||
fun import(
|
||||
context: Context, file: File, name: String?, icon: File?, meta: Map<String, String> = emptyMap(), onProgress: (String) -> Unit,
|
||||
): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
problem(file)?.let { return it }
|
||||
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
|
||||
problem(file)?.let { return it.describe(context) }
|
||||
val base = root(context).apply { mkdirs() }
|
||||
// Room for the image while it is extracted, and the extracted tree (about the same again).
|
||||
if (StatFs(base.path).availableBytes < file.length() * 3) {
|
||||
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
|
||||
return context.getString(R.string.appimg_no_space_import, FileUtils.sizeToString(file.length() * 3))
|
||||
}
|
||||
val dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
|
||||
try {
|
||||
@@ -103,17 +110,17 @@ object AppImageManager {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "import ${file.name}", e)
|
||||
FileUtils.delete(dir)
|
||||
return e.message ?: "Import failed"
|
||||
return e.message ?: context.getString(R.string.appimg_import_failed)
|
||||
}
|
||||
}
|
||||
|
||||
/** Swaps the program of imported [id] for [file], keeping its name and icon; the old one stays if this fails. */
|
||||
fun replace(context: Context, id: String, file: File, meta: Map<String, String>, onProgress: (String) -> Unit): String? {
|
||||
problem(file)?.let { return it }
|
||||
problem(file)?.let { return it.describe(context) }
|
||||
val dir = dir(context, id)
|
||||
if (!File(dir, "app").isDirectory) return "That AppImage is gone"
|
||||
if (!File(dir, "app").isDirectory) return context.getString(R.string.appimg_gone)
|
||||
if (StatFs(dir.path).availableBytes < file.length() * 3) {
|
||||
return "Not enough free space: updating needs about ${FileUtils.sizeToString(file.length() * 3)}"
|
||||
return context.getString(R.string.appimg_no_space_update, FileUtils.sizeToString(file.length() * 3))
|
||||
}
|
||||
return try {
|
||||
extract(context, dir, file, onProgress) ?: run {
|
||||
@@ -123,7 +130,7 @@ object AppImageManager {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "replace $id", e)
|
||||
e.message ?: "Update failed"
|
||||
e.message ?: context.getString(R.string.appimg_update_failed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +142,10 @@ object AppImageManager {
|
||||
dir.mkdirs()
|
||||
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
|
||||
// maps a program executable.
|
||||
onProgress("Copying ${file.name}")
|
||||
onProgress(context.getString(R.string.user_apps_copying, file.name))
|
||||
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
|
||||
image.setExecutable(true, false)
|
||||
onProgress("Extracting ${file.name}")
|
||||
onProgress(context.getString(R.string.appimg_extracting, file.name))
|
||||
val unpack = if (LinuxFex.isX86(LinuxFex.elfMachine(image))) "${LinuxFex.TOOL} extract ./image.AppImage"
|
||||
else "./image.AppImage --appimage-extract || { rm -rf squashfs-root AppDir; ${LinuxFex.TOOL} extract ./image.AppImage; }"
|
||||
val out = StringBuilder()
|
||||
@@ -152,7 +159,8 @@ object AppImageManager {
|
||||
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
|
||||
if (status != 0 || !File(dir, "app").isDirectory) {
|
||||
listOf("squashfs-root", "AppDir").forEach { FileUtils.delete(File(dir, it)) }
|
||||
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
|
||||
return out.lines().lastOrNull { it.isNotBlank() }?.let { context.getString(R.string.appimg_extract_failed_detail, it) }
|
||||
?: context.getString(R.string.appimg_extract_failed)
|
||||
}
|
||||
copyLinkedFiles(File(dir, "app"), links)
|
||||
return null
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.core.ArchivePaths
|
||||
@@ -24,13 +25,17 @@ object DeckyManager {
|
||||
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
|
||||
private const val CEF_REMOTE_DEBUG_MARKER = "root/.local/share/Steam/.cef-enable-remote-debugging"
|
||||
|
||||
/** True while a plugin's remote binary downloads: what the UI branches on, not the stage's words. */
|
||||
@Volatile var downloadingBinary = false
|
||||
private set
|
||||
|
||||
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
|
||||
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
|
||||
|
||||
fun installed(context: Context): String? {
|
||||
val loader = loader(context)
|
||||
if (!loader.isFile || !loader.canExecute()) return null
|
||||
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: "Installed"
|
||||
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: context.getString(R.string.setup_installed)
|
||||
}
|
||||
|
||||
fun loader(context: Context) = File(LinuxRuntime.rootDir(context), PATH)
|
||||
@@ -102,39 +107,40 @@ object DeckyManager {
|
||||
|
||||
/** Downloads to a sibling temp file and atomically renames only after size, digest and ELF checks. */
|
||||
fun install(context: Context, release: Release, progress: (String, Int) -> Unit): String? {
|
||||
if (SessionState.running) return "Stop the active session before installing Decky Loader"
|
||||
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_loader)
|
||||
val target = loader(context)
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "PluginLoader.download")
|
||||
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress("Downloading ${release.tag}", if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
|
||||
if (!ok) { temp.delete(); return "Decky Loader download failed" }
|
||||
if (temp.length() != release.size) { temp.delete(); return "Decky Loader size did not match the release metadata" }
|
||||
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
|
||||
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
|
||||
if (!ok) { temp.delete(); return context.getString(R.string.deckymgr_download_failed) }
|
||||
if (temp.length() != release.size) { temp.delete(); return context.getString(R.string.deckymgr_size_mismatch) }
|
||||
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
|
||||
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return "Could not download the release checksum" }
|
||||
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
|
||||
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
|
||||
} ?: run { temp.delete(); return "Release checksum is missing" }
|
||||
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
|
||||
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
|
||||
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
|
||||
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
|
||||
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
|
||||
} ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_missing) }
|
||||
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return context.getString(R.string.deckymgr_checksum_invalid) }
|
||||
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return context.getString(R.string.deckymgr_checksum_mismatch) }
|
||||
if (!validElf(temp, release.machine)) { temp.delete(); return context.getString(R.string.deckymgr_not_elf) }
|
||||
if (SessionState.running) { temp.delete(); return context.getString(R.string.deckymgr_session_started_loader) }
|
||||
if (!temp.setExecutable(true, false)) { temp.delete(); return context.getString(R.string.deckymgr_loader_not_executable) }
|
||||
// Decky needs Steam's local CEF debugger only when its session supervisor is enabled.
|
||||
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
|
||||
val cefReady = !supervisorEnabled(context) || cefMarker.isFile || runCatching {
|
||||
cefMarker.parentFile?.mkdirs()
|
||||
cefMarker.createNewFile() || cefMarker.isFile
|
||||
}.getOrDefault(false)
|
||||
if (!cefReady) { temp.delete(); return "Could not enable Steam CEF remote debugging" }
|
||||
if (!cefReady) { temp.delete(); return context.getString(R.string.deckymgr_cef_failed) }
|
||||
val staged = File(target.parentFile, "PluginLoader.new")
|
||||
staged.delete()
|
||||
if (!temp.renameTo(staged)) { temp.delete(); return "Could not stage PluginLoader" }
|
||||
if (!temp.renameTo(staged)) { temp.delete(); return context.getString(R.string.deckymgr_stage_failed) }
|
||||
val old = File(target.parentFile, "PluginLoader.old")
|
||||
old.delete()
|
||||
if (target.exists() && !target.renameTo(old)) { staged.delete(); return "Could not preserve the existing PluginLoader" }
|
||||
if (target.exists() && !target.renameTo(old)) { staged.delete(); return context.getString(R.string.deckymgr_loader_preserve_failed) }
|
||||
if (!staged.renameTo(target)) {
|
||||
if (old.exists()) old.renameTo(target)
|
||||
staged.delete()
|
||||
return "Could not activate the new PluginLoader"
|
||||
return context.getString(R.string.deckymgr_loader_activate_failed)
|
||||
}
|
||||
old.delete()
|
||||
File(target.parentFile, ".droiddeck-decky-version").writeText(release.tag + "\n")
|
||||
@@ -145,43 +151,44 @@ object DeckyManager {
|
||||
|
||||
/** Imports a Decky distribution ZIP into homebrew/plugins for the next Steam session. */
|
||||
fun installPluginZip(context: Context, archive: File, progress: (String, Int) -> Unit): String? {
|
||||
if (SessionState.running) return "Stop the active session before installing a plugin"
|
||||
if (installed(context) == null) return "Install Decky Loader before importing a plugin"
|
||||
if (!archive.isFile || !archive.canRead()) return "Could not read the selected ZIP file"
|
||||
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_plugin)
|
||||
if (installed(context) == null) return context.getString(R.string.deckymgr_loader_required)
|
||||
if (!archive.isFile || !archive.canRead()) return context.getString(R.string.deckymgr_zip_unreadable)
|
||||
|
||||
val plugins = File(LinuxRuntime.rootDir(context), "root/homebrew/plugins")
|
||||
if (!plugins.exists() && !plugins.mkdirs()) return "Could not create the Decky plugins directory"
|
||||
if (!plugins.exists() && !plugins.mkdirs()) return context.getString(R.string.deckymgr_plugins_dir_failed)
|
||||
val staging = File(plugins, ".droiddeck-import-${UUID.randomUUID()}")
|
||||
val backupSuffix = ".droiddeck-backup-${UUID.randomUUID()}"
|
||||
try {
|
||||
staging.mkdirs()
|
||||
progress("Extracting plugin ZIP", -1)
|
||||
extractPluginZip(archive, staging)
|
||||
progress(context.getString(R.string.deckymgr_extracting_zip), -1)
|
||||
extractPluginZip(context, archive, staging)
|
||||
val roots = staging.listFiles()?.filter { it.isDirectory } ?: emptyList()
|
||||
if (roots.size != 1 || staging.listFiles()?.size != 1) return "Plugin ZIP must contain one top-level plugin folder"
|
||||
if (roots.size != 1 || staging.listFiles()?.size != 1) return context.getString(R.string.deckymgr_zip_one_folder)
|
||||
val plugin = roots.single()
|
||||
if (!File(plugin, "plugin.json").isFile || !File(plugin, "package.json").isFile || !File(plugin, "dist/index.js").isFile) {
|
||||
return "Plugin ZIP is missing plugin.json, package.json, or dist/index.js"
|
||||
return context.getString(R.string.deckymgr_zip_missing_files)
|
||||
}
|
||||
val name = JSONObject(File(plugin, "plugin.json").readText()).getString("name")
|
||||
require(name.isNotBlank()) { "Plugin name is missing" }
|
||||
require(name.isNotBlank()) { context.getString(R.string.deckymgr_plugin_name_missing) }
|
||||
val metadata = JSONObject(File(plugin, "package.json").readText())
|
||||
downloadRemoteBinaries(metadata, plugin, progress)
|
||||
if (SessionState.running) return "A session started during installation; stop it before installing a plugin"
|
||||
downloadRemoteBinaries(context, metadata, plugin, progress)
|
||||
if (SessionState.running) return context.getString(R.string.deckymgr_session_started_plugin)
|
||||
|
||||
DeckyPluginInstall.activate(plugin, plugins, name,
|
||||
File(plugins.parentFile, "settings/loader.json"), backupSuffix)
|
||||
progress("Plugin installed · restart Steam to load it", 100)
|
||||
progress(context.getString(R.string.deckymgr_plugin_installed), 100)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "plugin ZIP import", e)
|
||||
return e.message?.takeIf { it.isNotBlank() } ?: "Plugin ZIP installation failed"
|
||||
if (e is DeckyPluginInstall.Failure) return context.getString(e.text)
|
||||
return e.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.deckymgr_plugin_install_failed)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractPluginZip(archive: File, destination: File) {
|
||||
private fun extractPluginZip(context: Context, archive: File, destination: File) {
|
||||
var totalBytes = 0L
|
||||
var entries = 0
|
||||
val seen = HashSet<String>()
|
||||
@@ -189,30 +196,30 @@ object DeckyManager {
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
entries++
|
||||
require(entries <= 10_000) { "Plugin ZIP contains too many entries" }
|
||||
require(entries <= 10_000) { context.getString(R.string.deckymgr_zip_too_many) }
|
||||
val relative = entry.name.replace('\\', '/')
|
||||
require(relative.isNotBlank() && !relative.startsWith('/')) { "Plugin ZIP contains an invalid path" }
|
||||
require(seen.add(relative)) { "Plugin ZIP contains duplicate paths" }
|
||||
require(relative.isNotBlank() && !relative.startsWith('/')) { context.getString(R.string.deckymgr_zip_invalid_path) }
|
||||
require(seen.add(relative)) { context.getString(R.string.deckymgr_zip_duplicate_paths) }
|
||||
val output = ArchivePaths.inside(destination, relative)
|
||||
?: throw IllegalArgumentException("Plugin ZIP contains an unsafe path")
|
||||
?: throw IllegalArgumentException(context.getString(R.string.deckymgr_zip_unsafe_path))
|
||||
if (entry.isDirectory) {
|
||||
require(output.mkdirs() || output.isDirectory) { "Could not create plugin folder" }
|
||||
require(output.mkdirs() || output.isDirectory) { context.getString(R.string.deckymgr_folder_failed) }
|
||||
} else {
|
||||
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { "Could not create plugin folder" }
|
||||
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { context.getString(R.string.deckymgr_folder_failed) }
|
||||
FileOutputStream(output).use { out ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
totalBytes += read
|
||||
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { "Plugin ZIP expands beyond the 1 GiB limit" }
|
||||
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { context.getString(R.string.deckymgr_zip_too_large) }
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
// Decky distributions place plugin executables under bin/. Java's ZIP reader
|
||||
// does not expose Unix mode bits, so restore execution on those payload files.
|
||||
if (relative.split('/').dropLast(1).any { it == "bin" } && !output.setExecutable(true, false)) {
|
||||
throw IllegalStateException("Could not mark bundled plugin binary executable")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_bundled_not_executable))
|
||||
}
|
||||
}
|
||||
zip.closeEntry()
|
||||
@@ -220,42 +227,49 @@ object DeckyManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadRemoteBinaries(metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
|
||||
private fun downloadRemoteBinaries(context: Context, metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
|
||||
val binaries = metadata.optJSONArray("remote_binary") ?: return
|
||||
val bin = File(plugin, "bin")
|
||||
for (i in 0 until binaries.length()) {
|
||||
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException("Invalid remote_binary entry")
|
||||
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException(context.getString(R.string.deckymgr_remote_entry_invalid))
|
||||
val name = binary.optString("name")
|
||||
val url = binary.optString("url")
|
||||
val hash = binary.optString("sha256hash")
|
||||
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { "Invalid remote binary name" }
|
||||
require(url.startsWith("https://")) { "Remote binary URL must use HTTPS" }
|
||||
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { "Remote binary $name has an invalid SHA-256 hash" }
|
||||
require(bin.mkdirs() || bin.isDirectory) { "Could not create plugin bin directory" }
|
||||
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { context.getString(R.string.deckymgr_remote_name_invalid) }
|
||||
require(url.startsWith("https://")) { context.getString(R.string.deckymgr_remote_url_https) }
|
||||
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { context.getString(R.string.deckymgr_remote_hash_invalid, name) }
|
||||
require(bin.mkdirs() || bin.isDirectory) { context.getString(R.string.deckymgr_bin_dir_failed) }
|
||||
val temp = File(bin, ".$name.download")
|
||||
progress("Downloading plugin binary · $name", -1)
|
||||
if (!Downloader.downloadFile(url, temp, false) { fraction ->
|
||||
progress("Downloading plugin binary · $name", if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
|
||||
}) {
|
||||
val downloading = context.getString(R.string.deckymgr_downloading_binary, name)
|
||||
downloadingBinary = true
|
||||
progress(downloading, -1)
|
||||
val downloaded = try {
|
||||
Downloader.downloadFile(url, temp, false) { fraction ->
|
||||
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
|
||||
}
|
||||
} finally {
|
||||
downloadingBinary = false
|
||||
}
|
||||
if (!downloaded) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not download plugin binary $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_download_failed, name))
|
||||
}
|
||||
if (!hash.equals(Hashes.sha256(temp), ignoreCase = true)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Plugin binary checksum mismatch · $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_checksum_mismatch, name))
|
||||
}
|
||||
if (!temp.setExecutable(true, false)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not mark plugin binary executable · $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_not_executable, name))
|
||||
}
|
||||
val target = File(bin, name)
|
||||
if (target.exists() && !target.delete()) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not replace plugin binary · $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_replace_failed, name))
|
||||
}
|
||||
if (!temp.renameTo(target)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not install plugin binary · $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_install_failed, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Offline equivalent of Decky's identity-based replacement and loader settings update. */
|
||||
internal object DeckyPluginInstall {
|
||||
/** A failure the user is told about: [text] is its string resource. */
|
||||
class Failure(@StringRes val text: Int) : IllegalStateException()
|
||||
|
||||
fun activate(staged: File, plugins: File, name: String, settingsFile: File, backupSuffix: String) {
|
||||
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
|
||||
val existing = plugins.listFiles().orEmpty().filter { folder ->
|
||||
@@ -14,7 +19,7 @@ internal object DeckyPluginInstall {
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
val target = File(plugins, staged.name)
|
||||
require(!target.exists() || target in existing) { "Plugin folder belongs to another plugin" }
|
||||
if (target.exists() && target !in existing) throw Failure(R.string.deckymgr_folder_taken)
|
||||
val order = settings.optJSONArray("pluginOrder") ?: JSONArray(plugins.listFiles().orEmpty()
|
||||
.filter { it.isDirectory && !it.name.startsWith(".droiddeck-") }
|
||||
.mapNotNull { runCatching { JSONObject(File(it, "plugin.json").readText()).getString("name") }.getOrNull() }
|
||||
@@ -34,15 +39,15 @@ internal object DeckyPluginInstall {
|
||||
pending.writeText(settings.toString(4))
|
||||
for (old in existing) {
|
||||
val backup = File(plugins, ".droiddeck-backup-${old.name}$backupSuffix")
|
||||
check(old.renameTo(backup)) { "Could not preserve the existing plugin" }
|
||||
if (!old.renameTo(backup)) throw Failure(R.string.deckymgr_preserve_failed)
|
||||
backups.add(old to backup)
|
||||
}
|
||||
check(staged.renameTo(target)) { "Could not activate the imported plugin" }
|
||||
if (!staged.renameTo(target)) throw Failure(R.string.deckymgr_activate_failed)
|
||||
activated = true
|
||||
check(pending.renameTo(settingsFile)) { "Could not update Decky plugin settings" }
|
||||
if (!pending.renameTo(settingsFile)) throw Failure(R.string.deckymgr_settings_failed)
|
||||
} catch (error: Exception) {
|
||||
if (activated) target.deleteRecursively()
|
||||
for ((old, backup) in backups.asReversed()) check(backup.renameTo(old)) { "Could not restore the existing plugin" }
|
||||
for ((old, backup) in backups.asReversed()) if (!backup.renameTo(old)) throw Failure(R.string.deckymgr_restore_failed)
|
||||
throw error
|
||||
} finally {
|
||||
pending.delete()
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONObject
|
||||
@@ -81,20 +82,21 @@ object DesktopCatalog {
|
||||
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
|
||||
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (!root.isDirectory) return "The Linux runtime is not installed"
|
||||
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
|
||||
// Every catalog row carries a sha256; one without is refused rather than trusted.
|
||||
if (entry.sha256.isEmpty()) return "The catalog has no checksum for ${entry.name}"
|
||||
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
|
||||
val download = File(context.cacheDir, "pkg-${entry.id}.download")
|
||||
try {
|
||||
listener?.onProgress("Downloading ${entry.name}", 0)
|
||||
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
|
||||
val ok = Downloader.downloadFile(entry.url, download, true) { f ->
|
||||
listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f))
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
|
||||
}
|
||||
if (!ok) return "Download failed"
|
||||
listener?.onProgress("Verifying", -1)
|
||||
if (!ok) return context.getString(R.string.user_apps_download_failed)
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
|
||||
val actual = Hashes.sha256(download)
|
||||
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
|
||||
listener?.onProgress("Installing ${entry.name}", -1)
|
||||
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
|
||||
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
|
||||
when (entry.kind) {
|
||||
"appimage" -> {
|
||||
val dir = File(root, "opt/appimages").apply { mkdirs() }
|
||||
@@ -102,20 +104,20 @@ object DesktopCatalog {
|
||||
// Some projects zip the AppImage (melonDS); the one file inside is what we want.
|
||||
val placed = if (entry.url.endsWith(".zip", ignoreCase = true)) unzipAppImage(download, target)
|
||||
else download.renameTo(target)
|
||||
if (!placed) return "Could not place the AppImage"
|
||||
if (!placed) return context.getString(R.string.deskpkg_place_failed)
|
||||
target.setExecutable(true, false)
|
||||
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
|
||||
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
|
||||
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> if (!LinuxRuntimeInstaller.extract(download, root, listener)) return "Extraction failed"
|
||||
else -> if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
|
||||
}
|
||||
FileUtils.writeString(marker(context, entry.id), entry.version)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${entry.id}", e)
|
||||
return e.message ?: "Install failed"
|
||||
return e.message ?: context.getString(R.string.deskpkg_install_failed)
|
||||
} finally {
|
||||
download.delete()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -102,7 +103,7 @@ object FlatpakManager {
|
||||
|
||||
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
|
||||
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
|
||||
return exclusive("setup") {
|
||||
var failure: String? = null
|
||||
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
|
||||
@@ -114,11 +115,11 @@ object FlatpakManager {
|
||||
}
|
||||
when {
|
||||
failure != null -> failure
|
||||
status != 0 -> "Flatpak setup failed (exit $status)"
|
||||
!ready(context) -> "Flatpak setup did not finish"
|
||||
status != 0 -> context.getString(R.string.flatpakmgr_setup_failed, status)
|
||||
!ready(context) -> context.getString(R.string.flatpakmgr_setup_unfinished)
|
||||
else -> null
|
||||
}
|
||||
} ?: "Another store task is running"
|
||||
} ?: context.getString(R.string.flatpakmgr_busy)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,51 +127,61 @@ object FlatpakManager {
|
||||
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
|
||||
*/
|
||||
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!ready(context)) return "Set up Flatpak first"
|
||||
if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready)
|
||||
return exclusive("$verb:${id ?: "all"}") {
|
||||
var error: String? = null
|
||||
// Progress lines carry no ref; they belong to the step the last "op" line started.
|
||||
var step = "Starting"
|
||||
var step = context.getString(R.string.flatpakmgr_starting)
|
||||
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
|
||||
val status = runGuest(context, argv, fakeRoot = false) { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull()
|
||||
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
|
||||
when (o.optString("e")) {
|
||||
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
|
||||
"op" -> { step = stage(context, o); onProgress(step, overall(o, 0)) }
|
||||
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
|
||||
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
|
||||
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
|
||||
}
|
||||
}
|
||||
error ?: if (status != 0) "Flatpak exited with status $status" else null
|
||||
} ?: "Another store task is running"
|
||||
error ?: if (status != 0) context.getString(R.string.flatpakmgr_exit_status, status) else null
|
||||
} ?: context.getString(R.string.flatpakmgr_busy)
|
||||
}
|
||||
|
||||
private fun stage(o: JSONObject): String {
|
||||
private fun stage(context: Context, o: JSONObject): String {
|
||||
val ref = o.optString("ref")
|
||||
val name = refLabel(ref)
|
||||
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
|
||||
return when (o.optString("kind")) {
|
||||
"uninstall" -> "Removing $name$step"
|
||||
"update" -> "Updating $name$step"
|
||||
else -> "Installing $name$step"
|
||||
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
|
||||
if (ref.isEmpty()) return o.optString("status").ifEmpty { context.getString(R.string.setup_check_busy) }
|
||||
val name = refLabel(ref) { id, args -> context.getString(id, *args) }
|
||||
val n = o.optInt("n")
|
||||
val kind = o.optString("kind")
|
||||
return if (n > 1) context.getString(when (kind) {
|
||||
"uninstall" -> R.string.flatpakmgr_removing_step
|
||||
"update" -> R.string.flatpakmgr_updating_step
|
||||
else -> R.string.flatpakmgr_installing_step
|
||||
}, name, o.optInt("i"), n)
|
||||
else context.getString(when (kind) {
|
||||
"uninstall" -> R.string.flatpakmgr_removing
|
||||
"update" -> R.string.flatpakmgr_updating
|
||||
else -> R.string.flatpakmgr_installing
|
||||
}, name)
|
||||
}
|
||||
|
||||
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
|
||||
internal fun refLabel(ref: String): String {
|
||||
/**
|
||||
* A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". [text]
|
||||
* resolves a string resource with its arguments.
|
||||
*/
|
||||
internal fun refLabel(ref: String, text: (Int, Array<out Any>) -> String): String {
|
||||
val parts = ref.split('/')
|
||||
val id = parts.getOrNull(1) ?: return ref
|
||||
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
|
||||
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
|
||||
val branch = parts.getOrNull(3)
|
||||
return when {
|
||||
id.endsWith(".Locale") -> "translations"
|
||||
".GL." in id || ".GL32." in id -> "graphics drivers"
|
||||
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
|
||||
id.endsWith(".Locale") -> text(R.string.flatpakmgr_ref_translations, emptyArray())
|
||||
".GL." in id || ".GL32." in id -> text(R.string.flatpakmgr_ref_graphics_drivers, emptyArray())
|
||||
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> text(R.string.flatpakmgr_ref_media_codecs, emptyArray())
|
||||
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
|
||||
"gnome", "kde" -> vendor.uppercase()
|
||||
else -> vendor.replaceFirstChar { it.uppercase() }
|
||||
} + " runtime$branch"
|
||||
}.let { if (branch != null) text(R.string.flatpakmgr_ref_runtime_branch, arrayOf(it, branch)) else text(R.string.flatpakmgr_ref_runtime, arrayOf(it)) }
|
||||
else -> id.substringAfterLast('.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ public final class LinuxRuntime {
|
||||
* session ended at once. A long host path behind it is proot's to shorten.
|
||||
*/
|
||||
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
|
||||
private static final String SYSTEM_FONTS = "/system/fonts";
|
||||
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
@@ -207,6 +209,7 @@ public final class LinuxRuntime {
|
||||
File shm = new File(context.getCacheDir(), "shm");
|
||||
shm.mkdirs();
|
||||
bind(cmd, shm.getPath() + ":/dev/shm");
|
||||
bindSystemFonts(cmd, root);
|
||||
|
||||
// Android denies apps these; glibc, Steam and libcap read them at startup.
|
||||
File fakeProc = new File(root, "etc/droiddeck/proc");
|
||||
@@ -465,6 +468,15 @@ public final class LinuxRuntime {
|
||||
return byType;
|
||||
}
|
||||
|
||||
private static void bindSystemFonts(List<String> cmd, File root) {
|
||||
File fonts = new File(SYSTEM_FONTS);
|
||||
File target = new File(root, GUEST_SYSTEM_FONTS.substring(1));
|
||||
if (!fonts.isDirectory() || !fonts.canRead() || !new File(root, "usr/local").isDirectory()) return;
|
||||
if (target.isDirectory() || target.mkdirs()) {
|
||||
bind(cmd, SYSTEM_FONTS + ":" + GUEST_SYSTEM_FONTS);
|
||||
}
|
||||
}
|
||||
|
||||
private static void bind(List<String> cmd, String spec) {
|
||||
cmd.add("-b");
|
||||
cmd.add(spec);
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
|
||||
import com.droiddeck.launcher.R;
|
||||
import com.droiddeck.launcher.core.ArchivePaths;
|
||||
import com.droiddeck.launcher.core.Downloader;
|
||||
import com.droiddeck.launcher.core.Hashes;
|
||||
@@ -44,9 +45,17 @@ public final class LinuxRuntimeInstaller {
|
||||
|
||||
private static final String VERSION_FILE = ".version";
|
||||
|
||||
/** What a stage is doing, for code that branches on it rather than on its (translated) words. */
|
||||
public enum Step { DOWNLOADING, VERIFYING, OTHER }
|
||||
|
||||
public interface ProgressListener {
|
||||
/** {@code percent} is -1 while the size is unknown. */
|
||||
void onProgress(String stage, int percent);
|
||||
|
||||
/** The same with its {@link Step}; a listener that branches on the step overrides this one. */
|
||||
default void onProgress(Step step, String stage, int percent) {
|
||||
onProgress(stage, percent);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Release {
|
||||
@@ -71,11 +80,12 @@ public final class LinuxRuntimeInstaller {
|
||||
private static final class Job {
|
||||
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
|
||||
final CountDownLatch done = new CountDownLatch(1);
|
||||
volatile String stage = "Starting\u2026";
|
||||
volatile String stage;
|
||||
volatile Step step = Step.OTHER;
|
||||
volatile int percent = -1;
|
||||
volatile boolean ok;
|
||||
final boolean removal;
|
||||
Job(boolean removal) { this.removal = removal; }
|
||||
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
|
||||
}
|
||||
|
||||
private static final Object JOB_LOCK = new Object();
|
||||
@@ -131,16 +141,23 @@ public final class LinuxRuntimeInstaller {
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null && running.removal) return false;
|
||||
owner = running == null;
|
||||
if (owner) { running = new Job(false); removalError = null; }
|
||||
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
|
||||
job = running;
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
}
|
||||
if (!owner) return join(job, listener);
|
||||
try {
|
||||
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
|
||||
job.stage = stage;
|
||||
job.percent = percent;
|
||||
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
|
||||
job.ok = installOnce(context.getApplicationContext(), release, new ProgressListener() {
|
||||
@Override public void onProgress(String stage, int percent) {
|
||||
onProgress(Step.OTHER, stage, percent);
|
||||
}
|
||||
|
||||
@Override public void onProgress(Step step, String stage, int percent) {
|
||||
job.step = step;
|
||||
job.stage = stage;
|
||||
job.percent = percent;
|
||||
for (ProgressListener l : job.listeners) l.onProgress(step, stage, percent);
|
||||
}
|
||||
});
|
||||
return job.ok;
|
||||
} finally {
|
||||
@@ -192,7 +209,7 @@ public final class LinuxRuntimeInstaller {
|
||||
|
||||
private static boolean join(Job job, ProgressListener listener) {
|
||||
try {
|
||||
if (listener != null) listener.onProgress(job.stage, job.percent);
|
||||
if (listener != null) listener.onProgress(job.step, job.stage, job.percent);
|
||||
job.done.await();
|
||||
return job.ok;
|
||||
} catch (InterruptedException e) {
|
||||
@@ -209,13 +226,14 @@ public final class LinuxRuntimeInstaller {
|
||||
// A failed/interrupted explicit removal must finish before a fresh installation.
|
||||
// Its quarantined home is not user data to carry into an update.
|
||||
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
|
||||
if (pendingRemoval.exists() && listener != null) listener.onProgress("Removing runtime leftovers", -1);
|
||||
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
|
||||
RuntimeFileTree.delete(pendingRemoval, null);
|
||||
if (listener != null) listener.onProgress("Downloading", 0);
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
|
||||
if (listener != null) {
|
||||
listener.onProgress("Downloading",
|
||||
listener.onProgress(Step.DOWNLOADING, downloading,
|
||||
fraction < 0 ? -1 : Math.round(fraction * 100f));
|
||||
}
|
||||
});
|
||||
@@ -224,7 +242,7 @@ public final class LinuxRuntimeInstaller {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listener != null) listener.onProgress("Verifying", -1);
|
||||
if (listener != null) listener.onProgress(Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1);
|
||||
String actual = Hashes.sha256(archive);
|
||||
if (!release.sha256.equalsIgnoreCase(actual)) {
|
||||
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
|
||||
@@ -239,8 +257,8 @@ public final class LinuxRuntimeInstaller {
|
||||
recoverInterruptedSwap(root, staging, old);
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
if (!staging.mkdirs()) return false;
|
||||
if (listener != null) listener.onProgress("Extracting", -1);
|
||||
if (!extract(archive, staging, listener)) {
|
||||
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
|
||||
if (!extract(context, archive, staging, listener)) {
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
return false;
|
||||
}
|
||||
@@ -307,30 +325,35 @@ public final class LinuxRuntimeInstaller {
|
||||
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
|
||||
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
|
||||
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
|
||||
return beginUninstall(LinuxRuntime.rootDir(context));
|
||||
return beginUninstall(context.getApplicationContext(), LinuxRuntime.rootDir(context));
|
||||
}
|
||||
|
||||
static Removal beginUninstall(File root) {
|
||||
/** {@code app} words the progress; unit tests pass null and get none. */
|
||||
static Removal beginUninstall(Context app, File root) {
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null) return null;
|
||||
removalError = null;
|
||||
running = new Job(true);
|
||||
running.stage = "Removing Linux runtime";
|
||||
return new Removal(root, running);
|
||||
running = new Job(true, text(app, R.string.rtinst_removing));
|
||||
return new Removal(app, root, running);
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(Context app, int id) {
|
||||
return app == null ? "" : app.getString(id);
|
||||
}
|
||||
|
||||
public static final class Removal {
|
||||
private final Context app;
|
||||
private final File root;
|
||||
private final Job job;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private Removal(File root, Job job) { this.root = root; this.job = job; }
|
||||
private Removal(Context app, File root, Job job) { this.app = app; this.root = root; this.job = job; }
|
||||
|
||||
public boolean run(ProgressListener listener) {
|
||||
if (!started.compareAndSet(false, true)) return join(job, listener);
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
try {
|
||||
report("Removing Linux runtime", -1);
|
||||
report(text(app, R.string.rtinst_removing), -1);
|
||||
final long[] removed = {0};
|
||||
final long[] lastShown = {0};
|
||||
java.util.function.LongConsumer progress = ignored -> {
|
||||
@@ -338,7 +361,8 @@ public final class LinuxRuntimeInstaller {
|
||||
long now = System.nanoTime();
|
||||
if (removed[0] == 1 || now - lastShown[0] >= 200_000_000L) {
|
||||
lastShown[0] = now;
|
||||
report("Removing Linux runtime · " + removed[0] + " entries", -1);
|
||||
report(app == null ? "" : app.getResources().getQuantityString(
|
||||
R.plurals.rtinst_removing_entries, (int) Math.min(removed[0], Integer.MAX_VALUE), removed[0]), -1);
|
||||
}
|
||||
};
|
||||
File pending = removalDirectory(root);
|
||||
@@ -355,10 +379,10 @@ public final class LinuxRuntimeInstaller {
|
||||
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
|
||||
RuntimeFileTree.delete(pending, progress);
|
||||
job.ok = true;
|
||||
report("Linux runtime removed", 100);
|
||||
report(text(app, R.string.rtinst_removed), 100);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
removalError = "Could not finish removing the Linux runtime. Retry removal in Setup.";
|
||||
removalError = text(app, R.string.rtinst_removal_failed);
|
||||
Log.w(TAG, "runtime removal incomplete", e);
|
||||
report(removalError, -1);
|
||||
return false;
|
||||
@@ -380,8 +404,9 @@ public final class LinuxRuntimeInstaller {
|
||||
* empty file instead of its link target is a rootfs that boots to nothing. Symlinks, hard
|
||||
* links and the executable bit are all carried over here.
|
||||
*/
|
||||
public static boolean extract(File archive, File destination, ProgressListener listener) {
|
||||
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
|
||||
long entries = 0;
|
||||
String extracting = context.getString(R.string.rtinst_extracting);
|
||||
try (InputStream in = new ZstdCompressorInputStream(
|
||||
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
|
||||
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
|
||||
@@ -432,7 +457,7 @@ public final class LinuxRuntimeInstaller {
|
||||
continue; // device nodes and fifos: the runtime binds the real ones
|
||||
}
|
||||
if (++entries % 2000 == 0 && listener != null) {
|
||||
listener.onProgress("Extracting", -1);
|
||||
listener.onProgress(extracting, -1);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -22,12 +22,15 @@ import com.droiddeck.launcher.R
|
||||
* two gets there first.
|
||||
*/
|
||||
class RuntimeInstallService : Service() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val removal = intent?.action == ACTION_REMOVE
|
||||
val release = intent?.let(::releaseFrom)
|
||||
startForeground(NOTIFICATION_ID, notification(if (removal) "Removing Linux runtime" else "Starting…", -1))
|
||||
startForeground(NOTIFICATION_ID, notification(getString(if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
|
||||
if (release == null && !removal) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
@@ -53,9 +56,9 @@ class RuntimeInstallService : Service() {
|
||||
|
||||
private fun notification(stage: String, percent: Int): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Linux runtime",
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
|
||||
NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Shows while the Linux runtime is installed or removed"
|
||||
description = getString(R.string.rtinst_channel_description)
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
@@ -65,7 +68,7 @@ class RuntimeInstallService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(if (LinuxRuntimeInstaller.isRemoving()) "Removing the Linux runtime" else "Installing the Linux runtime")
|
||||
.setContentTitle(getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
|
||||
.setContentText(stage)
|
||||
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
|
||||
.setContentIntent(open)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.R
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
@@ -248,12 +249,13 @@ object ComponentsManager {
|
||||
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
|
||||
}.getOrDefault("")
|
||||
|
||||
private fun fexVersion(dir: File): String {
|
||||
/** The FEX build's version name; [unnamed] when its DLL carries none, "" when there is no FEX. */
|
||||
private fun fexVersion(dir: File, unnamed: String = "FEX (build without a version name)"): String {
|
||||
val dll = File(dir, FEX_FILES[0])
|
||||
if (!dll.isFile) return ""
|
||||
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
|
||||
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
|
||||
?: "FEX (build without a version name)"
|
||||
?: unnamed
|
||||
}
|
||||
|
||||
private fun compFiles(dir: File, comp: String): List<String> {
|
||||
@@ -274,23 +276,24 @@ object ComponentsManager {
|
||||
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
|
||||
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
|
||||
|
||||
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
|
||||
private fun readProfile(context: Context, f: File): JSONObject = readWcp(f) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
|
||||
}
|
||||
throw IllegalArgumentException("no profile.json in ${f.name}")
|
||||
throw IllegalArgumentException(context.getString(R.string.cmgr_no_profile, f.name))
|
||||
}
|
||||
|
||||
private fun packageInfo(f: File): Package {
|
||||
val prof = readProfile(f)
|
||||
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
|
||||
private fun packageInfo(context: Context, f: File): Package {
|
||||
val prof = readProfile(context, f)
|
||||
val comp = TYPES[prof.optString("type")]
|
||||
?: throw IllegalArgumentException(context.getString(R.string.cmgr_not_component_package, f.name, prof.optString("type")))
|
||||
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
|
||||
}
|
||||
|
||||
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
|
||||
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
|
||||
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
|
||||
?.mapNotNull { f -> runCatching { packageInfo(context, f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
|
||||
?: emptyList()
|
||||
|
||||
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
|
||||
@@ -363,7 +366,7 @@ object ComponentsManager {
|
||||
return versions.flatMap { v ->
|
||||
COMPONENTS.mapNotNull { comp ->
|
||||
val f = File(v, "$comp.wcp")
|
||||
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
|
||||
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(context, f).optString("description") }.getOrDefault(""), f.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,24 +418,24 @@ object ComponentsManager {
|
||||
val syncPacks = EsyncPacks.status(root(context))
|
||||
val views = protons(context).map { p ->
|
||||
val comps = COMPONENTS.associateWith { comp ->
|
||||
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
|
||||
val detected = if (comp == "fex") fexVersion(p.dir, context.getString(R.string.cmgr_fex_unnamed)) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
val detail = if (comp == "fex") context.getString(if (File(p.dir, FEX_FILES[2]).isFile) R.string.cmgr_fex_linux_helpers else R.string.cmgr_fex_dlls_only) else ""
|
||||
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
val inUseLabel = when {
|
||||
active == null -> "Original"
|
||||
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
|
||||
active == null -> context.getString(R.string.comp_tag_original)
|
||||
active.optString("protonVersion") != p.version -> context.getString(R.string.cmgr_original_proton_updated)
|
||||
else -> {
|
||||
val want = active.optJSONObject("files") ?: JSONObject()
|
||||
val fp = fingerprint(p.dir, comp)
|
||||
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
|
||||
else "Changed outside Components"
|
||||
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) originalLabel(context, active) ?: active.optString("label", active.optString("file"))
|
||||
else context.getString(R.string.cmgr_changed_outside)
|
||||
}
|
||||
}
|
||||
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
Component(
|
||||
detected.ifEmpty { "not present" }, detail, inUseLabel,
|
||||
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel,
|
||||
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
|
||||
q?.optString("label"),
|
||||
q?.let { originalLabel(context, it) ?: it.optString("label") },
|
||||
)
|
||||
}
|
||||
ProtonView(p, comps, originals(context, p.id), gameRunning(context, p), reapplied[p.dir.name] ?: 0, syncPacks.tool(p.guestPath))
|
||||
@@ -440,6 +443,16 @@ object ComponentsManager {
|
||||
Snapshot(views, packages(context))
|
||||
}
|
||||
|
||||
/**
|
||||
* The label of a chosen or queued original, in the app's language: the stored "label" was written
|
||||
* in English when it was chosen. Null for a package, whose label is its version.
|
||||
*/
|
||||
private fun originalLabel(context: Context, entry: JSONObject): String? = when {
|
||||
entry.optString("kind") == "original" -> context.getString(R.string.cmgr_original_version, entry.optString("protonVersion"))
|
||||
entry.optString("file").startsWith("original ") -> context.getString(R.string.cmgr_original_from, entry.optString("file").removePrefix("original "))
|
||||
else -> null
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ launch-time enforcement
|
||||
|
||||
/**
|
||||
@@ -508,17 +521,17 @@ object ComponentsManager {
|
||||
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
val wcp = File(packagesDir(context), safeName(file))
|
||||
val info = packageInfo(wcp)
|
||||
val info = packageInfo(context, wcp)
|
||||
val state = loadState(context)
|
||||
if (inUse(context, p)) {
|
||||
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
|
||||
saveState(context, state)
|
||||
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
|
||||
return context.getString(R.string.cmgr_swap_queued, LABEL[info.comp], info.version, p.name)
|
||||
}
|
||||
applyPackage(context, p, info, wcp, state)
|
||||
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
|
||||
saveState(context, state)
|
||||
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
|
||||
context.getString(R.string.cmgr_swap_done, LABEL[info.comp], info.version, p.name)
|
||||
}
|
||||
|
||||
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
|
||||
@@ -534,17 +547,17 @@ object ComponentsManager {
|
||||
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
|
||||
check(wcp.isFile) { "That original bundle is not stored" }
|
||||
check(wcp.isFile) { context.getString(R.string.cmgr_original_missing) }
|
||||
val state = loadState(context)
|
||||
if (inUse(context, p)) {
|
||||
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
|
||||
saveState(context, state)
|
||||
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
|
||||
return context.getString(R.string.cmgr_restore_queued, LABEL[comp], p.name)
|
||||
}
|
||||
applyOriginal(context, p, comp, protonVersion, wcp, state)
|
||||
state.sub("queued").optJSONObject(p.id)?.remove(comp)
|
||||
saveState(context, state)
|
||||
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
|
||||
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
|
||||
}
|
||||
|
||||
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
|
||||
@@ -576,7 +589,7 @@ object ComponentsManager {
|
||||
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
|
||||
} else {
|
||||
val wcp = File(packagesDir(context), q.optString("file"))
|
||||
applyPackage(context, p, packageInfo(wcp), wcp, state)
|
||||
applyPackage(context, p, packageInfo(context, wcp), wcp, state)
|
||||
}
|
||||
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
|
||||
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
|
||||
@@ -600,27 +613,27 @@ object ComponentsManager {
|
||||
val active = state.sub("active")
|
||||
for (pid in active.keys()) {
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { "$name is in use in $pid. Swap that Proton to something else first." }
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
|
||||
}
|
||||
val f = File(packagesDir(context), name)
|
||||
check(f.isFile) { "$name is not stored" }
|
||||
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
|
||||
f.delete()
|
||||
"Deleted $name."
|
||||
context.getString(R.string.cmgr_deleted, name)
|
||||
}
|
||||
|
||||
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
|
||||
check(safeName(p.version) != protonVersion) { context.getString(R.string.cmgr_original_kept) }
|
||||
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
|
||||
check(f.isFile) { "Not stored" }
|
||||
check(f.isFile) { context.getString(R.string.cmgr_not_stored) }
|
||||
f.delete()
|
||||
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
|
||||
"Deleted the $protonVersion original of ${LABEL[comp]}."
|
||||
context.getString(R.string.cmgr_deleted_original, protonVersion, LABEL[comp])
|
||||
}
|
||||
|
||||
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
|
||||
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
|
||||
val info = packageInfo(source)
|
||||
val info = packageInfo(context, source)
|
||||
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
|
||||
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
|
||||
info.copy(file = dest.name)
|
||||
@@ -742,7 +755,7 @@ object ComponentsManager {
|
||||
fetch: (CatalogItem) -> Unit,
|
||||
): String? {
|
||||
val wcp = File(packagesDir(context), safeName(rule.file))
|
||||
if (wcp.isFile && runCatching { packageInfo(wcp) }.isSuccess) return null
|
||||
if (wcp.isFile && runCatching { packageInfo(context, wcp) }.isSuccess) return null
|
||||
wcp.delete()
|
||||
val item = lookup(rule.file, rule.release)
|
||||
?: return "${rule.file} is not in the Nightlies listing; ${LABEL.getValue(rule.comp)} stays on the Proton's copy"
|
||||
@@ -764,14 +777,14 @@ object ComponentsManager {
|
||||
|
||||
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
|
||||
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
|
||||
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
|
||||
require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" }
|
||||
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { context.getString(R.string.cmgr_nightlies_only) }
|
||||
require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) }
|
||||
val dest = File(packagesDir(context), safeName(item.file))
|
||||
val part = File(dest.parentFile, dest.name + ".part")
|
||||
part.delete()
|
||||
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
|
||||
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
|
||||
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
|
||||
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
|
||||
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
|
||||
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
|
||||
synchronized(lock) { part.renameTo(dest) }
|
||||
return info.copy(file = dest.name)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ object EsyncPacks {
|
||||
private const val TOOLS = "tools.tsv"
|
||||
private const val FLOOR = "floor"
|
||||
private const val PACK_FAILURES = "packFailures"
|
||||
private const val PROGRESS_LABEL = "Fetching droiddeck-esync pack"
|
||||
private const val INDEX_LIMIT = 4L shl 20
|
||||
private const val SIG_LIMIT = 4096L
|
||||
private const val ASSET_LIMIT = 256L shl 20
|
||||
@@ -618,14 +617,15 @@ object EsyncPacks {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
onProgress?.invoke(PROGRESS_LABEL, -1)
|
||||
val label = context.getString(com.droiddeck.launcher.R.string.esync_fetching)
|
||||
onProgress?.invoke(label, -1)
|
||||
var reported = -1
|
||||
val fetched = FileOutputStream(part).use { output ->
|
||||
fetch(entry.asset.url, entry.asset.size, output) { done ->
|
||||
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
|
||||
if (percent != reported) {
|
||||
reported = percent
|
||||
onProgress?.invoke(PROGRESS_LABEL, percent)
|
||||
onProgress?.invoke(label, percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
@@ -25,9 +27,12 @@ import java.util.zip.ZipOutputStream
|
||||
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
|
||||
*/
|
||||
object GameSaves {
|
||||
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
|
||||
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
|
||||
WINLATOR("Winlator zip", "drive_c/", "xuser"),
|
||||
enum class Layout(val app: String, val rootPrefix: String, val user: String) {
|
||||
GAMEHUB("GameHub", "/drive_c/", "steamuser"),
|
||||
WINLATOR("Winlator", "drive_c/", "xuser");
|
||||
|
||||
/** "GameHub zip", in the app's language. */
|
||||
fun label(context: Context): String = context.getString(R.string.saves_layout_zip, app)
|
||||
}
|
||||
|
||||
/** A game, its prefix, and the save folders found in it. */
|
||||
@@ -127,12 +132,12 @@ object GameSaves {
|
||||
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
|
||||
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
|
||||
*/
|
||||
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
|
||||
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
|
||||
fun export(context: Context, game: Game, layout: Layout, dir: File): Pair<File, Int> {
|
||||
val prefix = game.prefix ?: error(context.getString(R.string.saves_not_launched, game.game.name))
|
||||
dir.mkdirs()
|
||||
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
|
||||
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
|
||||
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
|
||||
if (count == 0) { out.delete(); error(context.getString(R.string.saves_none_found, game.game.name)) }
|
||||
return out to count
|
||||
}
|
||||
|
||||
@@ -162,11 +167,11 @@ object GameSaves {
|
||||
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
|
||||
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
|
||||
*/
|
||||
fun import(game: Game, zip: File): Pair<Int, File?> {
|
||||
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
|
||||
fun import(context: Context, game: Game, zip: File): Pair<Int, File?> {
|
||||
val prefix = game.prefix ?: error(context.getString(R.string.saves_not_launched, game.game.name))
|
||||
val driveC = File(prefix, "drive_c")
|
||||
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
|
||||
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
|
||||
if (!driveC.isDirectory) error(context.getString(R.string.saves_not_launched, game.game.name))
|
||||
if (!looksLikeSaveZip(zip)) error(context.getString(R.string.saves_not_save_zip, zip.name))
|
||||
|
||||
backupsDir().mkdirs()
|
||||
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
|
||||
|
||||
@@ -4,7 +4,9 @@ import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import android.os.storage.StorageManager
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -17,7 +19,8 @@ import java.io.File
|
||||
* slowly (intro movies, big asset loads); internal stays the default and the dialog says so.
|
||||
*/
|
||||
object GameStorage {
|
||||
class Option(val label: String, val path: String)
|
||||
/** A library root: [label] as the menu lists it (a volume with its free space), [name] alone. */
|
||||
class Option(val label: String, val path: String, val name: String = label)
|
||||
|
||||
/** Every removable volume, as its app folder, with the free space it has. */
|
||||
fun options(context: Context): List<Option> {
|
||||
@@ -26,8 +29,8 @@ object GameStorage {
|
||||
val removable = try { Environment.isExternalStorageRemovable(dir) } catch (e: Exception) { false }
|
||||
if (!removable) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null }
|
||||
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: "SD card"
|
||||
Option("$name · ${free(dir)} free", File(dir, "steam").absolutePath)
|
||||
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: context.getString(R.string.gstore_sd_card)
|
||||
Option(context.getString(R.string.gstore_volume_free, name, free(dir)), File(dir, "steam").absolutePath, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,15 +44,15 @@ object GameStorage {
|
||||
* Makes [path] a library root the session can bind - the folder and its steamapps/ - and proves
|
||||
* it writable. Returns why it cannot be used, or null when it can.
|
||||
*/
|
||||
fun prepare(path: String): String? {
|
||||
fun prepare(context: Context, path: String): String? {
|
||||
val root = File(path)
|
||||
val steamapps = File(root, "steamapps")
|
||||
if (!steamapps.isDirectory && !steamapps.mkdirs()) return "cannot create folders in $path"
|
||||
if (!steamapps.isDirectory && !steamapps.mkdirs()) return context.getString(R.string.gstore_cannot_create, path)
|
||||
val probe = File(steamapps, ".writable")
|
||||
return try {
|
||||
if (!probe.createNewFile() && !probe.isFile) "cannot write in $path" else { probe.delete(); null }
|
||||
if (!probe.createNewFile() && !probe.isFile) context.getString(R.string.gstore_cannot_write, path) else { probe.delete(); null }
|
||||
} catch (e: Exception) {
|
||||
"cannot write in $path (${e.message})"
|
||||
context.getString(R.string.gstore_cannot_write_detail, path, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +61,32 @@ object GameStorage {
|
||||
val pref = SessionPrefs.gameStorage(context)
|
||||
return when {
|
||||
pref == SessionPrefs.GAME_STORAGE_OFF -> null
|
||||
pref.isEmpty() -> options(context).firstOrNull()?.let { Option(it.label.substringBefore(" ·"), it.path) }
|
||||
pref.isEmpty() -> options(context).firstOrNull()?.let { Option(it.name, it.path) }
|
||||
else -> Option(SessionPrefs.gameStorageLabel(context), pref)
|
||||
}
|
||||
}
|
||||
|
||||
class Library(val host: File, val guest: String, val label: String)
|
||||
|
||||
fun gamesFolderLibraries(context: Context): List<Library> {
|
||||
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
|
||||
val card = effective(context)?.let { canonical(File(it.path)) }
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
return AddedGames.roots(context).mapNotNull { root ->
|
||||
val host = canonical(root.host)
|
||||
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
|
||||
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
|
||||
val name = root.host.name.ifEmpty { context.getString(R.string.gstore_folder) }
|
||||
val label = if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume)
|
||||
Library(root.host, root.guest, label.replace('"', ' ').replace('\\', ' '))
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
|
||||
|
||||
/** The label the client shows for a chosen folder: its last name, or the volume's. */
|
||||
fun labelFor(context: Context, path: String): String =
|
||||
options(context).firstOrNull { it.path == path }?.label?.substringBefore(" ·")
|
||||
?: File(path).name.ifEmpty { "Folder" }
|
||||
options(context).firstOrNull { it.path == path }?.name
|
||||
?: File(path).name.ifEmpty { context.getString(R.string.gstore_folder) }
|
||||
}
|
||||
@@ -17,9 +17,16 @@ import java.nio.charset.StandardCharsets
|
||||
* progress, and lifting that out is the difference between "starting the Steam client" for three
|
||||
* minutes and a percentage that moves.
|
||||
*/
|
||||
class LoadingState(context: Context, steam: Boolean = true) {
|
||||
class LoadingState(context: Context, private val steam: Boolean = true) {
|
||||
var visible by mutableStateOf(true)
|
||||
var step by mutableStateOf("Starting the session…")
|
||||
var step by mutableStateOf(context.getString(com.droiddeck.launcher.R.string.loadstate_starting))
|
||||
private set
|
||||
/** What [step] is about, so the screen picks its checklist stage without reading the (translated) words. */
|
||||
var topic by mutableStateOf(Topic.SESSION)
|
||||
private set
|
||||
/** [step] is a download or install line worth showing as it is, not one of the script's own notes. */
|
||||
var readable by mutableStateOf(false)
|
||||
private set
|
||||
var percent by mutableIntStateOf(-1)
|
||||
var elapsed by mutableStateOf("")
|
||||
var hint by mutableStateOf("")
|
||||
@@ -31,15 +38,23 @@ class LoadingState(context: Context, steam: Boolean = true) {
|
||||
private val hints = context.resources.getStringArray(
|
||||
if (steam) com.droiddeck.launcher.R.array.loading_hints else com.droiddeck.launcher.R.array.loading_hints_desktop,
|
||||
)
|
||||
private val res = context.resources
|
||||
private val startedAt = SystemClock.elapsedRealtime()
|
||||
|
||||
/** Once a second: the clock and the hint. */
|
||||
fun tick() {
|
||||
val seconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
|
||||
elapsed = String.format(java.util.Locale.US, "%d:%02d elapsed · still working", seconds / 60, seconds % 60)
|
||||
elapsed = res.getString(com.droiddeck.launcher.R.string.loadstate_elapsed, seconds / 60, seconds % 60)
|
||||
hint = hints[((seconds / 8) % hints.size).toInt()]
|
||||
}
|
||||
|
||||
/** A line the app itself writes (an install's progress, the session starting). */
|
||||
fun say(line: String, topic: Topic, readable: Boolean) {
|
||||
step = line
|
||||
this.topic = topic
|
||||
this.readable = readable
|
||||
}
|
||||
|
||||
fun showEnded(message: String, detail: String? = null) {
|
||||
visible = true
|
||||
ended = true
|
||||
@@ -50,12 +65,16 @@ class LoadingState(context: Context, steam: Boolean = true) {
|
||||
/** Re-reads the end of the log and updates the line and the bar. */
|
||||
fun update(context: Context, log: File?) {
|
||||
val state = read(context, log) ?: return
|
||||
step = state.first
|
||||
percent = state.second
|
||||
step = state.line
|
||||
topic = state.topic
|
||||
readable = state.readable
|
||||
percent = state.percent
|
||||
}
|
||||
|
||||
private class Line(val line: String, val percent: Int, val topic: Topic, val readable: Boolean)
|
||||
|
||||
/** Only the tail is read: the client alone writes megabytes an hour. */
|
||||
private fun read(context: Context, log: File?): Pair<String, Int>? {
|
||||
private fun read(context: Context, log: File?): Line? {
|
||||
if (log == null || !log.isFile) return null
|
||||
val text = try {
|
||||
RandomAccessFile(log, "r").use { file ->
|
||||
@@ -100,16 +119,45 @@ class LoadingState(context: Context, steam: Boolean = true) {
|
||||
}
|
||||
}
|
||||
return when {
|
||||
downloadAt > stepAt && downloadPercent >= 0 ->
|
||||
Pair("Downloading the Steam client update · $downloadPercent%", downloadPercent)
|
||||
clientDownloadAt == stepAt && clientDownload != null ->
|
||||
Pair("Downloading the Steam client · $clientDownload", clientPercent)
|
||||
stepText != null -> Pair(stepText, -1)
|
||||
downloadAt > stepAt && downloadPercent >= 0 -> Line(
|
||||
context.getString(com.droiddeck.launcher.R.string.loadstate_steam_update, downloadPercent), downloadPercent,
|
||||
Topic.STEAM, readable = true,
|
||||
)
|
||||
clientDownloadAt == stepAt && clientDownload != null -> Line(
|
||||
context.getString(com.droiddeck.launcher.R.string.loadstate_steam_client, clientDownload), clientPercent,
|
||||
Topic.STEAM, readable = true,
|
||||
)
|
||||
stepText != null -> Line(stepText, -1, topicOf(stepText, steam), readableScriptStep(stepText))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The checklist stages a loading line can move the screen to. */
|
||||
enum class Topic { RUNTIME, SESSION, DESKTOP, STEAM, STEAM_STARTING, OTHER }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A session script "== STEP" line's topic in a Steam ([steam]) or desktop session; those
|
||||
* lines are English whatever the app's language.
|
||||
*/
|
||||
internal fun topicOf(scriptStep: String, steam: Boolean): Topic {
|
||||
val t = scriptStep.lowercase()
|
||||
return when {
|
||||
"linux runtime" in t -> Topic.RUNTIME
|
||||
"starting the session" in t -> Topic.SESSION
|
||||
!steam && "desktop" in t -> Topic.DESKTOP
|
||||
steam && "starting the steam client" in t -> Topic.STEAM_STARTING
|
||||
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> Topic.STEAM
|
||||
else -> Topic.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/** The script's install lines are worth reading as they are; its other notes are not. */
|
||||
internal fun readableScriptStep(scriptStep: String): Boolean {
|
||||
val t = scriptStep.lowercase()
|
||||
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
|
||||
}
|
||||
|
||||
private const val TAIL_BYTES = 48L * 1024
|
||||
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
|
||||
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
|
||||
|
||||
@@ -30,7 +30,7 @@ object OfflineMode {
|
||||
|
||||
private fun loginUsers(root: File) = File(root, "root/.local/share/Steam/config/loginusers.vdf")
|
||||
|
||||
/** Whether the client has an account it could sign in as offline. */
|
||||
/** The account the client could sign in as offline: its name, "" when it recorded none, null when there is none. */
|
||||
fun account(context: Context): String? {
|
||||
val file = loginUsers(LinuxRuntime.rootDir(context))
|
||||
val text = try {
|
||||
@@ -39,7 +39,7 @@ object OfflineMode {
|
||||
Log.w(TAG, "could not read loginusers.vdf", e); return null
|
||||
}
|
||||
if (!REMEMBER.containsMatchIn(text)) return null
|
||||
return PERSONA.find(text)?.groupValues?.get(1)?.takeIf { it.isNotBlank() } ?: "signed in"
|
||||
return PERSONA.find(text)?.groupValues?.get(1)?.takeIf { it.isNotBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
fun enabled(context: Context): Boolean = prefs(context).getBoolean(KEY, false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.R
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
@@ -59,70 +60,72 @@ object ProtonExtras {
|
||||
/** Downloads, verifies and registers the latest build without waiting for another session. */
|
||||
@Synchronized
|
||||
fun install(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
|
||||
if (installInProgress) return "A compatibility tool install is already running"
|
||||
if (installInProgress) return context.getString(R.string.pextra_busy)
|
||||
installInProgress = true
|
||||
return try { installNow(context, tool, onProgress) } finally { installInProgress = false }
|
||||
}
|
||||
|
||||
private fun installNow(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
|
||||
if (SessionState.running) return "Stop the active session before installing compatibility tools"
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
if (SessionState.running) return context.getString(R.string.pextra_stop_session)
|
||||
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
|
||||
|
||||
val asset = findLatestAsset(tool) ?: return "Could not find an ARM64 release for ${tool.name}"
|
||||
val asset = findLatestAsset(tool) ?: return context.getString(R.string.pextra_no_release, tool.name)
|
||||
val downloads = File(context.filesDir, "proton-downloads").apply { mkdirs() }
|
||||
val archive = File(downloads, "${tool.id}-${asset.name}")
|
||||
if (asset.size > 0 && archive.length() > asset.size) archive.delete()
|
||||
val missing = (asset.size - archive.length()).coerceAtLeast(0L)
|
||||
if (StatFs(LinuxRuntime.rootDir(context).path).availableBytes < NEED_BYTES + missing) {
|
||||
return "At least 4 GB free plus the download size is required to install ${tool.name}"
|
||||
return context.getString(R.string.pextra_no_space, tool.name)
|
||||
}
|
||||
|
||||
onProgress("Downloading ${tool.name} ${asset.tag}", 0)
|
||||
val downloading = context.getString(R.string.pextra_downloading, tool.name, asset.tag)
|
||||
onProgress(downloading, 0)
|
||||
var downloaded = false
|
||||
for (attempt in 0 until 5) {
|
||||
downloaded = Downloader.downloadFile(asset.url, archive, true) { fraction ->
|
||||
onProgress("Downloading ${tool.name} ${asset.tag}", if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
|
||||
onProgress(downloading, if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
|
||||
}
|
||||
if (downloaded) break
|
||||
if (attempt < 4) {
|
||||
onProgress("Retrying download · attempt ${attempt + 2} of 5", -1)
|
||||
onProgress(context.getString(R.string.pextra_retrying, attempt + 2, 5), -1)
|
||||
try { Thread.sleep(3_000) } catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
return "Download interrupted"
|
||||
return context.getString(R.string.pextra_interrupted)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!downloaded) return "Download failed; the partial file is kept so a retry can resume"
|
||||
if (!downloaded) return context.getString(R.string.pextra_download_failed)
|
||||
if (asset.size > 0 && archive.length() != asset.size) {
|
||||
if (archive.length() > asset.size) {
|
||||
archive.delete()
|
||||
return "Download exceeded the release size and was deleted"
|
||||
return context.getString(R.string.pextra_too_big)
|
||||
}
|
||||
return "Download was incomplete; the partial file is kept for a resumable retry"
|
||||
return context.getString(R.string.pextra_incomplete)
|
||||
}
|
||||
|
||||
// One checksum has to be found and has to match: GitHub's sha256 digest, else the
|
||||
// release's own sha512 file. A download neither vouches for is not installed.
|
||||
onProgress("Verifying download", -1)
|
||||
onProgress(context.getString(R.string.pextra_verifying), -1)
|
||||
val verified = when {
|
||||
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
|
||||
asset.sha512 != null -> {
|
||||
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
|
||||
?: return "Could not read the release checksum; try again"
|
||||
?: return context.getString(R.string.pextra_checksum_unreadable)
|
||||
expected.equals(Hashes.sha512(archive), ignoreCase = true)
|
||||
}
|
||||
else -> {
|
||||
archive.delete()
|
||||
return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed"
|
||||
return context.getString(R.string.pextra_no_checksum, tool.name, asset.tag)
|
||||
}
|
||||
}
|
||||
if (!verified) {
|
||||
archive.delete()
|
||||
return "Checksum mismatch; the download was deleted"
|
||||
return context.getString(R.string.pextra_checksum_mismatch)
|
||||
}
|
||||
|
||||
if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools"
|
||||
onProgress("Installing ${tool.name}", -1)
|
||||
if (SessionState.running) return context.getString(R.string.pextra_session_started)
|
||||
val installing = context.getString(R.string.pextra_installing, tool.name)
|
||||
onProgress(installing, -1)
|
||||
return try {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
@@ -146,17 +149,17 @@ object ProtonExtras {
|
||||
lines.forEach { line ->
|
||||
Log.i(TAG, line)
|
||||
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
|
||||
onProgress("Installing ${tool.name}", -1)
|
||||
onProgress(installing, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
val status = child.waitFor()
|
||||
if (status != 0) "${tool.name} installation failed (exit $status)"
|
||||
if (status != 0) context.getString(R.string.pextra_install_failed, tool.name, status)
|
||||
else {
|
||||
archive.delete()
|
||||
unqueue(context, tool)
|
||||
if (EsyncPacks.enabled(context)) {
|
||||
onProgress("Fetching droiddeck-esync pack", -1)
|
||||
onProgress(context.getString(R.string.esync_fetching), -1)
|
||||
try {
|
||||
EsyncPacks.fetchWanted(context, root, onProgress)
|
||||
} catch (t: Throwable) {
|
||||
@@ -167,7 +170,7 @@ object ProtonExtras {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${tool.id}", e)
|
||||
e.message ?: "Could not install ${tool.name}"
|
||||
e.message ?: context.getString(R.string.pextra_could_not_install, tool.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ object SecondaryLibrary {
|
||||
return File(files, "steam-libraries/$digest")
|
||||
}
|
||||
|
||||
val CARD_GUESTS = listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd")
|
||||
|
||||
/** Parent binds come first; the more specific private directories override them in PRoot. */
|
||||
fun binds(files: File, library: File): List<String> {
|
||||
fun binds(files: File, library: File, guests: List<String> = CARD_GUESTS): List<String> {
|
||||
val private = privateRoot(files, library)
|
||||
val apps = File(library, "steamapps")
|
||||
val tools = LinkedHashMap(bootstrapTools)
|
||||
@@ -57,7 +59,7 @@ object SecondaryLibrary {
|
||||
if (lower != relative) overrides.add(target to lower)
|
||||
}
|
||||
}
|
||||
return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest ->
|
||||
return guests.flatMap { guest ->
|
||||
listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,13 @@ object SessionFiles {
|
||||
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
|
||||
"libssbs.so" to "usr/local/lib/libssbs.so",
|
||||
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
|
||||
"usr/local/bin/droiddeck-fonts" to "usr/local/bin/droiddeck-fonts",
|
||||
"usr/local/bin/droiddeck-agent" to "usr/local/bin/droiddeck-agent",
|
||||
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
|
||||
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
|
||||
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
|
||||
"usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale",
|
||||
"usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language",
|
||||
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
|
||||
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
|
||||
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.FileProvider
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
@@ -123,6 +124,6 @@ object SessionLogShare {
|
||||
.putExtra(Intent.EXTRA_SUBJECT, zip.nameWithoutExtension)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
send.clipData = ClipData.newRawUri(zip.name, uri)
|
||||
return Intent.createChooser(send, "Share session logs").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
return Intent.createChooser(send, context.getString(R.string.logshare_chooser)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import org.json.JSONObject
|
||||
@@ -11,6 +13,8 @@ object SessionPrefs {
|
||||
const val SUSPEND_NATIVE = "native"
|
||||
const val SUSPEND_MANUAL = "manual"
|
||||
const val SUSPEND_NEVER = "never"
|
||||
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
|
||||
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
@@ -19,10 +23,12 @@ object SessionPrefs {
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
const val OSC_NEVER = "never"
|
||||
|
||||
const val BACK_MENU_THEN_QAM = "1: menu 2: QAM"
|
||||
const val BACK_QAM_THEN_MENU = "1: QAM 2: menu"
|
||||
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
|
||||
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
|
||||
val BACK_QAM_THEN_MENU = R.string.back_qam_then_menu
|
||||
|
||||
fun backActionsOrder(inverted: Boolean): String =
|
||||
@StringRes
|
||||
fun backActionsOrder(inverted: Boolean): Int =
|
||||
if (inverted) BACK_QAM_THEN_MENU else BACK_MENU_THEN_QAM
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
@@ -156,10 +162,9 @@ object SessionPrefs {
|
||||
|
||||
/**
|
||||
* The Steam client's own sound through the DirectAudio relay instead of the classic AAudio
|
||||
* sink. Off by default: on an AYN Thor (Android 13, 20 ms bursts) the relay path stayed choppy
|
||||
* where the classic sink - the one 0.1.5 shipped - was fine.
|
||||
* sink. On unless the user picked Classic.
|
||||
*/
|
||||
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", false)
|
||||
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", true)
|
||||
|
||||
fun setClientDirectAudio(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
|
||||
@@ -588,8 +593,25 @@ object SessionPrefs {
|
||||
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
|
||||
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
|
||||
|
||||
private val addedGameLock = Any()
|
||||
|
||||
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
|
||||
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
|
||||
synchronized(addedGameLock) { prefs(context).edit().putString("addedExe:$folderPath", path).apply() }
|
||||
}
|
||||
|
||||
fun addedGameExeSeen(context: Context, folderPath: String): Int = prefs(context).getInt("addedExeSeen:$folderPath", 0)
|
||||
|
||||
fun adoptAddedGameExe(context: Context, folderPath: String, expected: String, path: String, seen: Int): Boolean =
|
||||
synchronized(addedGameLock) {
|
||||
val unchanged = addedGameExe(context, folderPath) == expected
|
||||
if (unchanged) prefs(context).edit().putString("addedExe:$folderPath", path).putInt("addedExeSeen:$folderPath", seen).apply()
|
||||
unchanged
|
||||
}
|
||||
|
||||
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
|
||||
val p = prefs(context)
|
||||
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||
?: first.also { p.edit().putLong("addedAppId:$folderPath", it).apply() }
|
||||
}
|
||||
|
||||
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
|
||||
@@ -658,6 +680,10 @@ object SessionPrefs {
|
||||
|
||||
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
|
||||
|
||||
/** [fpsLimitChoices] labelled in the app's language. */
|
||||
fun fpsLimitChoices(context: Context): List<Pair<Int, String>> =
|
||||
fpsLimitChoices.map { (fps, label) -> fps to if (fps == 0) context.getString(R.string.frame_gen_off) else label }
|
||||
|
||||
/**
|
||||
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
|
||||
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
|
||||
@@ -669,6 +695,16 @@ object SessionPrefs {
|
||||
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
|
||||
)
|
||||
|
||||
/** The [upscalerChoices] labels that are words rather than product names. */
|
||||
private val upscalerLabels = mapOf(
|
||||
0 to R.string.sprefs_upscaler_linear, 2 to R.string.sprefs_upscaler_nearest,
|
||||
8 to R.string.sprefs_upscaler_gsr_quality, 6 to R.string.sprefs_upscaler_sharpen,
|
||||
)
|
||||
|
||||
/** [upscalerChoices] labelled in the app's language; the English list stays for the device report. */
|
||||
fun upscalerChoices(context: Context): List<Pair<Int, String>> =
|
||||
upscalerChoices.map { (mode, label) -> mode to (upscalerLabels[mode]?.let(context::getString) ?: label) }
|
||||
|
||||
fun canonicalUpscaler(mode: Int): Int = when (mode) {
|
||||
1 -> 0
|
||||
5 -> 4
|
||||
@@ -703,6 +739,10 @@ object SessionPrefs {
|
||||
|
||||
val textureAnisotropyChoices = TextureFiltering.ANISOTROPY.map { it to if (it == 0) "Off" else "${it}x" }
|
||||
|
||||
/** [textureAnisotropyChoices] labelled in the app's language. */
|
||||
fun textureAnisotropyChoices(context: Context): List<Pair<Int, String>> =
|
||||
textureAnisotropyChoices.map { (value, label) -> value to if (value == 0) context.getString(R.string.frame_gen_off) else label }
|
||||
|
||||
val textureLodBiasChoices = TextureFiltering.LOD_BIAS.map {
|
||||
it to when (it) {
|
||||
TextureFiltering.LOD_BIAS_OFF -> "Off"
|
||||
@@ -711,6 +751,15 @@ object SessionPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
/** [textureLodBiasChoices] labelled in the app's language. */
|
||||
fun textureLodBiasChoices(context: Context): List<Pair<String, String>> = textureLodBiasChoices.map { (value, label) ->
|
||||
value to when (value) {
|
||||
TextureFiltering.LOD_BIAS_OFF -> context.getString(R.string.frame_gen_off)
|
||||
TextureFiltering.LOD_BIAS_AUTO -> context.getString(R.string.sprefs_lod_bias_auto)
|
||||
else -> label
|
||||
}
|
||||
}
|
||||
|
||||
/** Anisotropic filtering forced on DirectX 9-11 games (core/TextureFiltering); 0 = the game's own. */
|
||||
fun textureAnisotropy(context: Context): Int =
|
||||
prefs(context).getInt("textureAnisotropy", 0).takeIf { it in TextureFiltering.ANISOTROPY } ?: 0
|
||||
@@ -749,16 +798,41 @@ object SessionPrefs {
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
|
||||
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
|
||||
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
|
||||
// Direct games share Steam's settings but have no Steam client to prepare.
|
||||
?.let { if (it == SUSPEND_NATIVE && mode != SessionService.MODE_STEAM) SUSPEND_AUTO else it }
|
||||
?.let { if (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) SUSPEND_AUTO else it }
|
||||
?: SUSPEND_MANUAL
|
||||
|
||||
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
|
||||
val normalized = policy.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_MANUAL, SUSPEND_NEVER) ||
|
||||
(it == SUSPEND_NATIVE && mode == SessionService.MODE_STEAM) }
|
||||
?: SUSPEND_MANUAL
|
||||
prefs(context).edit().putString("suspendPolicy.${prefMode(mode)}", normalized).apply()
|
||||
val prefs = prefs(context)
|
||||
val key = "suspendPolicy.${prefMode(mode)}"
|
||||
prefs.edit().apply {
|
||||
if (mode == SessionService.MODE_STEAM && prefs.getString(key, null) == LEGACY_SUSPEND_DOWNLOADS) {
|
||||
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, true)
|
||||
}
|
||||
putString(key, normalized)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun steamDownloadsInBackground(context: Context): Boolean {
|
||||
val prefs = prefs(context)
|
||||
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
|
||||
prefs.getString("suspendPolicy.${SessionService.MODE_STEAM}", null) == LEGACY_SUSPEND_DOWNLOADS
|
||||
}
|
||||
|
||||
fun setSteamDownloadsInBackground(context: Context, enabled: Boolean) {
|
||||
val prefs = prefs(context)
|
||||
val policyKey = "suspendPolicy.${SessionService.MODE_STEAM}"
|
||||
prefs.edit().apply {
|
||||
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, enabled)
|
||||
if (prefs.getString(policyKey, null) == LEGACY_SUSPEND_DOWNLOADS) {
|
||||
putString(policyKey, SUSPEND_AUTO)
|
||||
}
|
||||
}.apply()
|
||||
}
|
||||
|
||||
// ── Game storage ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.TrafficStats
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
@@ -60,6 +61,9 @@ import java.util.UUID
|
||||
* The activity comes and goes on top of this; see [com.droiddeck.launcher.wayland.CompositorHost].
|
||||
*/
|
||||
class SessionService : Service() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
|
||||
|
||||
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
|
||||
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
|
||||
private var deckBinds: List<String> = emptyList()
|
||||
@@ -72,7 +76,12 @@ class SessionService : Service() {
|
||||
private val auxiliaryProcessesLock = Any()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var suspendController: SessionSuspendController? = null
|
||||
private val steamDownloadMonitor = SteamDownloadMonitor()
|
||||
private var downloadMonitorTask: Runnable? = null
|
||||
private var downloadActive = false
|
||||
private var suspendDownloadNow = false
|
||||
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
|
||||
private var steamDownloadsInBackground = false
|
||||
private var activityVisible = true
|
||||
private var screenOn = true
|
||||
private var manualPauseRequested = false
|
||||
@@ -164,6 +173,7 @@ class SessionService : Service() {
|
||||
ACTION_ACTIVITY_VISIBLE -> {
|
||||
if (!SessionState.pipActive) pipTask = false
|
||||
activityVisible = true
|
||||
suspendDownloadNow = false
|
||||
resumeSteamSleepOnReturn()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
@@ -178,10 +188,18 @@ class SessionService : Service() {
|
||||
ACTION_SUSPEND_POLICY_CHANGED -> {
|
||||
if (!SessionState.running) return START_NOT_STICKY
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
updateDownloadMonitoring()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_SUSPEND_NOW -> {
|
||||
if (!SessionState.running) return START_NOT_STICKY
|
||||
suspendDownloadNow = true
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_RESUME -> {
|
||||
resumeSession()
|
||||
return START_NOT_STICKY
|
||||
@@ -196,9 +214,13 @@ class SessionService : Service() {
|
||||
}
|
||||
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
activityVisible = true
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
steamSleepToken = null
|
||||
completeNativeSleep()
|
||||
suspendOperationPending = false
|
||||
@@ -263,11 +285,15 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
private fun extraEnv(): List<String> {
|
||||
val file = File(Environment.getExternalStorageDirectory(), ENV_SWITCH).takeIf { it.isFile } ?: return emptyList()
|
||||
if (File(Environment.getExternalStorageDirectory(), LEGACY_ENV_SWITCH).isFile) {
|
||||
Log.w(TAG, "ignoring $LEGACY_ENV_SWITCH: any app with storage access can write there; " +
|
||||
"the file now lives at ${envSwitchFile(this)?.path ?: "(not available before Android 11)"}")
|
||||
}
|
||||
val file = envSwitchFile(this)?.takeIf { it.isFile } ?: return emptyList()
|
||||
val lines = FileUtils.readString(file)?.lines().orEmpty()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') && !it.startsWith("=") }
|
||||
if (lines.isNotEmpty()) Log.i(TAG, "extra environment from $ENV_SWITCH: $lines")
|
||||
if (lines.isNotEmpty()) Log.i(TAG, "extra environment keys from ${file.path}: ${lines.map { it.substringBefore('=') }}")
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -298,6 +324,7 @@ class SessionService : Service() {
|
||||
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
|
||||
killStragglers()
|
||||
SessionFiles.stage(this, root)
|
||||
com.droiddeck.launcher.agent.AgentGuest.reset(this)
|
||||
|
||||
val sessionDir = openSessionFolder()
|
||||
val sessionLog = File(sessionDir, "session.log")
|
||||
@@ -397,14 +424,14 @@ class SessionService : Service() {
|
||||
}
|
||||
guest.add(program)
|
||||
guest.addAll(SessionState.programArgs)
|
||||
Log.i(TAG, "run: $program ${SessionState.programArgs.joinToString(" ")} under gamescope")
|
||||
Log.i(TAG, "run: $program (${SessionState.programArgs.size} arguments) under gamescope")
|
||||
}
|
||||
|
||||
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
|
||||
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
|
||||
FileUtils.clear(File(cacheDir, "shm"))
|
||||
|
||||
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest)
|
||||
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest, sessionRoot, runtimeDir)
|
||||
// The fast path is told exactly the rootfs and binds proot is given (ProotFastPath).
|
||||
val fastPathKey = if (ProotFastPath.enabled(this)) {
|
||||
val prootBinds = LinuxRuntime.binds(
|
||||
@@ -522,6 +549,7 @@ class SessionService : Service() {
|
||||
},
|
||||
)
|
||||
mainHandler.post { updateSuspendPolicy() }
|
||||
updateDownloadMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +582,12 @@ class SessionService : Service() {
|
||||
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
|
||||
guest.add("TERM=xterm-256color")
|
||||
guest.add("LANG=C.UTF-8")
|
||||
// Steam's interface language: the app's (Setup's choice, or the system's). The system's
|
||||
// region decides between Spain's and Latin American Spanish, which the app has one of.
|
||||
guest.add("BL_STEAM_LANGUAGE=" + SteamLanguage.forLocale(
|
||||
com.droiddeck.launcher.core.AppLanguage.effective(this),
|
||||
com.droiddeck.launcher.core.AppLanguage.system(this).country,
|
||||
))
|
||||
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
|
||||
// session bundle sit hours off the device's. Bannerlator carries the same line.
|
||||
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
|
||||
@@ -619,11 +653,15 @@ class SessionService : Service() {
|
||||
// gamescope's realtime Vulkan queues (the session script turns this into
|
||||
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
|
||||
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
|
||||
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
|
||||
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
|
||||
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
|
||||
// the client's - whatever the experiment needs, without a build per attempt.
|
||||
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
|
||||
// Anything else, for a device that cannot be reached with a debugger: droiddeck-env in the
|
||||
// app's own external files (envSwitchFile) holds KEY=VALUE lines that go into the session's
|
||||
// environment as written, after ours, so a line here wins. Zink and Turnip tunables
|
||||
// (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's, the client's - whatever the experiment
|
||||
// needs, without a build per attempt.
|
||||
extraEnv().forEach { guest.add(it) }
|
||||
// The agent bridge's experiment lines (AgentEnv), after the user's so an agent's win.
|
||||
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
|
||||
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
|
||||
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
|
||||
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
|
||||
@@ -747,6 +785,8 @@ class SessionService : Service() {
|
||||
fakeInputDir: File,
|
||||
sessionDir: File,
|
||||
guest: MutableList<String>,
|
||||
sessionRoot: File,
|
||||
runtimeDir: File,
|
||||
): ArrayList<String> {
|
||||
val binds = ArrayList<String>()
|
||||
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
|
||||
@@ -808,7 +848,7 @@ class SessionService : Service() {
|
||||
val library = GameStorage.effective(this)
|
||||
var storageDiagnosticLibrary: File? = null
|
||||
if (library != null) {
|
||||
val problem = GameStorage.prepare(library.path)
|
||||
val problem = GameStorage.prepare(this, library.path)
|
||||
if (problem == null) {
|
||||
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
|
||||
// Links, prefixes and Steam entries made before the rename still name the old path.
|
||||
@@ -867,6 +907,45 @@ class SessionService : Service() {
|
||||
} else if (roms != null) {
|
||||
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
|
||||
}
|
||||
val libraries = ArrayList<Pair<String, String>>()
|
||||
if (storageDiagnosticLibrary != null && library != null) libraries.add(SecondaryLibrary.CARD_GUESTS[0] to library.label)
|
||||
val gamesLibraries = GameStorage.gamesFolderLibraries(this)
|
||||
if (gamesLibraries.isNotEmpty()) {
|
||||
var room = if (ProotFastPath.enabled(this)) {
|
||||
ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size
|
||||
} else Int.MAX_VALUE
|
||||
for (games in gamesLibraries) {
|
||||
if (games.host.path + ":" + games.guest !in binds) continue
|
||||
val problem = GameStorage.prepare(this, games.host.path)
|
||||
if (problem != null) {
|
||||
Log.w(TAG, "steam library: $problem")
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val extra = SecondaryLibrary.binds(filesDir, games.host, listOf(games.guest)).drop(1)
|
||||
if (extra.size > room) {
|
||||
Log.w(TAG, "steam library: ${games.host} needs ${extra.size} binds, $room left for the fast path; not a library this session")
|
||||
continue
|
||||
}
|
||||
binds.addAll(extra)
|
||||
room -= extra.size
|
||||
libraries.add(games.guest to games.label)
|
||||
Log.i(TAG, "steam library: ${games.host} -> ${games.guest} (\"${games.label}\")")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam library: ${games.host} private directories could not be prepared", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
val listing = File(filesDir, "session/steam-libraries.json").apply { parentFile?.mkdirs() }
|
||||
val json = org.json.JSONArray()
|
||||
for ((path, label) in libraries) json.put(org.json.JSONObject().put("path", path).put("label", label.replace('"', ' ').replace('\\', ' ')))
|
||||
listing.writeText(json.toString())
|
||||
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
|
||||
guest.add(envAt, "BL_STEAM_LIBRARIES=" + listing.path)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam library: the list could not be written; the card alone this session", e)
|
||||
}
|
||||
return binds
|
||||
}
|
||||
|
||||
@@ -1007,6 +1086,7 @@ class SessionService : Service() {
|
||||
activityVisible = true
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
@@ -1178,16 +1258,19 @@ class SessionService : Service() {
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
val hidden = !activityVisible || !screenOn
|
||||
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden) {
|
||||
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
|
||||
if (downloadsBlockingSuspend) acquireLocks()
|
||||
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
|
||||
completeNativeSleep()
|
||||
} else if (nativeSleepToken == null && steamSleepToken == null &&
|
||||
!SessionState.suspended && !suspendAttemptFailed) {
|
||||
prepareNativeSleep()
|
||||
}
|
||||
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
|
||||
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
|
||||
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
|
||||
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
|
||||
else -> false
|
||||
}
|
||||
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
|
||||
@@ -1324,6 +1407,9 @@ class SessionService : Service() {
|
||||
SessionState.pipActive = false
|
||||
pipTask = false
|
||||
SessionState.guestPid = -1
|
||||
com.droiddeck.launcher.agent.AgentGuest.stop()
|
||||
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
|
||||
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
|
||||
releaseLocks()
|
||||
if (status == 0) {
|
||||
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
|
||||
@@ -1371,6 +1457,7 @@ class SessionService : Service() {
|
||||
if (!SessionState.running) return
|
||||
SessionState.running = false
|
||||
}
|
||||
updateDownloadMonitoring()
|
||||
val stoppedGen = sessionGen
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
@@ -1539,11 +1626,26 @@ class SessionService : Service() {
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(if (SessionState.suspended) R.string.session_notification_paused else R.string.session_notification))
|
||||
.setContentText(getString(
|
||||
when {
|
||||
SessionState.suspended -> R.string.session_notification_paused
|
||||
downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
|
||||
else -> R.string.session_notification
|
||||
}
|
||||
))
|
||||
.setContentIntent(open)
|
||||
.apply {
|
||||
if (SessionState.suspended) {
|
||||
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
|
||||
} else if (downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
|
||||
val suspend = PendingIntent.getService(
|
||||
this@SessionService, 3,
|
||||
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
addAction(Notification.Action.Builder(null, getString(R.string.suspend_now), suspend).build())
|
||||
}
|
||||
}
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.stop_session), stop).build())
|
||||
@@ -1557,6 +1659,40 @@ class SessionService : Service() {
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
private fun updateDownloadMonitoring() {
|
||||
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
|
||||
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
|
||||
if (!enabled) {
|
||||
val wasActive = downloadActive
|
||||
downloadMonitorTask?.let(mainHandler::removeCallbacks)
|
||||
downloadMonitorTask = null
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
if (wasActive) refreshNotification()
|
||||
return
|
||||
}
|
||||
if (downloadMonitorTask != null) return
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (downloadMonitorTask !== this || !SessionState.running ||
|
||||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
|
||||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
|
||||
return
|
||||
}
|
||||
val wasActive = downloadActive
|
||||
downloadActive = steamDownloadMonitor.poll(
|
||||
LinuxRuntime.rootDir(this@SessionService),
|
||||
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
|
||||
)
|
||||
if (wasActive != downloadActive) refreshNotification()
|
||||
updateSuspendPolicy()
|
||||
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
|
||||
}
|
||||
}
|
||||
downloadMonitorTask = poll
|
||||
mainHandler.post(poll)
|
||||
}
|
||||
|
||||
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
|
||||
private fun logControllersAtStart() {
|
||||
val pads = ArrayList<String>()
|
||||
@@ -1575,12 +1711,30 @@ class SessionService : Service() {
|
||||
private const val TRACER_NICE = -6
|
||||
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
|
||||
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
|
||||
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
|
||||
private const val ENV_SWITCH = "Download/droiddeck-env"
|
||||
/**
|
||||
* Where droiddeck-env (KEY=VALUE lines added to the session environment verbatim) used to
|
||||
* be. Ignored now: a line there reaches the Steam client's environment as written
|
||||
* (LD_PRELOAD, VK_ICD_FILENAMES, PATH...), and any app with storage access can write to
|
||||
* Download - code of its choosing inside our sandbox, next to the client's saved login.
|
||||
*/
|
||||
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
|
||||
private const val ENV_SWITCH_NAME = "droiddeck-env"
|
||||
|
||||
/**
|
||||
* The session's extra-environment file: Android/data/<package>/files/droiddeck-env. Only
|
||||
* this app, adb and the user (through the app's own file manager) can write there - from
|
||||
* Android 11 on. Before that other apps with storage access can reach Android/data too, so
|
||||
* there is no such file at all.
|
||||
*/
|
||||
fun envSwitchFile(context: Context): File? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
context.getExternalFilesDir(null)?.let { File(it, ENV_SWITCH_NAME) }
|
||||
else null
|
||||
private const val CHANNEL_ID = "session"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
|
||||
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
|
||||
const val ACTION_SUSPEND_NOW = "com.droiddeck.launcher.SUSPEND_NOW"
|
||||
private const val ACTION_PIP_BEGIN = "com.droiddeck.launcher.PIP_BEGIN"
|
||||
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
|
||||
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
|
||||
@@ -1600,6 +1754,7 @@ class SessionService : Service() {
|
||||
private const val STEAM_PICKUP_MS = 1500L
|
||||
private const val STEAM_EXIT_MS = 10_000L
|
||||
private const val GAME_LAUNCH_RETRY_MS = 250L
|
||||
private const val DOWNLOAD_POLL_MS = 2_000L
|
||||
private const val STEAM_GAME_REQUEST = "steam-game"
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
|
||||
@@ -80,6 +80,9 @@ object SessionState {
|
||||
/** Told (on the thread that changed it) whenever [deckPad] changes. */
|
||||
@Volatile var deckPadListener: (() -> Unit)? = null
|
||||
|
||||
/** The session screen's virtual pad, for the agent bridge's pad input (AgentInput). */
|
||||
@Volatile var padBridge: com.droiddeck.launcher.input.PadBridge? = null
|
||||
|
||||
/** What the second screen shows, and on which display: kept for the session, so a screen
|
||||
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
|
||||
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
internal class SteamDownloadMonitor {
|
||||
private data class ManifestState(
|
||||
val bytesDownloaded: Long,
|
||||
val incomplete: Boolean,
|
||||
)
|
||||
|
||||
private var previous = emptyMap<String, ManifestState>()
|
||||
private var lastProgressAt = 0L
|
||||
private var contentLogOffset = 0L
|
||||
private val activeContentUpdates = mutableSetOf<String>()
|
||||
private var lastContentActivityAt = 0L
|
||||
private var lastReceivedBytes = -1L
|
||||
private var lastReceivedAt = 0L
|
||||
|
||||
/**
|
||||
* [receivedBytes] is the app uid's received-byte counter (the guest runs as that uid), or a
|
||||
* negative value when Android does not report one. Steam writes its rate line about once a
|
||||
* minute and flushes manifest counters even less often, so live traffic is what bridges them.
|
||||
*/
|
||||
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L): Boolean {
|
||||
val current = manifests(runtimeRoot).associate { it.path to read(it) }
|
||||
val progressed = current.any { (path, state) ->
|
||||
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
|
||||
}
|
||||
if (progressed) lastProgressAt = now
|
||||
previous = current
|
||||
val manifestActive = progressed || lastProgressAt > 0L && now - lastProgressAt <= MANIFEST_PROGRESS_GRACE_MS &&
|
||||
current.values.any { it.incomplete }
|
||||
if (receiving(receivedBytes, now)) lastContentActivityAt = now
|
||||
return manifestActive || readContentActivity(runtimeRoot, now)
|
||||
}
|
||||
|
||||
private fun receiving(receivedBytes: Long, now: Long): Boolean {
|
||||
val previousBytes = lastReceivedBytes
|
||||
val previousAt = lastReceivedAt
|
||||
lastReceivedBytes = receivedBytes
|
||||
lastReceivedAt = now
|
||||
if (receivedBytes < 0L || previousBytes < 0L || receivedBytes < previousBytes || now <= previousAt) return false
|
||||
return (receivedBytes - previousBytes) * 1000L / (now - previousAt) >= MIN_RECEIVE_BYTES_PER_SEC
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
previous = emptyMap()
|
||||
lastProgressAt = 0L
|
||||
contentLogOffset = 0L
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
lastReceivedBytes = -1L
|
||||
lastReceivedAt = 0L
|
||||
}
|
||||
|
||||
private fun readContentActivity(runtimeRoot: File, now: Long): Boolean {
|
||||
val log = File(runtimeRoot, "root/.local/share/Steam/logs/content_log.txt")
|
||||
if (!log.isFile) return false
|
||||
if (log.length() < contentLogOffset) {
|
||||
contentLogOffset = 0L
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
}
|
||||
runCatching {
|
||||
RandomAccessFile(log, "r").use { input ->
|
||||
input.seek(contentLogOffset)
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
when {
|
||||
"Client version:" in line -> {
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
}
|
||||
CONTENT_UPDATE.find(line)?.let { match ->
|
||||
val appId = match.groupValues[1]
|
||||
val updateType = match.groupValues[2]
|
||||
val state = match.groupValues[3]
|
||||
val updateId = "$updateType:$appId"
|
||||
if ("Running Update" in state && "Stopping" !in state) {
|
||||
activeContentUpdates += updateId
|
||||
lastContentActivityAt = now
|
||||
} else {
|
||||
activeContentUpdates -= updateId
|
||||
}
|
||||
true
|
||||
} == true -> Unit
|
||||
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
|
||||
lastContentActivityAt = now
|
||||
"Downloading " in line && " chunks for depot " in line ->
|
||||
lastContentActivityAt = now
|
||||
}
|
||||
if (activeContentUpdates.isNotEmpty()) lastContentActivityAt = now
|
||||
}
|
||||
contentLogOffset = input.filePointer
|
||||
}
|
||||
}
|
||||
return activeContentUpdates.isNotEmpty() && lastContentActivityAt > 0L &&
|
||||
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
|
||||
}
|
||||
|
||||
private fun manifests(runtimeRoot: File): List<File> {
|
||||
val steamRoots = linkedSetOf(File(runtimeRoot, "root/.local/share/Steam"))
|
||||
val primary = steamRoots.first()
|
||||
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
|
||||
runCatching {
|
||||
PATH.findAll(libraryFolders.readText()).forEach { match ->
|
||||
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
|
||||
}
|
||||
}
|
||||
return steamRoots.flatMap { root ->
|
||||
File(root, "steamapps").listFiles { file ->
|
||||
file.isFile && file.name.startsWith("appmanifest_") && file.name.endsWith(".acf")
|
||||
}.orEmpty().toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun read(file: File): ManifestState {
|
||||
val text = runCatching { file.readText() }.getOrDefault("")
|
||||
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
|
||||
val downloaded = value(text, "BytesDownloaded")?.toLongOrNull() ?: 0L
|
||||
val total = value(text, "BytesToDownload")?.toLongOrNull() ?: 0L
|
||||
return ManifestState(downloaded, flags != 4L && (total <= 0L || downloaded < total))
|
||||
}
|
||||
|
||||
private fun value(text: String, key: String): String? =
|
||||
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
|
||||
|
||||
private companion object {
|
||||
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
|
||||
// Longer than Steam's ~60 s between rate lines, for when no traffic counter is available.
|
||||
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
|
||||
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
|
||||
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
|
||||
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
|
||||
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Steam's name for a language: the API language codes the client and the Steamworks API use
|
||||
* ("english", "schinese", "koreana"...), for every language Steam's interface comes in.
|
||||
*/
|
||||
object SteamLanguage {
|
||||
const val DEFAULT = "english"
|
||||
|
||||
/** Every language the Steam client's interface is offered in. */
|
||||
val all: Set<String> = setOf(
|
||||
"arabic", "bulgarian", "schinese", "tchinese", "czech", "danish", "dutch", "english",
|
||||
"finnish", "french", "german", "greek", "hungarian", "indonesian", "italian", "japanese",
|
||||
"koreana", "norwegian", "polish", "portuguese", "brazilian", "romanian", "russian",
|
||||
"spanish", "latam", "swedish", "thai", "turkish", "ukrainian", "vietnamese", "malay",
|
||||
)
|
||||
|
||||
/** Spanish-speaking regions Steam serves with Latin American Spanish rather than Spain's. */
|
||||
private val latinAmerica = setOf(
|
||||
"419", "AR", "BO", "CL", "CO", "CR", "CU", "DO", "EC", "GT", "HN", "MX", "NI", "PA", "PE",
|
||||
"PR", "PY", "SV", "US", "UY", "VE",
|
||||
)
|
||||
|
||||
/**
|
||||
* Steam's language for [locale]. [region] stands in for the locale's own when it has none: the
|
||||
* app's Spanish carries no region, and a player in Mexico should get Latin American Spanish.
|
||||
*/
|
||||
fun forLocale(locale: Locale, region: String? = null): String {
|
||||
val country = locale.country.ifEmpty { region.orEmpty() }
|
||||
return when (locale.language) {
|
||||
"ar" -> "arabic"
|
||||
"bg" -> "bulgarian"
|
||||
"cs" -> "czech"
|
||||
"da" -> "danish"
|
||||
"nl" -> "dutch"
|
||||
"en" -> "english"
|
||||
"fi" -> "finnish"
|
||||
"fr" -> "french"
|
||||
"de" -> "german"
|
||||
"el" -> "greek"
|
||||
"hu" -> "hungarian"
|
||||
"id", "in" -> "indonesian"
|
||||
"it" -> "italian"
|
||||
"ms" -> "malay"
|
||||
"ja" -> "japanese"
|
||||
"ko" -> "koreana"
|
||||
"nb", "no", "nn" -> "norwegian"
|
||||
"pl" -> "polish"
|
||||
"pt" -> if (country == "BR") "brazilian" else "portuguese"
|
||||
"ro" -> "romanian"
|
||||
"ru" -> "russian"
|
||||
"es" -> if (country in latinAmerica) "latam" else "spanish"
|
||||
"sv" -> "swedish"
|
||||
"th" -> "thai"
|
||||
"tr" -> "turkish"
|
||||
"uk" -> "ukrainian"
|
||||
"vi" -> "vietnamese"
|
||||
"zh" -> when {
|
||||
locale.script == "Hant" -> "tchinese"
|
||||
locale.script == "Hans" -> "schinese"
|
||||
country == "TW" || country == "HK" || country == "MO" -> "tchinese"
|
||||
else -> "schinese"
|
||||
}
|
||||
else -> DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.store
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
@@ -32,13 +34,13 @@ object FlathubApi {
|
||||
)
|
||||
|
||||
/** A category as Flathub's search filters it, with the name shown for it. */
|
||||
class Category(val id: String, val label: String)
|
||||
class Category(val id: String, @StringRes val label: Int)
|
||||
|
||||
val categories = listOf(
|
||||
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
|
||||
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
|
||||
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
|
||||
Category("Utility", "Utilities"),
|
||||
Category("Game", R.string.store_cat_games), Category("AudioVideo", R.string.store_cat_audio_video), Category("Graphics", R.string.store_cat_graphics),
|
||||
Category("Network", R.string.store_cat_internet), Category("Office", R.string.store_cat_office), Category("Development", R.string.store_cat_developer),
|
||||
Category("Education", R.string.store_cat_education), Category("Science", R.string.store_cat_science), Category("System", R.string.store_cat_system),
|
||||
Category("Utility", R.string.store_cat_utilities),
|
||||
)
|
||||
|
||||
/** Front-page collections: popular, trending, recently-updated, recently-added. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.FlatpakManager
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,7 @@ object StoreState {
|
||||
private set
|
||||
private var searchSeq = 0
|
||||
|
||||
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
|
||||
val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated)
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
@@ -97,7 +98,7 @@ object StoreState {
|
||||
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
|
||||
if (busy != null) return
|
||||
val app = context.applicationContext
|
||||
busy = what; stage = "Starting…"; percent = -1
|
||||
busy = what; stage = app.getString(R.string.store_starting); percent = -1
|
||||
Thread({
|
||||
val problem = try {
|
||||
work(app) { s, p -> main.post { stage = s; percent = p } }
|
||||
@@ -109,12 +110,12 @@ object StoreState {
|
||||
main.post {
|
||||
busy = null; stage = null; percent = -1
|
||||
ready = r; installed = list
|
||||
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
|
||||
if (problem != null) Toast.makeText(app, app.getString(R.string.store_problem, label, problem), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "store-$what").start()
|
||||
}
|
||||
|
||||
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
|
||||
fun setup(context: Context) = run(context, "setup", context.getString(R.string.store_flatpak_setup)) { c, p -> FlatpakManager.setup(c, p) }
|
||||
|
||||
fun install(context: Context, id: String, name: String) =
|
||||
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
|
||||
@@ -135,7 +136,7 @@ object StoreState {
|
||||
val u = FlatpakManager.updates(app)
|
||||
main.post {
|
||||
checkingUpdates = false
|
||||
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
|
||||
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, R.string.store_updates_check_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "store-updates").start()
|
||||
}
|
||||
|
||||
@@ -49,12 +49,12 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
val tintOpen = host.open == "controller-tint"
|
||||
SettingsRow(stringResource(R.string.ctrl_color), stringResource(R.string.ctrl_color_hint), highlighted = tintOpen) {
|
||||
Box {
|
||||
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: stringResource(R.string.ctrl_custom), tintOpen) {
|
||||
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second?.let { stringResource(it) } ?: stringResource(R.string.ctrl_custom), tintOpen) {
|
||||
host.open = if (tintOpen) null else "controller-tint"
|
||||
}
|
||||
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = stringResource(R.string.ctrl_color)) { firstItemFocus ->
|
||||
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
|
||||
MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
MenuItem(stringResource(name), checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTint(color)
|
||||
host.open = null
|
||||
}
|
||||
@@ -90,8 +90,9 @@ fun ControllerMappingPage(mapping: Map<String, String>, onPick: (String, String)
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) {
|
||||
val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) }
|
||||
for ((id, name) in ControllerPrefs.mappable) {
|
||||
ChoiceRow(host, "map-$id", name, null, ControllerPrefs.targets, mapping[id] ?: id) { onPick(id, it) }
|
||||
ChoiceRow(host, "map-$id", stringResource(name), null, targets, mapping[id] ?: id) { onPick(id, it) }
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.ctrl_defaults)) {
|
||||
|
||||
@@ -197,7 +197,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
|
||||
color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (row.tag.isNotEmpty()) Text(
|
||||
row.tag, fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
|
||||
driverTagLabel(row.tag), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* One line of text in a slot of fixed width - a rail label, a key cap - set as large as
|
||||
* [maxSize] when it fits and smaller, down to [minSize], when it does not. Labels are as long as
|
||||
* their language makes them ("Components" is "コンポーネント", "Componentes", "구성 요소"), so a
|
||||
* fixed size cuts some of them off; below [minSize] the end is ellipsized rather than clipped.
|
||||
*/
|
||||
@Composable
|
||||
fun FitText(
|
||||
text: String,
|
||||
maxSize: TextUnit,
|
||||
minSize: TextUnit = 9.sp,
|
||||
modifier: Modifier = Modifier,
|
||||
fontWeight: FontWeight? = null,
|
||||
color: Color = Color.Unspecified,
|
||||
) {
|
||||
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val style = LocalTextStyle.current
|
||||
val width = constraints.maxWidth
|
||||
val size = remember(text, width, maxSize, minSize, fontWeight, style) {
|
||||
if (width == Constraints.Infinity) return@remember maxSize
|
||||
var s = maxSize.value
|
||||
while (s > minSize.value) {
|
||||
val measured = measurer.measure(
|
||||
text, style.copy(fontSize = s.sp, fontWeight = fontWeight ?: style.fontWeight),
|
||||
maxLines = 1, softWrap = false,
|
||||
)
|
||||
if (measured.size.width <= width) break
|
||||
s -= 0.5f
|
||||
}
|
||||
s.coerceAtLeast(minSize.value).sp
|
||||
}
|
||||
Text(
|
||||
text, fontSize = size, fontWeight = fontWeight, color = color, maxLines = 1, softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,7 @@ private fun Content(
|
||||
Column {
|
||||
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(
|
||||
e.system.replaceFirstChar { it.uppercase() }.let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
|
||||
systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -334,7 +334,7 @@ private fun Content(
|
||||
)
|
||||
}
|
||||
else Rise(5, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
|
||||
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
|
||||
}
|
||||
} else {
|
||||
if (pkg != null) Rise(2) {
|
||||
@@ -379,7 +379,7 @@ private fun Content(
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
BackLink(e.name) { onSelect("emu:${e.id}") }
|
||||
Eyebrow(stringResource(R.string.content_desktop_system, e.system))
|
||||
Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e)))
|
||||
}
|
||||
}
|
||||
Rise(1) { Title(g.name) }
|
||||
@@ -391,7 +391,7 @@ private fun Content(
|
||||
Actions {
|
||||
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
|
||||
PrimaryButton(stringResource(R.string.content_launch_in, e.name), enabled = !s.busy, main = true) { a.onRom(g) }
|
||||
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
|
||||
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, ok = false)
|
||||
}
|
||||
}
|
||||
if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth))
|
||||
@@ -403,7 +403,7 @@ private fun Content(
|
||||
Rise(4, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(others.map { x ->
|
||||
val index = e.games.indexOf(x)
|
||||
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
|
||||
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
|
||||
}, wide = others.none { it.art != null })
|
||||
}
|
||||
}
|
||||
@@ -419,7 +419,6 @@ private fun Content(
|
||||
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val actions: @Composable () -> Unit = {
|
||||
Actions {
|
||||
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
|
||||
@@ -428,25 +427,33 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
|
||||
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
// The button goes under the title unless the card is wide enough for both side by side: in some
|
||||
// languages its label alone takes half a small phone's pane.
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
|
||||
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!narrow) actions()
|
||||
}
|
||||
if (!narrow) actions()
|
||||
if (narrow) actions()
|
||||
}
|
||||
if (narrow) actions()
|
||||
}
|
||||
}
|
||||
|
||||
/** How wide the desktop card must be to keep its button beside the title. */
|
||||
private val DesktopCardInlineWidth = 640.dp
|
||||
|
||||
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
|
||||
private sealed class GridItem(val key: String) {
|
||||
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
|
||||
@@ -454,25 +461,33 @@ private sealed class GridItem(val key: String) {
|
||||
data object Add : GridItem("add")
|
||||
}
|
||||
|
||||
/** Wide tiles - three across, or a list on a narrow page. */
|
||||
/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */
|
||||
@Composable
|
||||
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) {
|
||||
val columns = if (LocalNarrowPane.current) 1 else 3
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
|
||||
items.chunked(columns).forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
row.forEachIndexed { i, item ->
|
||||
key(item.key) {
|
||||
val m = Modifier.weight(1f).fillMaxHeight()
|
||||
val isFirst = first && r == 0 && i == 0
|
||||
when (item) {
|
||||
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
|
||||
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
|
||||
GridItem.Add -> AddTile(m, isFirst, onAdd)
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
// A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the
|
||||
// name no room at all.
|
||||
val columns = when {
|
||||
LocalNarrowPane.current -> 1
|
||||
maxWidth < 720.dp -> 2
|
||||
else -> 3
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
|
||||
items.chunked(columns).forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
row.forEachIndexed { i, item ->
|
||||
key(item.key) {
|
||||
val m = Modifier.weight(1f).fillMaxHeight()
|
||||
val isFirst = first && r == 0 && i == 0
|
||||
when (item) {
|
||||
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
|
||||
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
|
||||
GridItem.Add -> AddTile(m, isFirst, onAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -658,10 +673,14 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
|
||||
* contrast - dimming it read as disabled rather than one press from installing.
|
||||
*/
|
||||
@Composable
|
||||
private fun systemLabel(e: Library.Emulator): String =
|
||||
if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() }
|
||||
|
||||
@Composable
|
||||
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val system = e.system.replaceFirstChar { it.uppercase() }
|
||||
val system = systemLabel(e)
|
||||
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
|
||||
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
|
||||
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
@@ -71,7 +72,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
|
||||
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
|
||||
if (!s.shortcutPicker) Rise(2) {
|
||||
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
|
||||
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) }
|
||||
}
|
||||
GameFileFolderActions(s, a)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -162,6 +163,7 @@ private fun RailItem(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
|
||||
.testTag("rail-$key")
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
|
||||
.clip(Shape14)
|
||||
@@ -174,7 +176,7 @@ private fun RailItem(
|
||||
if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
|
||||
else Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (height < 56.dp) 20.dp else 22.dp))
|
||||
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false)
|
||||
FitText(label, maxSize = 12.sp, modifier = Modifier.padding(horizontal = 4.dp), fontWeight = FontWeight.SemiBold, color = fg)
|
||||
}
|
||||
if (badge) Box(
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
|
||||
|
||||
@@ -91,6 +91,8 @@ class FrontEndState(
|
||||
val frameGenLabel: String,
|
||||
val romsDir: String?,
|
||||
val logsEnabled: Boolean,
|
||||
/** Setup's Debugging tools toggle (AgentAccess): off unless the user turns it on. */
|
||||
val agentCommands: Boolean = false,
|
||||
val steamGames: List<Library.SteamGame>,
|
||||
val emulators: List<Library.Emulator>,
|
||||
val running: String?,
|
||||
@@ -101,6 +103,8 @@ class FrontEndState(
|
||||
val gameSyncFolder: String? = null,
|
||||
val pageKey: String? = null,
|
||||
val theme: String = Themes.GRAPHITE,
|
||||
/** The language picked in Setup, or AppLanguage.SYSTEM to follow the system. */
|
||||
val language: String = "",
|
||||
val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE,
|
||||
val isHomeApp: Boolean = false,
|
||||
val homeScreenEnabled: Boolean = false,
|
||||
@@ -164,11 +168,13 @@ class FrontEndActions(
|
||||
val onFiles: () -> Unit,
|
||||
val onBrowseFiles: (File) -> Unit = {},
|
||||
val onLogs: () -> Unit,
|
||||
val onAgentCommands: () -> Unit = {},
|
||||
val onShareLogs: () -> Unit = {},
|
||||
val onClearLogs: () -> Unit = {},
|
||||
val onOffline: () -> Unit,
|
||||
val onPageBack: () -> Unit = {},
|
||||
val onTheme: (String) -> Unit = {},
|
||||
val onLanguage: (String) -> Unit = {},
|
||||
val onAppScale: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
@@ -179,10 +185,10 @@ class FrontEndActions(
|
||||
val onBackActionsInverted: (Boolean) -> Unit = {},
|
||||
val onRefreshPhantomStatus: () -> Unit = {},
|
||||
val onOpenDeveloperOptions: (Int?) -> Unit = {},
|
||||
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
|
||||
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit,
|
||||
val onFindWirelessAdbPort: (String, (Int?) -> Unit) -> Unit = { _, done -> done(null) },
|
||||
val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
|
||||
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") },
|
||||
val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit,
|
||||
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit,
|
||||
val onCopyPhantomCommand: (Boolean) -> Unit = {},
|
||||
val onDismissPhantomGate: () -> Unit = {},
|
||||
val onStartWirelessAdbPairing: () -> Unit = {},
|
||||
|
||||
@@ -71,6 +71,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.core.AppLanguage
|
||||
import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
@@ -158,7 +159,7 @@ internal fun SetupPanel(
|
||||
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
|
||||
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
|
||||
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
|
||||
val gpuName = remember { DeviceSupport.gpuName() }
|
||||
val gpuName = remember { DeviceSupport.gpuName(ctx) }
|
||||
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
|
||||
val signedIn = s.offlineAccount != null
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -202,8 +203,8 @@ internal fun SetupPanel(
|
||||
else -> stringResource(R.string.setup_gpu_unsupported)
|
||||
},
|
||||
when (gpu.support) {
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.name, gpuName)
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.name, gpu.supportText.replaceFirstChar { it.lowercase() })
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.displayName(ctx), gpuName)
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() })
|
||||
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
|
||||
},
|
||||
)
|
||||
@@ -237,7 +238,7 @@ internal fun SetupPanel(
|
||||
PhantomProcessStatus.ENABLED -> stringResource(R.string.setup_limit_on)
|
||||
PhantomProcessStatus.UNSET -> stringResource(R.string.setup_limit_unset)
|
||||
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
|
||||
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
|
||||
else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus)
|
||||
},
|
||||
) {
|
||||
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
|
||||
@@ -250,8 +251,8 @@ internal fun SetupPanel(
|
||||
// raw command live on the full page Wireless debugging opens.
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
|
||||
Text(
|
||||
if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)
|
||||
else PhantomProcessLimit.instructions(s.phantomProcessStatus),
|
||||
if (limitBlocks) PhantomProcessLimit.gateInstructions(ctx, s.phantomProcessStatus)
|
||||
else PhantomProcessLimit.instructions(ctx, s.phantomProcessStatus),
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
Actions {
|
||||
@@ -270,7 +271,13 @@ internal fun SetupPanel(
|
||||
CheckRow(
|
||||
if (signedIn) CheckState.OK else CheckState.WARN,
|
||||
stringResource(R.string.setup_account),
|
||||
s.offlineAccount?.let { if (s.offline) stringResource(R.string.setup_signed_in_offline, it) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in),
|
||||
s.offlineAccount?.let {
|
||||
when {
|
||||
it.isEmpty() -> stringResource(if (s.offline) R.string.setup_signed_in_offline_unnamed else R.string.setup_signed_in_unnamed)
|
||||
s.offline -> stringResource(R.string.setup_signed_in_offline, it)
|
||||
else -> stringResource(R.string.setup_signed_in, it)
|
||||
}
|
||||
} ?: stringResource(R.string.setup_sign_in),
|
||||
divider = false,
|
||||
)
|
||||
}
|
||||
@@ -287,10 +294,10 @@ internal fun SetupPanel(
|
||||
2 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_session)) {
|
||||
ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
|
||||
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
|
||||
@@ -300,18 +307,20 @@ internal fun SetupPanel(
|
||||
}
|
||||
}
|
||||
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
|
||||
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
|
||||
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
|
||||
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
|
||||
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
|
||||
ToggleRow(
|
||||
host, "offline", stringResource(R.string.setup_offline),
|
||||
s.offlineAccount?.let { stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
|
||||
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
|
||||
s.offline, enabled = s.offlineAccount != null,
|
||||
) { a.onOffline() }
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_launcher)) {
|
||||
LanguageRow(host, s.language, a.onLanguage)
|
||||
ChoiceRow(
|
||||
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
|
||||
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
|
||||
@@ -323,10 +332,10 @@ internal fun SetupPanel(
|
||||
)
|
||||
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
|
||||
Box {
|
||||
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
|
||||
Themes.all.forEachIndexed { index, theme ->
|
||||
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTheme(theme.id)
|
||||
host.open = null
|
||||
}
|
||||
@@ -493,3 +502,32 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi
|
||||
Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's language: "System default" (what Android uses, named in brackets) or one of the
|
||||
* languages the app ships, each in its own script. Steam is set to the same language when it starts.
|
||||
*/
|
||||
@Composable
|
||||
private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val systemName = AppLanguage.displayName(AppLanguage.system(context))
|
||||
val systemLabel = stringResource(R.string.setup_language_system, systemName)
|
||||
SettingsRow(stringResource(R.string.setup_language), stringResource(R.string.setup_language_hint)) {
|
||||
Box {
|
||||
ValueChip(if (chosen == AppLanguage.SYSTEM) stringResource(R.string.setup_language_system_short) else AppLanguage.nativeName(chosen),
|
||||
host.open == "language") { host.open = if (host.open == "language") null else "language" }
|
||||
AnchoredMenu(host.open == "language", onDismiss = { if (host.open == "language") host.open = null }, title = stringResource(R.string.setup_language)) { firstItemFocus ->
|
||||
MenuItem(systemLabel, checked = chosen == AppLanguage.SYSTEM, focusRequester = firstItemFocus) {
|
||||
host.open = null
|
||||
onPick(AppLanguage.SYSTEM)
|
||||
}
|
||||
AppLanguage.supported.forEach { tag ->
|
||||
MenuItem(AppLanguage.nativeName(tag), checked = chosen == tag) {
|
||||
host.open = null
|
||||
onPick(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: St
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
|
||||
if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
@@ -235,9 +235,11 @@ private fun DeviceCard(s: GpuDriversState) {
|
||||
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
|
||||
// needs: a long one (in some languages) would squeeze the name to a letter a line.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(
|
||||
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -38,6 +39,16 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
|
||||
}
|
||||
}
|
||||
|
||||
/** A [DriverRow.tag] as shown, in the app's language. */
|
||||
@Composable
|
||||
internal fun driverTagLabel(tag: String): String = when (tag) {
|
||||
DriverRow.BUNDLED -> stringResource(R.string.driver_tag_bundled)
|
||||
DriverRow.DOWNLOADED -> stringResource(R.string.driver_tag_downloaded)
|
||||
DriverRow.IMPORTED -> stringResource(R.string.driver_tag_imported)
|
||||
DriverRow.BUNDLE -> stringResource(R.string.driver_tag_bundle)
|
||||
else -> tag
|
||||
}
|
||||
|
||||
/** A release driver (Banners-Turnip, WinNative) that is not installed yet; [key] is its asset name. */
|
||||
class DownloadRow(val key: String, val label: String, val detail: String, val progress: Int? = null)
|
||||
|
||||
@@ -48,13 +59,15 @@ class ModeSettings(
|
||||
val hdr: Boolean,
|
||||
val hdrReason: String?,
|
||||
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
|
||||
val gpuDrivers: String = "Auto",
|
||||
/** The drivers line; null shows Auto. */
|
||||
val gpuDrivers: String? = null,
|
||||
/** Frames per second the session is capped at; 0 = none. */
|
||||
val fpsLimit: Int = 0,
|
||||
val upscaler: Int = 0,
|
||||
val upscaleSharpness: Int = 75,
|
||||
val touchMode: String,
|
||||
val suspendPolicy: String,
|
||||
val steamDownloadsInBackground: Boolean = false,
|
||||
val pipSupported: Boolean = false,
|
||||
val pipAutoEnter: Boolean = false,
|
||||
/** Steam only. */
|
||||
@@ -62,11 +75,11 @@ class ModeSettings(
|
||||
/** Steam only: whether single and double Back actions are swapped. */
|
||||
val backActionsInverted: Boolean = false,
|
||||
val directAudio: Boolean?,
|
||||
val clientDirectAudio: Boolean = false,
|
||||
val clientDirectAudio: Boolean = true,
|
||||
val mic: Boolean?,
|
||||
val renderer: String?,
|
||||
val gameStorage: String? = null,
|
||||
val storageOptions: List<Pair<String, String>> = emptyList(),
|
||||
val storageOptions: List<com.droiddeck.launcher.session.GameStorage.Option> = emptyList(),
|
||||
/** Steam only: record allocation and sampled storage timings in the next session's Share logs. */
|
||||
val storageDiagnostics: Boolean = false,
|
||||
val fexPreset: String? = null,
|
||||
@@ -103,6 +116,8 @@ class ModeSettings(
|
||||
val deckyChecking: Boolean = false,
|
||||
val deckyStage: String? = null,
|
||||
val deckyPercent: Int = -1,
|
||||
/** The stage is the Decky plugin's binary download. */
|
||||
val deckyDownloadingBinary: Boolean = false,
|
||||
val deckyEnabled: Boolean = false,
|
||||
val deckySessionRunning: Boolean = false,
|
||||
)
|
||||
@@ -120,6 +135,7 @@ class ModeSettingsActions(
|
||||
val onUpscaleSharpness: (Int) -> Unit = {},
|
||||
val onTouch: (String) -> Unit,
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
|
||||
val onPipAutoEnter: (Boolean) -> Unit = {},
|
||||
val onOsc: (String) -> Unit,
|
||||
val onBackActionsInverted: (Boolean) -> Unit = {},
|
||||
@@ -217,7 +233,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices, s.upscaler,
|
||||
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
|
||||
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
|
||||
)
|
||||
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
|
||||
@@ -227,7 +243,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_frame_rate)) {
|
||||
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
|
||||
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
@@ -239,7 +255,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
|
||||
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
|
||||
ValueChip(s.gpuDrivers ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,10 +287,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
|
||||
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
}
|
||||
@@ -288,11 +304,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
) + if (steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
|
||||
) + if (steam) listOf(
|
||||
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
|
||||
) else emptyList(),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
|
||||
host, "background-downloads", stringResource(R.string.mode_background_downloads),
|
||||
stringResource(R.string.mode_background_downloads_hint),
|
||||
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
|
||||
)
|
||||
}
|
||||
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
@@ -349,11 +372,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onChange = a.onDeckyEnabled,
|
||||
)
|
||||
SettingsRow(
|
||||
"Plugins",
|
||||
"Install a plugin ZIP for the next Steam session.",
|
||||
stringResource(R.string.mode_decky_plugins),
|
||||
stringResource(R.string.mode_decky_plugins_hint),
|
||||
) {
|
||||
SecondaryButton(
|
||||
if (s.deckyStage?.startsWith("Downloading plugin binary") == true) "Downloading…" else "Install from ZIP",
|
||||
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
|
||||
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
|
||||
onClick = a.onPickDeckyPluginZip,
|
||||
)
|
||||
@@ -471,12 +494,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
|
||||
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage }
|
||||
// Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with.
|
||||
class StorageChoice(val path: String, val label: String, val chip: String, val saved: String)
|
||||
val internal = stringResource(R.string.mode_storage_internal)
|
||||
val options = buildList {
|
||||
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
|
||||
add("off" to stringResource(R.string.mode_storage_internal))
|
||||
for ((label, path) in s.storageOptions) add(path to label)
|
||||
if (custom) add(s.gameStorage to stringResource(R.string.mode_storage_folder, s.gameStorage))
|
||||
add(StorageChoice(
|
||||
"", if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto),
|
||||
stringResource(R.string.mode_storage_auto_short), "",
|
||||
))
|
||||
add(StorageChoice("off", internal, internal, ""))
|
||||
for (o in s.storageOptions) add(StorageChoice(o.path, o.label, o.label, o.name))
|
||||
if (custom) stringResource(R.string.mode_storage_folder, s.gameStorage).let { add(StorageChoice(s.gameStorage, it, it, it)) }
|
||||
}
|
||||
val open = host.open == "storage"
|
||||
SettingsRow(
|
||||
@@ -485,14 +514,14 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
highlighted = open,
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box {
|
||||
ValueChip(options.firstOrNull { it.first == s.gameStorage }?.second?.substringBefore(" -") ?: "-", open) { host.open = if (open) null else "storage" }
|
||||
ValueChip(options.firstOrNull { it.path == s.gameStorage }?.chip ?: "-", open) { host.open = if (open) null else "storage" }
|
||||
AnchoredMenu(
|
||||
open, onDismiss = { if (host.open == "storage") host.open = null }, title = stringResource(R.string.mode_second_library),
|
||||
note = stringResource(R.string.mode_storage_note),
|
||||
) { firstItemFocus ->
|
||||
options.forEachIndexed { index, (path, label) ->
|
||||
MenuItem(label, checked = path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onGameStorage(path, if (path.isEmpty() || path == "off") "" else label.substringBefore(" ·"))
|
||||
options.forEachIndexed { index, choice ->
|
||||
MenuItem(choice.label, checked = choice.path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onGameStorage(choice.path, choice.saved)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -63,40 +64,42 @@ import androidx.compose.ui.unit.sp
|
||||
* modifier for the next key, a second tap locks it, a third lets it go. A controller moves over the
|
||||
* keys with the d-pad and presses with A.
|
||||
*/
|
||||
private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null)
|
||||
private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null, @StringRes val labelRes: Int = 0)
|
||||
|
||||
private fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted)
|
||||
|
||||
private fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes)
|
||||
|
||||
private val ROWS: List<List<PcKey>> = listOf(
|
||||
listOf(
|
||||
k("Esc", 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
|
||||
k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
|
||||
k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88),
|
||||
k("Ins", 110), k("Del", 111), k("Home", 102), k("End", 107), k("PgUp", 104), k("PgDn", 109),
|
||||
k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109),
|
||||
),
|
||||
listOf(
|
||||
k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"),
|
||||
k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"),
|
||||
k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"),
|
||||
k("=", 13, shifted = "+"), k("⌫ Backspace", 14, 2f),
|
||||
k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f),
|
||||
),
|
||||
listOf(
|
||||
k("Tab ⇥", 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
|
||||
k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
|
||||
k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"),
|
||||
k("\\", 43, 1.5f, shifted = "|"),
|
||||
),
|
||||
listOf(
|
||||
k("Caps", 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
|
||||
k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
|
||||
k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""),
|
||||
k("Enter ⏎", 28, 2.25f),
|
||||
k(R.string.pckb_key_enter, 28, 2.25f),
|
||||
),
|
||||
listOf(
|
||||
k("⇧ Shift", 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
|
||||
k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
|
||||
k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"),
|
||||
k("⇧ Shift", 54, 1.75f), k("↑", 103),
|
||||
k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103),
|
||||
),
|
||||
listOf(
|
||||
k("Ctrl", 29, 1.5f), k("Super", 125, 1.25f), k("Alt", 56, 1.25f), k("Space", 57, 6.25f),
|
||||
k("AltGr", 100, 1.25f), k("Ctrl", 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
|
||||
k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f),
|
||||
k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -171,6 +174,7 @@ fun PcKeyboard(
|
||||
val state = mods[key.code] ?: MOD_OFF
|
||||
val lit = state != MOD_OFF || (key.code == 58 && capsOn)
|
||||
val label = when {
|
||||
key.labelRes != 0 -> stringResource(key.labelRes)
|
||||
shifted && key.shifted != null -> key.shifted
|
||||
key.label.length == 1 && key.label[0].isLetter() && (shifted xor capsOn) -> key.label.uppercase()
|
||||
else -> key.label
|
||||
|
||||
@@ -202,7 +202,7 @@ private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
|
||||
private fun StatusGroup(status: PhantomProcessStatus, compact: Boolean) {
|
||||
SettingsGroup(stringResource(R.string.gate_status), compact = compact) {
|
||||
Column(Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(PhantomProcessLimit.title(status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(PhantomProcessLimit.title(LocalContext.current, status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
|
||||
Body(stringResource(R.string.gate_polling))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,22 +226,24 @@ class SecondScreenPresentation(
|
||||
}
|
||||
keys.addView(button)
|
||||
}
|
||||
key("Esc", "\u001b")
|
||||
key("Tab", "\t")
|
||||
key("Ctrl", "") { button ->
|
||||
val ctrl = context.getString(R.string.pckb_key_ctrl)
|
||||
val alt = context.getString(R.string.pckb_key_alt)
|
||||
key(context.getString(R.string.pckb_key_esc), "\u001b")
|
||||
key(context.getString(R.string.second_key_tab), "\t")
|
||||
key(ctrl, "") { button ->
|
||||
ctrlActive = !ctrlActive
|
||||
button.text = if (ctrlActive) "Ctrl ✓" else "Ctrl"
|
||||
button.text = if (ctrlActive) context.getString(R.string.second_key_on, ctrl) else ctrl
|
||||
}
|
||||
key("Alt", "") { button ->
|
||||
key(alt, "") { button ->
|
||||
altActive = !altActive
|
||||
button.text = if (altActive) "Alt ✓" else "Alt"
|
||||
button.text = if (altActive) context.getString(R.string.second_key_on, alt) else alt
|
||||
}
|
||||
key("↑", "\u001b[A")
|
||||
key("↓", "\u001b[B")
|
||||
key("←", "\u001b[D")
|
||||
key("→", "\u001b[C")
|
||||
key("Ctrl+C", "\u0003")
|
||||
key("Ctrl+D", "\u0004")
|
||||
key(context.getString(R.string.second_key_ctrl_c), "\u0003")
|
||||
key(context.getString(R.string.second_key_ctrl_d), "\u0004")
|
||||
keyStrip.addView(keys)
|
||||
root.addView(keyStrip, LinearLayout.LayoutParams(-1, -2))
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
@@ -108,31 +109,24 @@ private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
|
||||
)
|
||||
|
||||
/**
|
||||
* The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines
|
||||
* are the session script's "== STEP" milestones and the installer's own progress; they arrive in
|
||||
* order, and the screen never steps back.
|
||||
* The checklist stage a loading line's [topic] belongs to, -1 when it says nothing about the stage.
|
||||
* The lines are the session script's "== STEP" milestones and the installer's own progress; they
|
||||
* arrive in order, and the screen never steps back.
|
||||
*/
|
||||
private fun stageOf(step: String, steam: Boolean): Int {
|
||||
val t = step.lowercase()
|
||||
return when {
|
||||
"linux runtime" in t -> 0
|
||||
"starting the session" in t -> 1
|
||||
!steam && "desktop" in t -> 1
|
||||
steam && "starting the steam client" in t -> 3
|
||||
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and install lines are worth reading as they are; the script's own notes are not. */
|
||||
private fun readableStep(step: String): Boolean {
|
||||
val t = step.lowercase()
|
||||
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
|
||||
private fun stageOf(topic: LoadingState.Topic, steam: Boolean): Int = when (topic) {
|
||||
LoadingState.Topic.RUNTIME -> 0
|
||||
LoadingState.Topic.SESSION -> 1
|
||||
LoadingState.Topic.DESKTOP -> if (steam) -1 else 1
|
||||
LoadingState.Topic.STEAM_STARTING -> if (steam) 3 else -1
|
||||
LoadingState.Topic.STEAM -> if (steam) 2 else -1
|
||||
LoadingState.Topic.OTHER -> -1
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingOverlay(
|
||||
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
|
||||
/** What [step] is about, and whether it is a download or install line worth showing as it is (see [LoadingState]). */
|
||||
topic: LoadingState.Topic, readable: Boolean,
|
||||
title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null,
|
||||
/** An ended session's exit status and log path, shown small under the advice. */
|
||||
endedDetail: String? = null,
|
||||
@@ -147,12 +141,12 @@ fun LoadingOverlay(
|
||||
var ball by remember { mutableStateOf<Pair<Offset, Float>?>(null) }
|
||||
val stages = remember(steam) { loadStages(steam) }
|
||||
var reached by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(step, steam) {
|
||||
val at = stageOf(step, steam)
|
||||
LaunchedEffect(step, topic, steam) {
|
||||
val at = stageOf(topic, steam)
|
||||
if (at > reached) reached = at
|
||||
}
|
||||
val active = reached.coerceAtMost(stages.lastIndex)
|
||||
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
|
||||
val detail = if (percent >= 0 || readable) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
|
||||
val context = LocalContext.current
|
||||
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
|
||||
val games by produceState(emptyList<Library.SteamGame>()) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.Canvas
|
||||
@@ -191,6 +192,7 @@ class DrawerActions(
|
||||
val oscMode: String,
|
||||
val onScreenButtonsVisible: Boolean,
|
||||
val suspendPolicy: String,
|
||||
val steamDownloadsInBackground: Boolean = false,
|
||||
val backActionsInverted: Boolean,
|
||||
val touchMode: String,
|
||||
val touchAuto: String,
|
||||
@@ -221,6 +223,7 @@ class DrawerActions(
|
||||
val onQamButton: (Boolean) -> Unit = {},
|
||||
val onKeyboardButton: (Boolean) -> Unit = {},
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
|
||||
val onBackActionsInverted: (Boolean) -> Unit,
|
||||
val onTouch: (String) -> Unit,
|
||||
val onFexPreset: (String) -> Unit,
|
||||
@@ -308,7 +311,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
|
||||
)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize().exposeTestTags(), contentAlignment = Alignment.CenterEnd) {
|
||||
// A 4:3 or near-square screen (under 440dp tall) gets tighter padding and a shorter tab row,
|
||||
// so the settings keep most of the height; a narrow one keeps some of the game in view.
|
||||
val short = maxHeight < 440.dp
|
||||
@@ -397,7 +400,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices, a.upscaler,
|
||||
SessionPrefs.upscalerChoices(LocalContext.current), a.upscaler,
|
||||
note = stringResource(R.string.display_filter_note),
|
||||
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
|
||||
if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow(
|
||||
@@ -433,7 +436,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
}
|
||||
}
|
||||
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
|
||||
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
|
||||
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
|
||||
@@ -499,12 +502,20 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
) + if (a.steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
|
||||
) + if (a.steam) listOf(
|
||||
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
|
||||
) else emptyList(),
|
||||
a.suspendPolicy,
|
||||
note = stringResource(if (a.steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
chipModifier = focus.track(page, "suspend"),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (a.steam && a.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
|
||||
host, "background-downloads", stringResource(R.string.mode_background_downloads),
|
||||
stringResource(R.string.mode_background_downloads_hint),
|
||||
a.steamDownloadsInBackground,
|
||||
onChange = a.onSteamDownloadsInBackground,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_support)) {
|
||||
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
|
||||
@@ -611,7 +622,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
|
||||
val stopDescription = stringResource(R.string.stop_session)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }
|
||||
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }.testTag("drawer-stop")
|
||||
.clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f))
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
@@ -722,6 +733,7 @@ private fun DrawerPageTabs(page: SessionDrawerPage, pages: List<SessionDrawerPag
|
||||
)
|
||||
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
|
||||
.semantics { contentDescription = pageDescription }
|
||||
.testTag("drawer-tab-${item.name.lowercase()}")
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
|
||||
.controllerConfirm(onClick = select),
|
||||
@@ -747,7 +759,7 @@ private fun ScreenEffectsGroup(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
SettingsGroup(stringResource(R.string.drawer_effects)) {
|
||||
val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name }
|
||||
val presets = ScreenEffectLooks.LOOKS.map { it.name to stringResource(it.label) }
|
||||
ChoiceRow(host, "look", stringResource(R.string.drawer_look), null,
|
||||
if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets,
|
||||
preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name ->
|
||||
@@ -825,7 +837,7 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
Text(stringResource(mode.label), fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
RadioButton(selected, onClick = null,
|
||||
colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant))
|
||||
}
|
||||
@@ -842,9 +854,9 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
|
||||
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
SettingsGroup(stringResource(R.string.drawer_texture)) {
|
||||
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
|
||||
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
|
||||
SessionPrefs.textureAnisotropyChoices(LocalContext.current), a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
|
||||
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
|
||||
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
|
||||
SessionPrefs.textureLodBiasChoices(LocalContext.current), a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
|
||||
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
|
||||
}
|
||||
}
|
||||
@@ -1162,14 +1174,14 @@ private fun StopConfirm(
|
||||
ConfirmChoice(
|
||||
stringResource(R.string.common_cancel), danger = false, enabled = open,
|
||||
modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() }
|
||||
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused },
|
||||
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused }.testTag("drawer-stop-cancel"),
|
||||
onClick = onCancel,
|
||||
)
|
||||
ConfirmChoice(
|
||||
stringResource(R.string.drawer_stop), danger = true, enabled = open,
|
||||
modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() }
|
||||
.onFocusChanged { stopFocused = it.isFocused }
|
||||
.onGloballyPositioned { stopCoords = it },
|
||||
.onGloballyPositioned { stopCoords = it }.testTag("drawer-stop-confirm"),
|
||||
) {
|
||||
val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) }
|
||||
onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat()))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.view.KeyEvent
|
||||
@@ -51,9 +52,13 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -67,6 +72,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -146,6 +152,7 @@ internal fun Modifier.bumpers(onPrevious: () -> Unit, onNext: () -> Unit): Modif
|
||||
* [bumpers]); the keycaps are for touch and stay out of the d-pad's path. The row scrolls sideways
|
||||
* when a narrow page cannot fit every tab.
|
||||
*/
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun TabStrip(
|
||||
tabs: List<String>, selected: Int, onSelect: (Int) -> Unit,
|
||||
@@ -154,11 +161,17 @@ internal fun TabStrip(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
// Tabs that overflow (long labels on a small phone) first close up; only what still does not
|
||||
// fit scrolls. Once tight the row stays tight, so it cannot flip back and forth.
|
||||
val scroll = rememberScrollState()
|
||||
var tight by remember(tabs) { mutableStateOf(false) }
|
||||
LaunchedEffect(scroll.maxValue) { if (scroll.maxValue in 1 until Int.MAX_VALUE) tight = true }
|
||||
val tabPadding = if (tight) 10.dp else 16.dp
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) {
|
||||
BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) }
|
||||
Row(
|
||||
modifier = Modifier.weight(1f, fill = false).clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape)
|
||||
.horizontalScroll(rememberScrollState()).padding(3.dp),
|
||||
.horizontalScroll(scroll).padding(3.dp),
|
||||
) {
|
||||
tabs.forEachIndexed { i, label ->
|
||||
val on = i == selected
|
||||
@@ -166,17 +179,22 @@ internal fun TabStrip(
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val pick = { onSelect(i) }
|
||||
val tabShape = RoundedCornerShape(9.dp)
|
||||
// The chosen tab scrolls fully into view, so one at the end is not left cut off.
|
||||
val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
|
||||
if (on) LaunchedEffect(Unit) { reveal.bringIntoView() }
|
||||
Text(
|
||||
label, fontSize = 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1,
|
||||
color = if (on) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.bringIntoViewRequester(reveal)
|
||||
.testTag("tab-$i")
|
||||
.then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier)
|
||||
.clip(tabShape)
|
||||
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.padding(horizontal = tabPadding, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -269,7 +287,34 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
|
||||
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
)
|
||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { content(firstItemFocus) }
|
||||
// While entries remain below the fold the list fades out at the bottom over a down
|
||||
// arrow, so a short screen (a phone in landscape) shows that the menu goes on.
|
||||
val scroll = rememberScrollState()
|
||||
Box(Modifier.weight(1f, fill = false)) {
|
||||
Column(
|
||||
Modifier
|
||||
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
if (scroll.canScrollForward) {
|
||||
val fade = 36.dp.toPx().coerceAtMost(size.height / 3)
|
||||
drawRect(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
listOf(Color.Black, Color.Transparent), startY = size.height - fade, endY = size.height,
|
||||
),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(0f, size.height - fade),
|
||||
size = androidx.compose.ui.geometry.Size(size.width, fade),
|
||||
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
}
|
||||
.verticalScroll(scroll),
|
||||
) { content(firstItemFocus) }
|
||||
if (scroll.canScrollForward) Icon(
|
||||
Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = colors.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).size(20.dp),
|
||||
)
|
||||
}
|
||||
if (note != null) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
@@ -280,6 +325,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MenuItem(
|
||||
label: String, checked: Boolean, enabled: Boolean = true, detail: String? = null,
|
||||
@@ -291,9 +337,13 @@ fun MenuItem(
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val shift by animateFloatAsState(if (hot) 2f else 0f, Motion.sp(0.5f), label = "miShift")
|
||||
// A menu taller than the screen opens scrolled to the current choice, not past it.
|
||||
val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
|
||||
if (checked) LaunchedEffect(Unit) { reveal.bringIntoView() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
.bringIntoViewRequester(reveal)
|
||||
.graphicsLayer { translationX = shift.dp.toPx() }
|
||||
.then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
@@ -408,7 +458,7 @@ fun <T> ChoiceRow(
|
||||
val open = host.open == key
|
||||
SettingsRow(label, hint, highlighted = open, hintLines = hintLines) {
|
||||
Box {
|
||||
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier) { host.open = if (open) null else key }
|
||||
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier.testTag("setting-$key")) { host.open = if (open) null else key }
|
||||
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
|
||||
options.forEachIndexed { index, (value, text) ->
|
||||
MenuItem(text, checked = value == selected, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
@@ -424,7 +474,7 @@ fun <T> ChoiceRow(
|
||||
@Composable
|
||||
fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) =
|
||||
SettingsRow(label, hint) {
|
||||
ToggleSwitch(checked, enabled, label, chipModifier) { host.open = null; onChange(it) }
|
||||
ToggleSwitch(checked, enabled, label, chipModifier.testTag("setting-$key")) { host.open = null; onChange(it) }
|
||||
}
|
||||
|
||||
/** An on/off switch: one tap or one A press flips it, where a menu of On and Off took three. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
@@ -91,7 +92,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
// Enabled without a runtime: the session's loading screen installs it first.
|
||||
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay)
|
||||
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = a.onPlay)
|
||||
Cog(size = 54.dp, onClick = a.onSteamSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
|
||||
Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) }
|
||||
Rise(3) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
|
||||
FlathubApi.categories.forEach { c -> PillButton(c.label, selected = false) { onCategory(c.id) } }
|
||||
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } }
|
||||
}
|
||||
}
|
||||
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
|
||||
@@ -195,7 +195,7 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
|
||||
}
|
||||
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
|
||||
val apps = StoreState.sections[key]
|
||||
Rise(4 + i) { SectionTitle(title, apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: "loading…") }
|
||||
Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) }
|
||||
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
|
||||
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
|
||||
FlathubApi.categories.forEach { c ->
|
||||
PillButton(c.label, selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
|
||||
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
|
||||
/**
|
||||
* Compose test tags as Android resource ids below this node, so UI Automator (`uiautomator dump`,
|
||||
* droiddeckctl ui-dump) sees stable ids such as `rail-steam` or `setting-logs` rather than only
|
||||
* labels that change with the language. Tags are named in docs/agent-control.md.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun Modifier.exposeTestTags(): Modifier = semantics { testTagsAsResourceId = true }
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -9,13 +10,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import com.droiddeck.launcher.core.AppUiPrefs
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
class Palette(
|
||||
val id: String, val label: String, val detail: String,
|
||||
val id: String, @StringRes val label: Int, @StringRes val detail: Int,
|
||||
val background: Color, val surface: Color, val surfaceVariant: Color, val line: Color, val line2: Color,
|
||||
val onBackground: Color, val onSurfaceVariant: Color,
|
||||
val primary: Color, val primary2: Color, val onPrimary: Color, val signal: Color,
|
||||
@@ -30,28 +32,28 @@ object Themes {
|
||||
|
||||
val all: List<Palette> = listOf(
|
||||
Palette(
|
||||
GRAPHITE, "Graphite", "Graphite surfaces, with the logo's own orb blue for everything selected, focused or pressed.",
|
||||
GRAPHITE, R.string.theme_graphite, R.string.theme_graphite_detail,
|
||||
background = Color(0xFF0A0B0D), surface = Color(0xFF121417), surfaceVariant = Color(0xFF1A1D22), line = Color(0xFF262A31), line2 = Color(0xFF343A43),
|
||||
onBackground = Color(0xFFF2F4F7), onSurfaceVariant = Color(0xFF9AA3AF),
|
||||
primary = Color(0xFF1A9FFF), primary2 = Color(0xFF1487DB), onPrimary = Color(0xFF03111F), signal = Color(0xFF1A9FFF),
|
||||
good = Color(0xFF4CD37F),
|
||||
),
|
||||
Palette(
|
||||
PAPER, "Paper on black", "White is the primary, the icon's blue is the signal for rules, rings and dots.",
|
||||
PAPER, R.string.theme_paper, R.string.theme_paper_detail,
|
||||
background = Color(0xFF000000), surface = Color(0xFF0F0F10), surfaceVariant = Color(0xFF191A1C), line = Color(0xFF262729), line2 = Color(0xFF343638),
|
||||
onBackground = Color(0xFFF9F9F9), onSurfaceVariant = Color(0xFF8E9196),
|
||||
primary = Color(0xFFF9F9F9), primary2 = Color(0xFFD9DADD), onPrimary = Color(0xFF000000), signal = Color(0xFF136CE0),
|
||||
good = Color(0xFF4CD37F),
|
||||
),
|
||||
Palette(
|
||||
ICON_BLUE, "Icon blue", "Black ground, white type, the blue for everything selected, focused or pressed.",
|
||||
ICON_BLUE, R.string.theme_icon_blue, R.string.theme_icon_blue_detail,
|
||||
background = Color(0xFF050608), surface = Color(0xFF0E1116), surfaceVariant = Color(0xFF161B23), line = Color(0xFF1F2630), line2 = Color(0xFF2A3340),
|
||||
onBackground = Color(0xFFF2F4F7), onSurfaceVariant = Color(0xFF8A93A0),
|
||||
primary = Color(0xFF136CE0), primary2 = Color(0xFF0B4FB0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF136CE0),
|
||||
good = Color(0xFF4CD37F),
|
||||
),
|
||||
Palette(
|
||||
ELECTRIC, "Electric navy", "The blue brighter, on surfaces that lean navy instead of grey.",
|
||||
ELECTRIC, R.string.theme_electric, R.string.theme_electric_detail,
|
||||
background = Color(0xFF070A10), surface = Color(0xFF0F1522), surfaceVariant = Color(0xFF16203A), line = Color(0xFF1E2A47), line2 = Color(0xFF2A3A5E),
|
||||
onBackground = Color(0xFFEEF3FF), onSurfaceVariant = Color(0xFF8B9AB8),
|
||||
primary = Color(0xFF2E86FF), primary2 = Color(0xFF136CE0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF2E86FF),
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -104,25 +105,32 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(top = 6.dp),
|
||||
)
|
||||
val checked = u.catalog?.checkedAt?.takeIf { it > 0 }
|
||||
// The relative time comes capitalized to stand alone (Spanish "Hace 5 minutos"); here it
|
||||
// ends a sentence.
|
||||
if (!LocalNarrowPane.current) Text(
|
||||
if (u.checking) stringResource(R.string.common_checking) else if (checked != null) stringResource(R.string.upd_checked, ago(checked)) else stringResource(R.string.upd_not_checked),
|
||||
if (u.checking) stringResource(R.string.common_checking) else if (checked != null) stringResource(R.string.upd_checked, ago(checked).replaceFirstChar { it.lowercase() }) else stringResource(R.string.upd_not_checked),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.store_check_updates), busy = u.checking, enabled = !u.checking && u.stage == null, onClick = ua.onCheck)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
|
||||
// Landscape has the width for channels and status side by side; a narrow pane stacks them.
|
||||
if (LocalNarrowPane.current) {
|
||||
StatusPanel(s, u, ua, me)
|
||||
Box(Modifier.height(18.dp))
|
||||
ChannelLabel()
|
||||
ChannelPicker(u, ua)
|
||||
} else {
|
||||
// The label sits over both columns, so the status card lines up with the first channel.
|
||||
ChannelLabel()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
|
||||
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
// Landscape has the width for channels and status side by side; a narrow pane, or a small
|
||||
// phone's (where each channel's name and date would be cut to a word), stacks them. The
|
||||
// Thor's top screen leaves about 690dp here and keeps them side by side.
|
||||
val stacked = LocalNarrowPane.current || maxWidth < 600.dp
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
|
||||
if (stacked) {
|
||||
StatusPanel(s, u, ua, me)
|
||||
Box(Modifier.height(18.dp))
|
||||
ChannelLabel()
|
||||
ChannelPicker(u, ua)
|
||||
} else {
|
||||
// The label sits over both columns, so the status card lines up with the first channel.
|
||||
ChannelLabel()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
|
||||
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +187,7 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
|
||||
}
|
||||
if (look.detail != null) Text(look.detail, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
|
||||
if (installBlock != null && offered) {
|
||||
Text(installBlock, fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
|
||||
Text(installBlock.message(androidx.compose.ui.platform.LocalContext.current), fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
|
||||
}
|
||||
if (u.error != null) Text(u.error, fontSize = 13.sp, color = pal.error, modifier = Modifier.padding(top = 12.dp))
|
||||
val installable = release?.apk != null && installBlock == null && !s.sessionRunning
|
||||
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user