mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-18 05:07:22 -04:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef261d9724 | ||
|
|
9310d19857 | ||
|
|
4454a6d39a | ||
|
|
453f6f5180 | ||
|
|
6d0484941e | ||
|
|
cf47910881 | ||
|
|
4f0f0cf5b1 | ||
|
|
a78d5cb600 | ||
|
|
206c8a8846 | ||
|
|
bf17ce1151 | ||
|
|
ef38c975b9 | ||
|
|
61953c4f7b | ||
|
|
1f13285ab8 | ||
|
|
1bc23ac169 | ||
|
|
50f50b16e0 | ||
|
|
adc86fc7df | ||
|
|
f3fd3a2307 | ||
|
|
1e877fa602 | ||
|
|
906009a33a | ||
|
|
08559a02f8 | ||
|
|
1d93626453 | ||
|
|
9d0989374b | ||
|
|
3278afaa1a | ||
|
|
bf221ffbbd | ||
|
|
c636eea355 | ||
|
|
ffaa906cd1 | ||
|
|
d61af961c1 | ||
|
|
bc01b77752 | ||
|
|
a1b01a04a8 | ||
|
|
b44235c495 | ||
|
|
b80d5db176 | ||
|
|
d3c0d258b7 | ||
|
|
9dcdbf245a | ||
|
|
369e218afb | ||
|
|
119d24bf6b | ||
|
|
2a905510d5 | ||
|
|
da29919b5a | ||
|
|
05608ac4d4 | ||
|
|
135b84a501 | ||
|
|
e7cbba37d1 | ||
|
|
d9bb5e15b0 | ||
|
|
46cdc32cf1 |
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
|
||||
|
||||
@@ -57,6 +57,22 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Version bumps are isolated
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
base=$(git merge-base origin/main HEAD)
|
||||
diff=$(git diff -U0 "$base..HEAD" -- app/build.gradle)
|
||||
grep -Eq '^[+-][[:space:]]*version(Code|Name)[[:space:]]' <<<"$diff" || exit 0
|
||||
other_files=$(git diff --name-only "$base..HEAD" | grep -v '^app/build.gradle$' || true)
|
||||
other_gradle=$(grep -E '^[+-]' <<<"$diff" | grep -Ev '^(---|\+\+\+)' | grep -Ev '^[+-][[:space:]]*version(Code|Name)[[:space:]]' || true)
|
||||
if [[ -n "$other_files" || -n "$other_gradle" ]]; then
|
||||
echo "::error::versionCode/versionName changes must be isolated in their own PR"
|
||||
echo "A version PR may only change the versionCode/versionName lines in app/build.gradle." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Merge onto main, or say what conflicts
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
@@ -157,7 +173,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -179,6 +195,10 @@ jobs:
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
|
||||
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
|
||||
mkdir -p "$D/usr/local/bin"
|
||||
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
|
||||
@@ -190,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"
|
||||
@@ -230,6 +250,26 @@ jobs:
|
||||
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
||||
rm -rf mango-pkgs
|
||||
ls -l "$M"
|
||||
# The Windows components installer's tools (tools/msitools/packages.txt).
|
||||
T="$D/usr/local/lib/droiddeck-msitools"
|
||||
mkdir -p "$T" msi-pkgs
|
||||
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
|
||||
[ -n "$url" ] || continue
|
||||
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
|
||||
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
|
||||
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
|
||||
rm msi-pkgs/pkg.tar.xz
|
||||
done
|
||||
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
|
||||
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
|
||||
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
|
||||
done
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
|
||||
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
|
||||
done
|
||||
rm -rf msi-pkgs
|
||||
ls -l "$T"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
|
||||
@@ -305,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
|
||||
@@ -343,11 +388,23 @@ jobs:
|
||||
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
|
||||
fi
|
||||
SESSION=$(syms "$D/libblsession.so")
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle drmIoctl; do
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
|
||||
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
|
||||
done
|
||||
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
|
||||
if grep -qx drmIoctl <<<"$SESSION"; then
|
||||
echo "::error::libblsession.so exports drmIoctl"; exit 1
|
||||
fi
|
||||
SSBS=$(syms "$D/libssbs.so")
|
||||
for sym in sigaction signal bsd_signal sysv_signal; do
|
||||
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
|
||||
done
|
||||
test -f "$D/usr/local/bin/droiddeck-session"
|
||||
test -f "$D/usr/local/bin/droiddeck-proton-extra"
|
||||
test -f "$D/usr/local/bin/droiddeck-msi-install"
|
||||
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Cache the CMake build tree
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -15,7 +15,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
|
||||
|
||||
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -190,6 +192,8 @@ dependencies {
|
||||
// The runtime tarball is a zstd-compressed tar.
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
// Bannerlator's Windows components come as .tar.xz (WinComponents).
|
||||
implementation 'org.tukaani:xz:1.10'
|
||||
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
|
||||
// on-screen pad are Views underneath it, which is what they are in every Compose app.
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Session logs go to Downloads so a failed run can be handed over as one folder. -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
@@ -71,6 +70,10 @@
|
||||
<action android:name="android.intent.action.CREATE_SHORTCUT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
|
||||
|
||||
@@ -44,6 +44,11 @@ add_subdirectory(waylandcomp)
|
||||
add_library(sdnative SHARED sdnative/sdnative.c)
|
||||
target_link_libraries(sdnative log)
|
||||
|
||||
# The device info window's Vulkan section: the phone's own driver, asked once through the system
|
||||
# loader (opened with dlopen, so a phone without Vulkan still loads the app).
|
||||
add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
|
||||
target_link_libraries(deviceinfo dl)
|
||||
|
||||
# Rebuild Termux terminal-emulator's PTY JNI helper with the app's NDK instead of carrying its
|
||||
# prebuilt libtermux.so with a redundant libstdc++.so dependency.
|
||||
add_library(termux SHARED termux/termux.c)
|
||||
@@ -0,0 +1,112 @@
|
||||
// What the phone's own Vulkan driver says about its GPU, for the app's device info window: one
|
||||
// instance on the system loader, the first physical device's properties, a few limits and the
|
||||
// extensions DXVK and VKD3D-Proton lean on. Returned as "key=value" lines; nothing is kept open.
|
||||
#include <dlfcn.h>
|
||||
#include <jni.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#define VK_NO_PROTOTYPES
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
#define LINE(...) do { if (used < sizeof(out)) used += snprintf(out + used, sizeof(out) - used, __VA_ARGS__); } while (0)
|
||||
|
||||
static const char *const EXTENSIONS[] = {
|
||||
"VK_EXT_robustness2", "VK_EXT_transform_feedback", "VK_EXT_custom_border_color",
|
||||
"VK_KHR_dynamic_rendering", "VK_EXT_extended_dynamic_state3", "VK_EXT_descriptor_indexing",
|
||||
"VK_KHR_timeline_semaphore", "VK_EXT_mesh_shader", "VK_KHR_ray_query",
|
||||
};
|
||||
|
||||
static const char *type_name(VkPhysicalDeviceType type) {
|
||||
switch (type) {
|
||||
case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_CPU: return "CPU";
|
||||
default: return "other";
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_VulkanInfo_nativeQuery(JNIEnv *env, jclass clazz) {
|
||||
static char out[4096];
|
||||
size_t used = 0;
|
||||
out[0] = 0;
|
||||
void *lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!lib) { LINE("error=no Vulkan loader\n"); return (*env)->NewStringUTF(env, out); }
|
||||
PFN_vkGetInstanceProcAddr gipa = (PFN_vkGetInstanceProcAddr) dlsym(lib, "vkGetInstanceProcAddr");
|
||||
PFN_vkCreateInstance create = gipa ? (PFN_vkCreateInstance) gipa(NULL, "vkCreateInstance") : NULL;
|
||||
PFN_vkEnumerateInstanceVersion instanceVersion = gipa ? (PFN_vkEnumerateInstanceVersion) gipa(NULL, "vkEnumerateInstanceVersion") : NULL;
|
||||
if (!create) { LINE("error=no vkCreateInstance\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
|
||||
uint32_t loader = VK_API_VERSION_1_0;
|
||||
if (instanceVersion) instanceVersion(&loader);
|
||||
LINE("loader=%u.%u.%u\n", VK_API_VERSION_MAJOR(loader), VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader));
|
||||
|
||||
VkApplicationInfo app = { .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .pApplicationName = "DroidDeck device info",
|
||||
.apiVersion = loader >= VK_API_VERSION_1_1 ? VK_API_VERSION_1_1 : VK_API_VERSION_1_0 };
|
||||
VkInstanceCreateInfo info = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &app };
|
||||
VkInstance instance = VK_NULL_HANDLE;
|
||||
if (create(&info, NULL, &instance) != VK_SUCCESS) { LINE("error=vkCreateInstance failed\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
|
||||
|
||||
#define GET(name) PFN_##name name = (PFN_##name) gipa(instance, #name)
|
||||
GET(vkDestroyInstance); GET(vkEnumeratePhysicalDevices); GET(vkGetPhysicalDeviceProperties);
|
||||
GET(vkGetPhysicalDeviceProperties2); GET(vkGetPhysicalDeviceMemoryProperties);
|
||||
GET(vkEnumerateDeviceExtensionProperties); GET(vkGetPhysicalDeviceFeatures);
|
||||
uint32_t count = 1;
|
||||
VkPhysicalDevice device = VK_NULL_HANDLE;
|
||||
VkResult listed = vkEnumeratePhysicalDevices ? vkEnumeratePhysicalDevices(instance, &count, &device) : VK_ERROR_INITIALIZATION_FAILED;
|
||||
if ((listed != VK_SUCCESS && listed != VK_INCOMPLETE) || count == 0 || !device || !vkGetPhysicalDeviceProperties) {
|
||||
LINE("error=no Vulkan device\n");
|
||||
} else {
|
||||
VkPhysicalDeviceProperties p;
|
||||
vkGetPhysicalDeviceProperties(device, &p);
|
||||
LINE("device=%s\n", p.deviceName);
|
||||
LINE("type=%s\n", type_name(p.deviceType));
|
||||
LINE("api=%u.%u.%u\n", VK_API_VERSION_MAJOR(p.apiVersion), VK_API_VERSION_MINOR(p.apiVersion), VK_API_VERSION_PATCH(p.apiVersion));
|
||||
// Qualcomm packs its driver version as 10.10.12 bits, the same layout as VK_MAKE_VERSION.
|
||||
LINE("driver_version=%u.%u.%u\n", p.driverVersion >> 22, (p.driverVersion >> 12) & 0x3ff, p.driverVersion & 0xfff);
|
||||
LINE("vendor_id=0x%04x\ndevice_id=0x%08x\n", p.vendorID, p.deviceID);
|
||||
LINE("max_image_2d=%u\n", p.limits.maxImageDimension2D);
|
||||
|
||||
// Driver name and build string (Vulkan 1.2 or VK_KHR_driver_properties).
|
||||
uint32_t extCount = 0;
|
||||
if (vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, NULL);
|
||||
static VkExtensionProperties exts[512];
|
||||
if (extCount > 512) extCount = 512;
|
||||
if (extCount && vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, exts);
|
||||
int driverProps = p.apiVersion >= VK_API_VERSION_1_2;
|
||||
for (uint32_t i = 0; i < extCount; i++)
|
||||
if (!strcmp(exts[i].extensionName, "VK_KHR_driver_properties")) driverProps = 1;
|
||||
if (driverProps && vkGetPhysicalDeviceProperties2) {
|
||||
VkPhysicalDeviceDriverProperties driver = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
|
||||
VkPhysicalDeviceProperties2 p2 = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &driver };
|
||||
vkGetPhysicalDeviceProperties2(device, &p2);
|
||||
LINE("driver_name=%s\n", driver.driverName);
|
||||
LINE("driver_info=%s\n", driver.driverInfo);
|
||||
LINE("conformance=%u.%u.%u.%u\n", driver.conformanceVersion.major, driver.conformanceVersion.minor,
|
||||
driver.conformanceVersion.subminor, driver.conformanceVersion.patch);
|
||||
}
|
||||
LINE("extension_count=%u\n", extCount);
|
||||
for (size_t e = 0; e < sizeof(EXTENSIONS) / sizeof(*EXTENSIONS); e++) {
|
||||
int has = 0;
|
||||
for (uint32_t i = 0; i < extCount && !has; i++) has = !strcmp(exts[i].extensionName, EXTENSIONS[e]);
|
||||
LINE("ext.%s=%s\n", EXTENSIONS[e], has ? "yes" : "no");
|
||||
}
|
||||
if (vkGetPhysicalDeviceFeatures) {
|
||||
VkPhysicalDeviceFeatures f;
|
||||
vkGetPhysicalDeviceFeatures(device, &f);
|
||||
LINE("geometry_shader=%s\ntessellation_shader=%s\ntexture_bc=%s\n",
|
||||
f.geometryShader ? "yes" : "no", f.tessellationShader ? "yes" : "no", f.textureCompressionBC ? "yes" : "no");
|
||||
}
|
||||
if (vkGetPhysicalDeviceMemoryProperties) {
|
||||
VkPhysicalDeviceMemoryProperties m;
|
||||
vkGetPhysicalDeviceMemoryProperties(device, &m);
|
||||
VkDeviceSize largest = 0;
|
||||
for (uint32_t i = 0; i < m.memoryHeapCount; i++)
|
||||
if ((m.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) && m.memoryHeaps[i].size > largest) largest = m.memoryHeaps[i].size;
|
||||
LINE("device_heap_mb=%llu\n", (unsigned long long) (largest >> 20));
|
||||
}
|
||||
}
|
||||
if (vkDestroyInstance) vkDestroyInstance(instance, NULL);
|
||||
dlclose(lib);
|
||||
return (*env)->NewStringUTF(env, out);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -65,6 +65,9 @@ import com.droiddeck.launcher.ui.FrontEndState
|
||||
import com.droiddeck.launcher.ui.FrontEndActions
|
||||
import com.droiddeck.launcher.frontend.CoverArt
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.frontend.GameFiles
|
||||
import com.droiddeck.launcher.frontend.GameFileSync
|
||||
import com.droiddeck.launcher.frontend.GameLaunchIntent
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.RomsDialog
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
@@ -81,6 +84,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)
|
||||
@@ -104,7 +112,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)
|
||||
@@ -151,8 +159,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 forceFullscreen 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)
|
||||
@@ -168,6 +175,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()
|
||||
@@ -251,29 +268,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 ->
|
||||
@@ -308,6 +328,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var settingsMode by mutableStateOf<String?>(null)
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var forceSsbs by mutableStateOf(false)
|
||||
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
|
||||
private var runSteamAtStartup by mutableStateOf(false)
|
||||
private var theme by mutableStateOf("graphite")
|
||||
@@ -319,12 +340,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)) }
|
||||
}
|
||||
@@ -355,15 +377,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
|
||||
}
|
||||
}
|
||||
@@ -399,10 +421,15 @@ class MainActivity : ComponentActivity() {
|
||||
private fun pickGameExport(game: Library.SteamGame?) {
|
||||
onSavePicked = { folder ->
|
||||
saveAction(getString(R.string.game_frontend_files)) {
|
||||
if (game != null) com.droiddeck.launcher.frontend.GameFiles.export(folder, game)
|
||||
else com.droiddeck.launcher.frontend.GameFileSync.enable(this, folder)
|
||||
ui.post { gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this) }
|
||||
getString(R.string.game_file_exported, folder.path)
|
||||
val saved = if (game != null) GameFiles.export(this, folder, game)
|
||||
else {
|
||||
GameFileSync.enable(this, folder)
|
||||
folder
|
||||
}
|
||||
ui.post {
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
}
|
||||
getString(R.string.game_file_exported, saved.path)
|
||||
}
|
||||
}
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
|
||||
@@ -416,10 +443,10 @@ class MainActivity : ComponentActivity() {
|
||||
shortcutLibraryScanning = true
|
||||
} else shortcutLibraryScanning = false
|
||||
pendingGameLink = null
|
||||
if (request.action == Intent.ACTION_VIEW) {
|
||||
if (GameLaunchIntent.accepts(request.action)) {
|
||||
val copy = Intent(request)
|
||||
Thread({
|
||||
val id = com.droiddeck.launcher.frontend.GameFiles.readIntent(this, copy)
|
||||
val id = GameLaunchIntent.read(this, copy)
|
||||
ui.post {
|
||||
if (intent === request && !isDestroyed) {
|
||||
pendingGameLink = id
|
||||
@@ -432,9 +459,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchGame(game: Library.SteamGame): Boolean {
|
||||
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) {
|
||||
@@ -447,10 +474,12 @@ class MainActivity : ComponentActivity() {
|
||||
return false
|
||||
}
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
return true
|
||||
}
|
||||
return startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
|
||||
}
|
||||
@@ -462,10 +491,14 @@ class MainActivity : ComponentActivity() {
|
||||
if (game == null) {
|
||||
pendingGameLink = null
|
||||
intent.data = null
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
|
||||
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
|
||||
} else if (launchGame(game)) {
|
||||
} else if (launchGame(game, returnHome = true)) {
|
||||
pendingGameLink = null
|
||||
intent.data = null
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,10 +510,11 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
readGameIntent(intent)
|
||||
gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this)
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
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)
|
||||
@@ -518,6 +552,7 @@ class MainActivity : ComponentActivity() {
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
|
||||
appScale = appScale,
|
||||
isHomeApp = homeAppSelected,
|
||||
homeScreenEnabled = homeScreenEnabled,
|
||||
@@ -556,7 +591,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
onStopGameFileSync = {
|
||||
Thread({
|
||||
com.droiddeck.launcher.frontend.GameFileSync.disable(this)
|
||||
GameFileSync.disable(this)
|
||||
ui.post { gameSyncFolder = null }
|
||||
}, "game-file-stop-sync").start()
|
||||
},
|
||||
@@ -624,20 +659,17 @@ class MainActivity : ComponentActivity() {
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
},
|
||||
onShareLogs = {
|
||||
Thread({
|
||||
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
|
||||
ui.post {
|
||||
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
}, "share-logs").start()
|
||||
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
|
||||
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))
|
||||
}
|
||||
},
|
||||
onClearLogs = {
|
||||
Thread({
|
||||
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()
|
||||
@@ -648,6 +680,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)
|
||||
@@ -680,7 +719,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()
|
||||
},
|
||||
@@ -693,7 +732,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)
|
||||
@@ -703,7 +742,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)
|
||||
@@ -712,9 +751,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) },
|
||||
@@ -748,24 +787,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 },
|
||||
)
|
||||
@@ -783,9 +822,6 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
|
||||
|
||||
// The session's logs land in Downloads so a failed run can be handed over as a folder
|
||||
// rather than dug out of app-private storage. targetSdk 28 means the old permission still
|
||||
// grants exactly that.
|
||||
val wanted = ArrayList<String>()
|
||||
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
@@ -875,7 +911,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()
|
||||
@@ -898,6 +934,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()
|
||||
}
|
||||
@@ -909,6 +950,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
|
||||
@@ -934,9 +977,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,7 +1014,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()
|
||||
@@ -1013,10 +1060,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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1034,14 +1081,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(
|
||||
@@ -1054,9 +1101,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() },
|
||||
),
|
||||
)
|
||||
@@ -1092,14 +1139,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()
|
||||
}
|
||||
@@ -1109,7 +1156,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 {
|
||||
@@ -1131,13 +1178,13 @@ 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,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
clientDirectAudio = clientDirectAudio,
|
||||
forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen else null,
|
||||
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
|
||||
mic = if (mode == SessionService.MODE_STEAM) mic else null,
|
||||
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
|
||||
@@ -1145,6 +1192,7 @@ class MainActivity : ComponentActivity() {
|
||||
storageOptions = storageOptions,
|
||||
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
forceSsbs = forceSsbs,
|
||||
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
|
||||
@@ -1163,6 +1211,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(
|
||||
@@ -1189,6 +1238,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 ->
|
||||
@@ -1197,7 +1250,6 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
|
||||
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
|
||||
onForceFullscreen = { on -> SessionPrefs.setForceFullscreen(this, on); forceFullscreen = on },
|
||||
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
|
||||
onMic = { on ->
|
||||
SessionPrefs.setMicEnabled(this, on)
|
||||
@@ -1211,13 +1263,14 @@ 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)
|
||||
storageDiagnostics = on
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
|
||||
onSyncBackend = { id ->
|
||||
SessionPrefs.setSyncBackend(this, id)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
@@ -1237,13 +1290,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() },
|
||||
@@ -1254,7 +1307,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 },
|
||||
),
|
||||
@@ -1310,7 +1363,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() {
|
||||
@@ -1339,6 +1392,7 @@ class MainActivity : ComponentActivity() {
|
||||
showMapping = false
|
||||
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
forceSsbs = SessionPrefs.forceSsbs(this)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
@@ -1356,11 +1410,11 @@ 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)
|
||||
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
|
||||
forceFullscreen = SessionPrefs.forceFullscreen(this)
|
||||
stretch16x9 = SessionPrefs.stretch16x9(this)
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
@@ -1373,7 +1427,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 {
|
||||
@@ -1392,9 +1446,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
|
||||
}
|
||||
}
|
||||
@@ -1436,9 +1490,9 @@ class MainActivity : ComponentActivity() {
|
||||
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
|
||||
logsEnabled = SessionPrefs.logsEnabled(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.
|
||||
@@ -1505,7 +1559,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
|
||||
}
|
||||
|
||||
@@ -1521,10 +1578,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1557,7 +1614,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 ->
|
||||
@@ -1582,6 +1639,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)
|
||||
@@ -229,7 +234,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var fillScreen by mutableStateOf(true)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
private var upscaleSharpness by mutableStateOf(75)
|
||||
private var effects by mutableStateOf(ScreenEffects.OFF)
|
||||
@@ -242,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)
|
||||
@@ -301,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
|
||||
}
|
||||
@@ -462,6 +467,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() },
|
||||
@@ -497,23 +503,22 @@ 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,
|
||||
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
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,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
|
||||
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
|
||||
onUpscaler = { m ->
|
||||
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
|
||||
WaylandCompositor.nativeSetUpscaler(m)
|
||||
@@ -559,6 +564,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,
|
||||
@@ -568,9 +578,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),
|
||||
@@ -688,7 +702,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()
|
||||
}
|
||||
@@ -703,10 +717,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()
|
||||
@@ -720,20 +734,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() {
|
||||
@@ -741,31 +757,25 @@ 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
|
||||
}
|
||||
Thread({
|
||||
val zip = runCatching { SessionLogShare.zipFolder(this, folder) }
|
||||
.onFailure { Log.w(TAG, "could not package current session logs", it) }
|
||||
.getOrNull()
|
||||
uiHandler.post {
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, "Could not create the session log archive.", 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()
|
||||
}
|
||||
}
|
||||
SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (zip == null) {
|
||||
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, R.string.session_logs_share_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}, "share-session-logs").start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrefs() {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
fillScreen = SessionPrefs.forceFullscreen(this)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
effects = SessionPrefs.screenEffects(this)
|
||||
@@ -786,6 +796,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)
|
||||
}
|
||||
@@ -814,8 +825,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
|
||||
@@ -856,7 +867,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)
|
||||
}
|
||||
@@ -872,14 +883,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -887,29 +916,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) {
|
||||
@@ -1145,6 +1174,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// the request landed in this instance's onNewIntent and went with its finish().
|
||||
SessionState.relaunch?.let { next ->
|
||||
SessionState.relaunch = null
|
||||
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||
setIntent(next)
|
||||
recreate()
|
||||
return@runOnUiThread
|
||||
@@ -1169,14 +1199,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()
|
||||
}
|
||||
@@ -1226,8 +1257,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
|
||||
@@ -1906,6 +1937,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
recreate()
|
||||
return
|
||||
}
|
||||
// Home/notification resumes must retain the external frontend's return destination.
|
||||
if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true)
|
||||
setIntent(intent)
|
||||
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
|
||||
handleHomeGuideIntent(intent)
|
||||
@@ -2046,6 +2079,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* off, it just closes.
|
||||
*/
|
||||
override fun finish() {
|
||||
if (isFinishing) return
|
||||
if (!quitFlooded && !closeAtOnce && com.droiddeck.launcher.ui.Motion.scale != 0f &&
|
||||
lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) {
|
||||
if (!leaving) {
|
||||
@@ -2056,6 +2090,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
super.finish()
|
||||
if (!closeAtOnce && intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) {
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()
|
||||
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
if (quitFlooded) overridePendingTransition(0, 0)
|
||||
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
}
|
||||
@@ -2071,6 +2111,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_RETURN_HOME = "returnHome"
|
||||
/** How long the leaving flood may take before the session closes without it. */
|
||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||
private const val TAG = "SessionActivity"
|
||||
@@ -2088,5 +2129,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
@@ -50,6 +51,7 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
val response = try {
|
||||
when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"logs" -> exportLogs(context)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
@@ -63,7 +65,7 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
|
||||
val session = JSONObject()
|
||||
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
|
||||
.put("phase", SessionState.phase.name)
|
||||
@@ -101,10 +103,12 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
.put("session", session)
|
||||
}
|
||||
|
||||
private fun latestSessionDirectory(): File? {
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
|
||||
?.maxWithOrNull(compareBy<File> { it.lastModified() }.thenBy { it.name })
|
||||
private fun exportLogs(context: Context): JSONObject {
|
||||
val dir = SessionState.logDirectory ?: SessionLogShare.latest(context)
|
||||
?: return error("NO_SESSION_LOGS", "No session logs are available")
|
||||
val zip = SessionLogShare.zipFolder(context, dir)
|
||||
?: return error("NO_SESSION_LOGS", "No diagnostic logs are available")
|
||||
return JSONObject().put("ok", true).put("sessionId", dir.name).put("path", zip.absolutePath)
|
||||
}
|
||||
|
||||
private fun stop(context: Context): JSONObject {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -109,8 +109,11 @@ object DeviceReport {
|
||||
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
|
||||
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
|
||||
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
|
||||
k("Resolution (Steam)", SessionPrefs.resolutionChoice(context, SessionService.MODE_STEAM, panelSize))
|
||||
k("Resolution (Desktop)", SessionPrefs.resolutionChoice(context, SessionService.MODE_DESKTOP, panelSize))
|
||||
for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) {
|
||||
val choice = SessionPrefs.resolutionChoice(context, mode, panelSize)
|
||||
val size = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panelSize, choice)
|
||||
k(label, "$choice (${size.first}x${size.second})")
|
||||
}
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
@@ -163,7 +166,6 @@ object DeviceReport {
|
||||
k("proot fast path", SessionPrefs.prootFastPath(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
|
||||
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
k("Microphone", SessionPrefs.micEnabled(context))
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ object GameEnvironment {
|
||||
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
|
||||
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
|
||||
|
||||
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
"MESA_SHADER_CACHE_DISABLE" to "false",
|
||||
"VKD3D_FEATURE_LEVEL" to "12_2",
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
@@ -37,8 +37,12 @@ object GameEnvironment {
|
||||
"DOTNET_EnableWriteXorExecute" to "0",
|
||||
).apply {
|
||||
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
|
||||
// Steam settings' Force SSBS: droiddeck-game-env preloads libssbs.so into the game's Wine.
|
||||
if (forceSsbs) put(FORCE_SSBS, "1")
|
||||
}
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
|
||||
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
const val FORCE_SSBS = "DROIDDECK_FORCE_SSBS"
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
|
||||
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
}
|
||||
@@ -36,11 +36,16 @@ object LogRedactor {
|
||||
* WebAPI key is caught by [WEBAPI_KEY]. The value class excludes `<` and `>` so running this
|
||||
* twice over an already-scrubbed line changes nothing.
|
||||
*/
|
||||
private val SECRET_KV = Regex(
|
||||
"(?i)\\b(access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
|
||||
private const val SECRET_KEYS =
|
||||
"access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
|
||||
"auth[_-]?ticket|ticket|sessionid|steamloginsecure|webapikey|api[_-]?key|" +
|
||||
"machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)" +
|
||||
"(\\s*[=:]\\s*|=)([^\\s\"'<>&;,]{4,})"
|
||||
"machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret"
|
||||
private val SECRET_KV = Regex(
|
||||
"(?i)\\b($SECRET_KEYS)" +
|
||||
"([\"']?\\s*[=:]\\s*[\"']?)([^\\s\"'<>&;,]{1,})"
|
||||
)
|
||||
private val QUOTED_SECRET = Regex(
|
||||
"(?i)\\b($SECRET_KEYS)([\"']?\\s*[=:]\\s*)(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')"
|
||||
)
|
||||
/** A Steam Guard code, only where the text around it says that is what it is. */
|
||||
private val GUARD_CODE = Regex(
|
||||
@@ -53,6 +58,7 @@ object LogRedactor {
|
||||
"(?i)\\b(jwt|token|ticket|sessionid|steamloginsecure|machineauth)\\b[\\s=:]*([A-Za-z0-9+/=_\\-]{12,})"
|
||||
)
|
||||
private val LONG_TOKEN = Regex("[A-Za-z0-9_\\-]{88,}")
|
||||
private val AUTHORIZATION = Regex("(?i)(\\b(?:proxy-)?authorization[\"']?\\s*[:=]\\s*[\"']?)[^\\r\\n\"']+")
|
||||
private val STEAMID64 = Regex("\\b(76561)(\\d{8})(\\d{4})\\b")
|
||||
private val STEAMID3 = Regex("\\[U:1:(\\d+)]")
|
||||
/** "external address 2607:..." / "external IP: 203.0.113.9" - the client stating ours. */
|
||||
@@ -179,22 +185,6 @@ object LogRedactor {
|
||||
return out
|
||||
}
|
||||
|
||||
/** A file worth scrubbing: text (no NUL in its first 4 KB) and not huge. */
|
||||
fun isText(file: java.io.File): Boolean = try {
|
||||
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
|
||||
val buf = ByteArray(4096)
|
||||
val n = input.read(buf)
|
||||
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** [src]'s lines, scrubbed, to [out]. */
|
||||
fun scrubTo(src: java.io.File, out: java.io.Writer) {
|
||||
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
|
||||
}
|
||||
|
||||
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
|
||||
fun redact(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
@@ -203,6 +193,11 @@ object LogRedactor {
|
||||
out = GUID.replace(out, "<redacted:guid>")
|
||||
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
|
||||
out = JWT_BASE64.replace(out, "<redacted:jwt>")
|
||||
out = AUTHORIZATION.replace(out) { "${it.groupValues[1]}<redacted:authorization>" }
|
||||
out = QUOTED_SECRET.replace(out) {
|
||||
val quote = it.groupValues[3].first()
|
||||
"${it.groupValues[1]}${it.groupValues[2]}$quote<redacted:token>$quote"
|
||||
}
|
||||
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
|
||||
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
|
||||
out = WEBAPI_KEY.replace(out, "<redacted:key>")
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -49,7 +49,7 @@ object SessionLogCapture {
|
||||
) { line ->
|
||||
synchronized(this) {
|
||||
try {
|
||||
writer?.apply { write(line); newLine(); flush() }
|
||||
if (writer === out) out.apply { write(line); newLine(); flush() }
|
||||
} catch (e: Exception) {
|
||||
// A full or unmounted card must not take the session with it.
|
||||
}
|
||||
|
||||
@@ -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,39 @@ 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/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 +1044,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 +1053,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 +1073,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 +1130,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 +1141,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 +1211,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)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Best-effort detection of the Windows redistributables a game bundles, by scanning its install
|
||||
* folder for the redist installers Steam/Epic/GOG ship (`_CommonRedist/`, `_Redist/`,
|
||||
* `Redistributables/`, loose `vcredist_x64.exe`, …) and mapping them to entries in our component
|
||||
* catalog (WinComponents, Bannerlator's components.json `name` keys). The result drives the
|
||||
* Recommended part of a game's Windows components; the user still switches each one on.
|
||||
* Bannerlator's detector, unchanged but for this note and its package.
|
||||
*
|
||||
* Wine-aware: keeps the redists that actually matter under Wine/DXVK (VC++ runtimes, .NET, PhysX,
|
||||
* OpenAL, XACT, XNA, GFWL) and SKIPS the ones DXVK/VKD3D already provide (DX12 Agility SDK) or that
|
||||
* aren't our components (anti-cheat, Rockstar/Social-Club/launcher setups). Never throws; returns an
|
||||
* empty list on anything unexpected. Pure filesystem — no network, no Android deps (unit-testable).
|
||||
*/
|
||||
object DependencyDetector {
|
||||
|
||||
/** How a recommendation was found — drives the UI's "recommended vs optional" split. */
|
||||
enum class Kind {
|
||||
/** The game bundles the redist installer (in _CommonRedist etc.) — meant to be installed. */
|
||||
BUNDLED,
|
||||
/** A runtime DLL the game ships loose — usually already works from the game folder; install
|
||||
* only if the game misbehaves. */
|
||||
SHIPPED,
|
||||
/** Steam installs it with the game from Steamworks Shared ([SteamRedists]). */
|
||||
STEAM,
|
||||
}
|
||||
|
||||
/** A recommended component: the catalog [componentName] to install, a display [label], the on-disk
|
||||
* redist that triggered it ([reason]), and [kind] (bundled installer vs shipped DLL). */
|
||||
data class Recommendation(val componentName: String, val label: String, val reason: String, val kind: Kind = Kind.BUNDLED)
|
||||
|
||||
private const val MAX_DEPTH = 5
|
||||
private const val MAX_ENTRIES = 8000 // cost guard against giant game trees
|
||||
|
||||
/** A loose VC++ installer with no year in its path — resolved to "latest" only if no specific
|
||||
* vcredistYYYY was found anywhere (else it's just a duplicate of the versioned one). */
|
||||
private const val VCREDIST_LOOSE = "__vcredist_loose"
|
||||
|
||||
/** Directory names that are redist containers worth descending into. */
|
||||
private val REDIST_DIR_RE = Regex(
|
||||
"""(?i)^(_?commonredist|_?redist(ributables)?|__?installer|vcredist|vc_redist|directx|direct_x|physx|openal|dotnet(fx)?|\.net|xna|xact|redist|support)$"""
|
||||
)
|
||||
|
||||
/** Skip whole subtrees that are never our components. */
|
||||
private val SKIP_DIR_RE = Regex("""(?i)^(d3d12.*|.*agility.*|dxvk|vkd3d|battleye|easyanticheat|eac|anticheat)$""")
|
||||
|
||||
private val YEAR_TO_VCREDIST = mapOf(
|
||||
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010",
|
||||
"2012" to "vcredist2012", "2013" to "vcredist2013", "2015" to "vcredist2015",
|
||||
"2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
|
||||
)
|
||||
|
||||
private val LABELS = mapOf(
|
||||
"vcredist2005" to "Visual C++ 2005", "vcredist2008" to "Visual C++ 2008",
|
||||
"vcredist2010" to "Visual C++ 2010", "vcredist2012" to "Visual C++ 2012",
|
||||
"vcredist2013" to "Visual C++ 2013", "vcredist2015" to "Visual C++ 2015-2017",
|
||||
"vcredist2019" to "Visual C++ 2019", "vcredist2022" to "Visual C++ 2015-2022",
|
||||
"physx" to "NVIDIA PhysX", "oalinst" to "OpenAL", "xact" to "DirectX XACT audio",
|
||||
"xact_x64" to "DirectX XACT audio (x64)", "xna40" to "XNA Framework 4.0",
|
||||
"xna31" to "XNA Framework 3.1", "d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
|
||||
"XLiveRedist" to "Games for Windows Live", "dotnet40" to ".NET Framework 4.0",
|
||||
"dotnet45" to ".NET Framework 4.5", "dotnet452" to ".NET Framework 4.5.2",
|
||||
"dotnet46" to ".NET Framework 4.6", "dotnet472" to ".NET Framework 4.7.2",
|
||||
"dotnet48" to ".NET Framework 4.8", "dotnet35" to ".NET Framework 3.5",
|
||||
"dotnetcoredesktop6" to ".NET Desktop 6", "dotnetcoredesktop7" to ".NET Desktop 7",
|
||||
"dotnetcoredesktop8" to ".NET Desktop 8",
|
||||
)
|
||||
|
||||
/** Preferred install order (runtimes others may depend on first). */
|
||||
private val INSTALL_ORDER = listOf(
|
||||
"vcredist2005", "vcredist2008", "vcredist2010", "vcredist2012", "vcredist2013",
|
||||
"vcredist2015", "vcredist2019", "vcredist2022",
|
||||
"dotnet35", "dotnet40", "dotnet45", "dotnet452", "dotnet46", "dotnet472", "dotnet48",
|
||||
"dotnetcoredesktop6", "dotnetcoredesktop7", "dotnetcoredesktop8",
|
||||
"d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist",
|
||||
)
|
||||
|
||||
/**
|
||||
* Detect from the .exe the user picked, resolving the game root first: walk up from the exe's
|
||||
* folder (bounded) to the nearest ancestor that holds a redist container (`_CommonRedist`, …) at
|
||||
* its top level, and scan from there — so a nested exe (`…/Game/bin/x.exe`) still finds a root-level
|
||||
* `_CommonRedist`, without over-reaching into a shared `steamapps/common` (which has no redist
|
||||
* container of its own). Falls back to scanning the exe's own folder.
|
||||
*/
|
||||
fun detectForExe(exeFile: File): List<Recommendation> {
|
||||
val start = exeFile.absoluteFile.parentFile ?: return emptyList()
|
||||
var d: File? = start
|
||||
var depth = 0
|
||||
while (d != null && depth < 4) {
|
||||
val hasRedistContainer = try {
|
||||
d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true
|
||||
} catch (_: Throwable) { false }
|
||||
if (hasRedistContainer) return detect(d)
|
||||
d = d.parentFile
|
||||
depth++
|
||||
}
|
||||
return detect(start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan [gameDir] and return the recommended components, de-duplicated (first reason wins), in a
|
||||
* stable install-friendly order. Empty when nothing is detected or [gameDir] isn't a directory.
|
||||
*/
|
||||
fun detect(gameDir: File): List<Recommendation> {
|
||||
if (!gameDir.isDirectory) return emptyList()
|
||||
val hits = LinkedHashMap<String, Recommendation>() // componentName -> first recommendation
|
||||
var visited = 0
|
||||
|
||||
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
|
||||
val name = f.name.lowercase(Locale.US)
|
||||
val path = f.path.replace('\\', '/').lowercase(Locale.US)
|
||||
// Bundled = in a redist container or an installer (exe/msi/cab); shipped = a loose runtime DLL.
|
||||
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
|
||||
Kind.BUNDLED else Kind.SHIPPED
|
||||
for (comp in classify(path, name, isDir)) {
|
||||
val existing = hits[comp]
|
||||
if (existing == null) {
|
||||
hits[comp] = Recommendation(comp, LABELS[comp] ?: comp, relativeReason(gameDir, f), kind)
|
||||
} else if (existing.kind == Kind.SHIPPED && kind == Kind.BUNDLED) {
|
||||
hits[comp] = existing.copy(kind = Kind.BUNDLED) // a bundled installer outranks a shipped DLL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun walk(dir: File, depth: Int, insideRedist: Boolean) {
|
||||
if (depth > MAX_DEPTH || visited > MAX_ENTRIES) return
|
||||
val entries = dir.listFiles() ?: return
|
||||
for (f in entries) {
|
||||
if (visited++ > MAX_ENTRIES) return
|
||||
val lower = f.name.lowercase(Locale.US)
|
||||
if (f.isDirectory) {
|
||||
if (SKIP_DIR_RE.matches(lower)) continue
|
||||
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
|
||||
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
|
||||
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
|
||||
// tree a few levels deep so runtime libraries the game ships (in bin/, etc.) are seen.
|
||||
if (isRedistDir || depth < 3) walk(f, depth + 1, isRedistDir)
|
||||
} else {
|
||||
// Classify installers + runtime libraries (.dll/.exe/.msi) anywhere in the scanned
|
||||
// tree; other files are ignored. Recommendations are advisory, so cast a wide net.
|
||||
val ext = lower.substringAfterLast('.', "")
|
||||
if (insideRedist || ext == "dll" || ext == "exe" || ext == "msi") consider(f, isDir = false, insideRedist = insideRedist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
walk(gameDir, 0, false)
|
||||
resolveLooseVcredist(hits)
|
||||
hits.values.sortedBy { INSTALL_ORDER.indexOf(it.componentName).let { i -> if (i < 0) 999 else i } }
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
|
||||
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
|
||||
val loose = hits.remove(VCREDIST_LOOSE) ?: return
|
||||
val hasSpecific = hits.keys.any { it in YEAR_TO_VCREDIST.values }
|
||||
if (!hasSpecific) hits["vcredist2022"] = loose.copy(componentName = "vcredist2022", label = LABELS["vcredist2022"]!!)
|
||||
}
|
||||
|
||||
private fun relativeReason(gameDir: File, f: File): String {
|
||||
val g = gameDir.path.replace('\\', '/')
|
||||
val p = f.path.replace('\\', '/')
|
||||
return if (p.startsWith("$g/")) p.substring(g.length + 1) else f.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a redist file/dir (its full lowercased [path], leaf [name], and whether it [isDir]) to
|
||||
* catalog component name(s). Empty for things we deliberately skip.
|
||||
*/
|
||||
private fun classify(path: String, name: String, isDir: Boolean): List<String> {
|
||||
// Hard skips first — DX12 is provided by DXVK/VKD3D; these aren't our components.
|
||||
if (path.contains("d3d12") || path.contains("agility") ||
|
||||
path.contains("battleye") || path.contains("anticheat") || path.contains("easyanticheat") ||
|
||||
name.contains("socialclub") || name.contains("social-club") ||
|
||||
name.contains("rockstar") || name == "setup.exe"
|
||||
) return emptyList()
|
||||
|
||||
// Visual C++ runtimes — most important. Year comes from the path context.
|
||||
if (path.contains("vcredist") || path.contains("vc_redist") || name.startsWith("vcredist") || name.startsWith("vc_redist")) {
|
||||
val year = YEAR_TO_VCREDIST.keys.firstOrNull { path.contains(it) }
|
||||
return when {
|
||||
year != null -> listOf(YEAR_TO_VCREDIST.getValue(year))
|
||||
isDir -> emptyList() // bare "vcredist" container — wait for the year subfolder / installer
|
||||
else -> listOf(VCREDIST_LOOSE) // loose installer, no year → resolved post-walk
|
||||
}
|
||||
}
|
||||
// VC++ runtime DLL the game ships (msvcp140.dll → 2015-2022, msvcp120.dll → 2013, …).
|
||||
VCRUNTIME_DLL.entries.firstOrNull { name.startsWith(it.key) }?.let { return listOf(it.value) }
|
||||
if (path.contains("openal") || name.startsWith("oalinst") || name == "openal32.dll" || name == "wrap_oal.dll") return listOf("oalinst")
|
||||
if (path.contains("physx") || name.startsWith("physx")) return listOf("physx")
|
||||
if (path.contains("xact") || name.startsWith("xactengine") || name.startsWith("x3daudio")) return listOf("xact", "xact_x64")
|
||||
// Games for Windows Live — the redist folder, OR a GFWL library the game ships (xlive.dll,
|
||||
// Codemasters' dbxLive32/64, gfwl*): GFWL titles need XLiveRedist to run even when they don't
|
||||
// bundle the redist itself.
|
||||
if (path.contains("xliveredist") || name.contains("xlive") || name.startsWith("gfwl") || name.startsWith("dbxlive")) {
|
||||
return listOf("XLiveRedist")
|
||||
}
|
||||
if (path.contains("xna")) return listOf(if (path.contains("3.1") || path.contains("3_1") || path.contains("v31")) "xna31" else "xna40")
|
||||
// .NET — Framework and Core/Desktop runtimes.
|
||||
if (path.contains("dotnet") || path.contains(".net") || name.startsWith("ndp") || name.startsWith("dotnetfx") || name.contains("windowsdesktop-runtime")) {
|
||||
val comp = dotnetComponent("$path $name")
|
||||
return when {
|
||||
comp != null -> listOf(comp)
|
||||
isDir -> emptyList() // bare ".NET" container — wait for the versioned installer
|
||||
else -> listOf("dotnet48") // unlabeled .NET Framework installer file → sensible default
|
||||
}
|
||||
}
|
||||
// DirectX 11 helper DLLs the game ships (d3dx11_43.dll).
|
||||
if (D3DX11_RE.matches(name)) return listOf("d3dx11")
|
||||
// DirectX 9 helper DLLs / d3dcompiler / June-2010 DX redist (DXVK provides core d3d9 but NOT
|
||||
// these helper DLLs).
|
||||
if (name.startsWith("dxsetup") || name.startsWith("dxwebsetup") ||
|
||||
D3DX9_RE.matches(name) || D3DCOMPILER_RE.matches(name) ||
|
||||
(path.contains("directx") && name.endsWith(".exe"))
|
||||
) return listOf("d3dx9")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
/** Runtime DLL prefix → the VC++ redistributable that provides it (checked with startsWith). */
|
||||
private val VCRUNTIME_DLL = linkedMapOf(
|
||||
"vcruntime140" to "vcredist2022", "msvcp140" to "vcredist2022", "msvcr140" to "vcredist2022", "concrt140" to "vcredist2022",
|
||||
"msvcp120" to "vcredist2013", "msvcr120" to "vcredist2013",
|
||||
"msvcp110" to "vcredist2012", "msvcr110" to "vcredist2012",
|
||||
"msvcp100" to "vcredist2010", "msvcr100" to "vcredist2010",
|
||||
"msvcp90" to "vcredist2008", "msvcr90" to "vcredist2008",
|
||||
"msvcp80" to "vcredist2005", "msvcr80" to "vcredist2005",
|
||||
)
|
||||
private val D3DX9_RE = Regex("""d3dx9_\d+\.dll""")
|
||||
private val D3DX11_RE = Regex("""d3dx11_\d+\.dll""")
|
||||
private val D3DCOMPILER_RE = Regex("""d3dcompiler_\d+\.dll""")
|
||||
|
||||
/** Resolve a .NET component from installer/path text, or null when no version is discernible. */
|
||||
private fun dotnetComponent(s: String): String? = when {
|
||||
s.contains("desktop-runtime-8") || Regex("""\b8\.\d""").containsMatchIn(s) -> "dotnetcoredesktop8"
|
||||
s.contains("desktop-runtime-7") || Regex("""\b7\.\d""").containsMatchIn(s) -> "dotnetcoredesktop7"
|
||||
s.contains("desktop-runtime-6") || Regex("""\b6\.\d""").containsMatchIn(s) -> "dotnetcoredesktop6"
|
||||
s.contains("ndp48") || s.contains("fx48") || s.contains("4.8") -> "dotnet48"
|
||||
s.contains("ndp472") || s.contains("4.7.2") -> "dotnet472"
|
||||
s.contains("ndp46") || s.contains("4.6") -> "dotnet46"
|
||||
s.contains("ndp452") || s.contains("4.5.2") -> "dotnet452"
|
||||
s.contains("dotnetfx45") || s.contains("ndp45") || s.contains("4.5") -> "dotnet45"
|
||||
s.contains("dotnetfx40") || s.contains("ndp40") || s.contains("4.0") -> "dotnet40"
|
||||
s.contains("dotnetfx35") || s.contains("3.5") -> "dotnet35"
|
||||
else -> null // no version signal (e.g. a bare "DotNet" container dir)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -12,7 +13,7 @@ import java.io.InputStream
|
||||
|
||||
/** Small, portable launch files for frontends that scan ROM folders. No guest paths or commands. */
|
||||
object GameFiles {
|
||||
const val EXTENSION = "droiddeck"
|
||||
const val EXTENSION = "steam"
|
||||
private const val MAX_BYTES = 128
|
||||
private const val INVENTORY = ".droiddeck-sync.json"
|
||||
private const val FORMAT = "droiddeck-game-files-v1"
|
||||
@@ -46,83 +47,115 @@ object GameFiles {
|
||||
}
|
||||
if (count > MAX_BYTES) return null
|
||||
val text = String(bytes, 0, count, Charsets.UTF_8).trim()
|
||||
return GameLaunchLink.parse(text) ?: text.takeIf(GameLaunchLink::validId)
|
||||
return text.takeIf(GameLaunchLink::validId)
|
||||
}
|
||||
|
||||
fun filename(game: Library.SteamGame): String {
|
||||
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
|
||||
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
|
||||
.trim().trim('.').ifBlank { "Game" }
|
||||
val points = sanitized.codePoints().limit(40).toArray()
|
||||
val title = String(points, 0, points.size)
|
||||
return "$title (${game.gameIdString}).$EXTENSION"
|
||||
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
|
||||
return "$title$suffix.$EXTENSION"
|
||||
}
|
||||
|
||||
private fun contents(id: String) = GameLaunchLink.uri(id) + "\n"
|
||||
private fun contents(id: String): String {
|
||||
require(GameLaunchLink.validId(id))
|
||||
return id
|
||||
}
|
||||
|
||||
@Synchronized fun export(folder: File, game: Library.SteamGame): File {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
val target = child(folder, filename(game))
|
||||
@Synchronized fun export(context: Context, folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(context, folder, game, filename(game))
|
||||
|
||||
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)
|
||||
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(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(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
|
||||
val title = filename(game).substringBeforeLast(" (${game.gameIdString})")
|
||||
val title = filename(game).substringBeforeLast(".")
|
||||
.replace(Regex("\\[[^]]*]|\\([^)]*\\)"), "")
|
||||
.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()
|
||||
val exports = games.distinctBy { it.gameId }.map { game ->
|
||||
val target = child(folder, filename(game))
|
||||
val unique = games.distinctBy { it.gameId }
|
||||
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(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 file = if (previous.optString(target.name) == game.gameIdString && target.exists() &&
|
||||
!matches(target, contents(game.gameIdString))) target else export(folder, game)
|
||||
next.put(file.name, game.gameIdString)
|
||||
val owned = inventoryOwns(context, folder, previous, target, game.gameIdString)
|
||||
val file = if (owned && target.exists() &&
|
||||
!matches(target, text)) target else exportNamed(context, folder, game, target.name)
|
||||
if (!existed || owned) next.put(file.name, game.gameIdString)
|
||||
file
|
||||
}
|
||||
// Write new exports before pruning; a failed write must not remove old launch files.
|
||||
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 child(folder: File, name: String): File {
|
||||
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(context, folder, name).let { old ->
|
||||
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
object GameLaunchIntent {
|
||||
const val ACTION = "com.droiddeck.launcher.LAUNCH_GAME"
|
||||
const val EXTRA_APP_ID = "app_id"
|
||||
const val EXTRA_GAME_SOURCE = "game_source"
|
||||
private const val GAMENATIVE_ACTION = "app.gamenative.LAUNCH_GAME"
|
||||
|
||||
fun accepts(action: String?): Boolean = action == Intent.ACTION_VIEW || action == ACTION || action == GAMENATIVE_ACTION
|
||||
|
||||
fun read(context: Context, intent: Intent): String? = when (intent.action) {
|
||||
Intent.ACTION_VIEW -> GameFiles.readIntent(context, intent)
|
||||
ACTION, GAMENATIVE_ACTION -> readId(intent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun readId(intent: Intent): String? = runCatching {
|
||||
if (intent.action != ACTION && intent.action != GAMENATIVE_ACTION) return null
|
||||
val source = intent.extras?.get(EXTRA_GAME_SOURCE)
|
||||
if (source != null && (source !is String || !source.equals("STEAM", ignoreCase = true))) return null
|
||||
val id = when (val value = intent.extras?.get(EXTRA_APP_ID)) {
|
||||
is Int -> value.takeIf { it > 0 }?.toString()
|
||||
is String -> value.takeIf(GameLaunchLink::validId)
|
||||
else -> null
|
||||
}
|
||||
id
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which catalog components a game's prefix already has from elsewhere - a real installer, the game,
|
||||
* the user - so the list can say so instead of recommending them again. Bannerlator's detector,
|
||||
* on DroidDeck's compatdata/<id>: only markers absent from a fresh Proton prefix count (OpenAL32,
|
||||
* Mono, PhysX, Games for Windows Live); msvcp140, d3dx9 and the like ship with Proton and cannot
|
||||
* tell. Files DroidDeck put there itself (droiddeck-wincomponents' state) are not counted.
|
||||
* Pure filesystem reads; never throws.
|
||||
*/
|
||||
object PrefixInstalledDetector {
|
||||
private const val STATE = ".droiddeck-wincomponents.json"
|
||||
|
||||
/** Detector names (oalinst, mono, physx, XLiveRedist) present in [compat]/pfx. */
|
||||
fun detect(compat: File?): Set<String> = try {
|
||||
val pfx = compat?.let { File(it, "pfx") }
|
||||
if (pfx == null || !pfx.isDirectory) emptySet() else {
|
||||
val ours = ours(compat)
|
||||
val out = HashSet<String>()
|
||||
val openal = listOf("system32", "syswow64").map { "drive_c/windows/$it/openal32.dll" }
|
||||
if (openal.any { it !in ours && fileIgnoringCase(pfx, it) }) out += "oalinst"
|
||||
if (File(pfx, "drive_c/windows/mono").isDirectory) out += "mono"
|
||||
if (hasPhysX(pfx)) out += "physx"
|
||||
if (registryContains(pfx, "Games for Windows")) out += "XLiveRedist"
|
||||
out
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
private fun ours(compat: File): Set<String> = try {
|
||||
val files = JSONObject(File(compat, STATE).readText()).optJSONObject("files")
|
||||
files?.keys()?.asSequence()?.map { it.lowercase() }?.toSet().orEmpty()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
/** Windows names ignore case; the prefix's folder may spell them either way. */
|
||||
private fun fileIgnoringCase(pfx: File, rel: String): Boolean {
|
||||
val target = File(pfx, rel)
|
||||
return target.parentFile?.listFiles()?.any { it.isFile && it.name.equals(target.name, ignoreCase = true) } == true
|
||||
}
|
||||
|
||||
private fun hasPhysX(pfx: File): Boolean {
|
||||
for (pf in arrayOf("drive_c/Program Files", "drive_c/Program Files (x86)")) {
|
||||
val base = File(pfx, pf)
|
||||
if (File(base, "NVIDIA Corporation/PhysX").isDirectory) return true
|
||||
if (base.listFiles()?.any { it.isDirectory && it.name.contains("PhysX", ignoreCase = true) } == true) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun registryContains(pfx: File, needle: String): Boolean =
|
||||
arrayOf("system.reg", "user.reg").any { name ->
|
||||
val f = File(pfx, name)
|
||||
try {
|
||||
f.isFile && f.length() < 32L * 1024 * 1024 &&
|
||||
f.bufferedReader().use { r -> r.lineSequence().any { it.contains(needle, ignoreCase = true) } }
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What Steam itself installs alongside a game: the Steamworks Shared redistributables (app 228980)
|
||||
* listed under "SharedDepots" in the game's appmanifest. Those live in Steamworks Shared rather than
|
||||
* the game's folder, so the folder scan ([DependencyDetector]) cannot see them; this reads Steam's
|
||||
* list instead. Depots are matched to Bannerlator's catalog names; unknown ones are left out.
|
||||
*/
|
||||
object SteamRedists {
|
||||
/** Steamworks Shared depot -> (catalog component, what Steam calls it). 2015-2019 runtimes are one family. */
|
||||
val DEPOTS = linkedMapOf(
|
||||
"228981" to ("vcredist2005_dll" to "VC++ 2005"),
|
||||
"228982" to ("vcredist2008_dll" to "VC++ 2008"),
|
||||
"228983" to ("vcredist2010_dll" to "VC++ 2010"),
|
||||
"228984" to ("vcredist2012_dll" to "VC++ 2012"),
|
||||
"228985" to ("vcredist2013_dll" to "VC++ 2013"),
|
||||
"228986" to ("vcredist2015_dll" to "VC++ 2015"),
|
||||
"228987" to ("vcredist2019_dll" to "VC++ 2017"),
|
||||
"228988" to ("vcredist2019_dll" to "VC++ 2019"),
|
||||
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
|
||||
"228990" to ("d3dx9" to "DirectX (June 2010)"),
|
||||
)
|
||||
private val BLOCK = Regex("\"SharedDepots\"\\s*\\{([^}]*)\\}", RegexOption.IGNORE_CASE)
|
||||
private val ENTRY = Regex("\"(\\d+)\"\\s+\"\\d+\"")
|
||||
|
||||
/** The game's appmanifest beside its install folder (steamapps/common/<dir> -> steamapps/). */
|
||||
fun manifest(gameDir: File?, appId: Int): File? {
|
||||
val steamapps = gameDir?.parentFile?.parentFile ?: return null
|
||||
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
|
||||
}
|
||||
|
||||
fun detect(gameDir: File?, appId: Int): List<DependencyDetector.Recommendation> {
|
||||
val text = try {
|
||||
manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList()
|
||||
} catch (_: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
val block = BLOCK.find(text)?.groupValues?.get(1) ?: return emptyList()
|
||||
return ENTRY.findAll(block).mapNotNull { DEPOTS[it.groupValues[1]] }
|
||||
.distinctBy { it.first }
|
||||
.map { (component, label) -> DependencyDetector.Recommendation(component, label, label, DependencyDetector.Kind.STEAM) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Everything the device info window shows about the phone's chip: SoC, GPU, CPU, memory, system,
|
||||
* the phone's own Vulkan driver and the drivers DroidDeck uses. Reads files and asks Vulkan once
|
||||
* ([VulkanInfo]), so [collect] belongs off the main thread. Values the phone does not give are
|
||||
* left out rather than guessed.
|
||||
*/
|
||||
object DeviceInfo {
|
||||
class Section(val title: String, val rows: List<Pair<String, String>>)
|
||||
|
||||
fun collect(context: Context): List<Section> {
|
||||
val vk = VulkanInfo.query()
|
||||
// After Vulkan has been asked, detection can use its name too.
|
||||
val gpu = GpuInfo.detect()
|
||||
return listOf(
|
||||
chip(), gpu(gpu), cpu(), memory(context), system(), vulkan(context, vk), drivers(context, gpu),
|
||||
).filter { it.rows.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** The whole window as plain text, for a bug report. */
|
||||
fun text(sections: List<Section>): String = buildString {
|
||||
append("DroidDeck device info\n")
|
||||
for (s in sections) {
|
||||
append('\n').append(s.title).append('\n')
|
||||
for ((k, v) in s.rows) append(" ").append(k).append(": ").append(v).append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<Pair<String, String>>.add(key: String, value: String?) {
|
||||
if (!value.isNullOrBlank() && value != Build.UNKNOWN) add(key to value.trim())
|
||||
}
|
||||
|
||||
private fun read(path: String): String? = FileUtils.readString(File(path))?.trim()?.ifEmpty { null }
|
||||
|
||||
private fun prop(name: String): String = GpuInfo.systemProperty(name)
|
||||
|
||||
private fun chip() = Section("Chip (SoC)", buildList<Pair<String, String>> {
|
||||
val model = SocNames.model()
|
||||
val platform = prop("ro.board.platform")
|
||||
add("Name", SocNames.name(model, platform))
|
||||
add("Model", model)
|
||||
if (Build.VERSION.SDK_INT >= 31) add("Maker", Build.SOC_MANUFACTURER)
|
||||
add("Platform", platform)
|
||||
add("Hardware", Build.HARDWARE)
|
||||
add("Board", Build.BOARD)
|
||||
val id = read("/sys/devices/soc0/soc_id")
|
||||
val machine = read("/sys/devices/soc0/machine")
|
||||
add("SoC id", listOfNotNull(id, machine).joinToString(" · "))
|
||||
add("Family", read("/sys/devices/soc0/family"))
|
||||
})
|
||||
|
||||
private fun gpu(gpu: GpuInfo) = Section("GPU", buildList<Pair<String, String>> {
|
||||
add("GPU", gpu.name)
|
||||
add("Family", gpu.family.label)
|
||||
add("Model found from", when (gpu.modelSource) {
|
||||
"kernel" -> "the kernel's GPU name"
|
||||
"vulkan" -> "the Vulkan driver (the kernel's name has no model)"
|
||||
"platform" -> "the platform (the kernel's name has no model)"
|
||||
else -> "unknown"
|
||||
})
|
||||
add("Kernel name", gpu.kgslName)
|
||||
val kgsl = "/sys/class/kgsl/kgsl-3d0"
|
||||
add("Clock range", gpuRange())
|
||||
add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" })
|
||||
add("Temperature", read("$kgsl/temp")?.toLongOrNull()?.let { if (it > 1000) "${it / 1000} °C" else "$it °C" })
|
||||
add("Support", gpu.supportText)
|
||||
})
|
||||
|
||||
private fun cpu() = Section("CPU", buildList<Pair<String, String>> {
|
||||
add("Cores", Runtime.getRuntime().availableProcessors().toString())
|
||||
// Each cpufreq policy is one cluster: the cores it covers and their speed range.
|
||||
val policies = File("/sys/devices/system/cpu/cpufreq").listFiles { f -> f.name.matches(Regex("policy\\d+")) }
|
||||
?.sortedBy { it.name.removePrefix("policy").toInt() }.orEmpty()
|
||||
for (p in policies) {
|
||||
val cpus = (read("${p.path}/related_cpus") ?: read("${p.path}/affected_cpus"))?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty()
|
||||
val min = read("${p.path}/cpuinfo_min_freq")?.toLongOrNull()
|
||||
val max = read("${p.path}/cpuinfo_max_freq")?.toLongOrNull() ?: continue
|
||||
val cores = if (cpus.size > 1) "cores ${cpus.first()}-${cpus.last()}" else "core ${cpus.firstOrNull() ?: p.name.removePrefix("policy")}"
|
||||
add("Cluster ${p.name.removePrefix("policy")}", "${cpus.size.coerceAtLeast(1)} × $cores · " +
|
||||
(if (min != null) ghz(min) + "–" else "") + ghz(max))
|
||||
}
|
||||
add("ABI", Build.SUPPORTED_ABIS.joinToString(", "))
|
||||
})
|
||||
|
||||
private fun ghz(khz: Long) = String.format(Locale.US, "%.2f GHz", khz / 1e6)
|
||||
|
||||
/** The GPU's slowest and fastest step: KGSL's list, else a devfreq GPU node (Mali and others). */
|
||||
private fun gpuRange(): String? {
|
||||
fun mhz(hz: Long) = "${hz / 1_000_000} MHz"
|
||||
val kgsl = read("/sys/class/kgsl/kgsl-3d0/gpu_available_frequencies")
|
||||
?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
|
||||
if (kgsl.isNotEmpty()) return "${mhz(kgsl.min())}–${mhz(kgsl.max())} (${kgsl.size} steps)"
|
||||
read("/sys/class/kgsl/kgsl-3d0/max_gpuclk")?.toLongOrNull()?.let { return "up to ${mhz(it)}" }
|
||||
val node = File("/sys/class/devfreq").listFiles()?.firstOrNull { f ->
|
||||
val n = f.name.lowercase()
|
||||
(n.contains("gpu") || n.contains("mali") || n.contains("kgsl-3d")) && listOf("bus", "bw", "memlat").none { n.contains(it) }
|
||||
} ?: return null
|
||||
val steps = read("${node.path}/available_frequencies")?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
|
||||
if (steps.isNotEmpty()) return "${mhz(steps.min())}–${mhz(steps.max())} (${steps.size} steps)"
|
||||
val min = read("${node.path}/min_freq")?.toLongOrNull()
|
||||
val max = read("${node.path}/max_freq")?.toLongOrNull() ?: return null
|
||||
return (if (min != null) "${mhz(min)}–" else "up to ") + mhz(max)
|
||||
}
|
||||
|
||||
private fun memory(context: Context) = Section("Memory", buildList<Pair<String, String>> {
|
||||
val am = context.getSystemService(ActivityManager::class.java) ?: return@buildList
|
||||
val mi = ActivityManager.MemoryInfo().also(am::getMemoryInfo)
|
||||
add("Total", FileUtils.sizeToString(mi.totalMem))
|
||||
add("Available now", FileUtils.sizeToString(mi.availMem))
|
||||
})
|
||||
|
||||
private fun system() = Section("System", buildList<Pair<String, String>> {
|
||||
add("Device", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
add("Android", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
|
||||
add("Security patch", Build.VERSION.SECURITY_PATCH)
|
||||
add("Kernel", System.getProperty("os.version"))
|
||||
add("Build", Build.DISPLAY)
|
||||
})
|
||||
|
||||
private fun vulkan(context: Context, vk: Map<String, String>) = Section("Vulkan (the phone's own driver)", buildList<Pair<String, String>> {
|
||||
vk["error"]?.let { add("Error", it) }
|
||||
add("Device", vk["device"])
|
||||
add("Type", vk["type"])
|
||||
add("API version", vk["api"])
|
||||
add("Loader version", vk["loader"])
|
||||
add("Driver", listOfNotNull(vk["driver_name"], vk["driver_version"]).joinToString(" "))
|
||||
// Qualcomm's build string starts with its own label ("Driver Build: 43c70540de, ...").
|
||||
add("Driver build", vk["driver_info"]?.replace(Regex("(?i)^driver build:\\s*"), ""))
|
||||
add("Conformance", vk["conformance"])
|
||||
add("Vendor / device id", listOfNotNull(vk["vendor_id"], vk["device_id"]).joinToString(" / "))
|
||||
add("Max texture size", vk["max_image_2d"])
|
||||
add("GPU memory heap", vk["device_heap_mb"]?.let { "$it MB" })
|
||||
add("Extensions", vk["extension_count"])
|
||||
val features = listOf("geometry_shader" to "Geometry shaders", "tessellation_shader" to "Tessellation", "texture_bc" to "BC textures")
|
||||
for ((key, label) in features) add(label, vk[key])
|
||||
for ((key, value) in vk) if (key.startsWith("ext.")) add(key.removePrefix("ext."), value)
|
||||
// What Android certifies the phone for.
|
||||
val pm = context.packageManager
|
||||
val hw = pm.systemAvailableFeatures.associate { it.name to it.version }
|
||||
hw[PackageManager.FEATURE_VULKAN_HARDWARE_VERSION]?.let {
|
||||
add("Android-certified version", "${it shr 22}.${(it shr 12) and 0x3ff}.${it and 0xfff}")
|
||||
}
|
||||
hw[PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL]?.let { add("Android hardware level", it.toString()) }
|
||||
})
|
||||
|
||||
private fun drivers(context: Context, gpu: GpuInfo) = Section("Drivers DroidDeck uses", buildList<Pair<String, String>> {
|
||||
add("Driver mode", SessionPrefs.gpuDriverMode(context))
|
||||
val turnip = TurnipDriver(context)
|
||||
val choice = SessionPrefs.androidDriver(context).ifEmpty { turnip.autoId() }
|
||||
add("Display driver", "${turnip.displayName(choice)} ${turnip.driverVersion(choice)}".trim())
|
||||
val linux = LinuxVulkanDriverManager(context)
|
||||
val id = SessionPrefs.linuxDriver(context)
|
||||
add("Game driver", if (id.isEmpty()) "the runtime's own Turnip" else "${linux.getDriverName(id)} ${linux.getDriverVersion(id)}".trim())
|
||||
if (gpu.oneUi8Gen2) add("Note", "One UI on an 8 Gen 2: needs the OneUI Turnip build")
|
||||
})
|
||||
}
|
||||
@@ -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. */
|
||||
@@ -16,10 +18,14 @@ data class GpuInfo(
|
||||
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
|
||||
val model: Int,
|
||||
val family: Family,
|
||||
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
|
||||
/** The SoC as a person would say it ("Snapdragon 8 Gen 2 (QCS8550)"), or "" when the phone does not say. */
|
||||
val soc: String,
|
||||
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
|
||||
val oneUi8Gen2: Boolean,
|
||||
/** KGSL's own name for the GPU, as the kernel spells it ("Adreno740v2", "Adreno33v2"). */
|
||||
val kgslName: String = "",
|
||||
/** Where [model] came from: "kernel", "vulkan" or "platform", "" when unknown. */
|
||||
val modelSource: String = "",
|
||||
) {
|
||||
enum class Family(val label: String) {
|
||||
A8XX("Adreno 8xx"),
|
||||
@@ -29,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 }
|
||||
@@ -37,36 +46,82 @@ 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")
|
||||
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
|
||||
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
|
||||
// Where vendors put the chip's model, named when it is known: "Snapdragon 8 Gen 2 (QCS8550)".
|
||||
val soc = if (adreno) SocNames.label() else ""
|
||||
val fromKernel = raw?.let { threeDigits(it) } ?: 0
|
||||
// Some kernels name the GPU without its model (AYANEO's Pocket FIT: "Adreno33v2"). The
|
||||
// Vulkan driver's own name, when this process has asked it, then the platform's code
|
||||
// name stand in - the GPU is the same on every phone of a platform.
|
||||
val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0
|
||||
val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel()
|
||||
val model = maxOf(fromKernel, fromVulkan, fromPlatform)
|
||||
val source = when {
|
||||
fromKernel > 0 -> "kernel"
|
||||
fromVulkan > 0 -> "vulkan"
|
||||
fromPlatform > 0 -> "platform"
|
||||
else -> ""
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
|
||||
/** Qualcomm platform code names whose GPU is known for certain. */
|
||||
private val PLATFORMS = mapOf(
|
||||
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
|
||||
"kalama" to 740, "pineapple" to 750, "sun" to 830,
|
||||
)
|
||||
|
||||
internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int =
|
||||
PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0
|
||||
|
||||
/** android.os.SystemProperties.get, which apps may read but not call directly. */
|
||||
fun systemProperty(name: String): String = try {
|
||||
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
|
||||
!adreno -> Family.NOT_ADRENO
|
||||
model == 0 -> Family.ADRENO_UNKNOWN
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Which chip this is, as a person would say it. Handhelds often leave Android's SoC model blank
|
||||
* (the AYANEO Pocket FIT does), so the model is looked for where vendors put it, the same order
|
||||
* ClusterTune uses; then it is named from a table of models we are sure of, or from the platform
|
||||
* code name as a family ("Snapdragon 8 Gen 3 family") when the model itself is a vendor variant.
|
||||
*/
|
||||
object SocNames {
|
||||
/** Model codes whose product name is certain. */
|
||||
private val MODELS = mapOf(
|
||||
"SM8150" to "Snapdragon 855", "SM8250" to "Snapdragon 865", "SM8250-AC" to "Snapdragon 870",
|
||||
"SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1",
|
||||
"SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3",
|
||||
"SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5",
|
||||
"SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3",
|
||||
"SM6375" to "Snapdragon 695",
|
||||
)
|
||||
|
||||
/** Platform code names: every chip of one platform shares its CPU and GPU design. */
|
||||
private val PLATFORMS = mapOf(
|
||||
"msmnile" to "Snapdragon 855/860", "kona" to "Snapdragon 865/870", "lahaina" to "Snapdragon 888",
|
||||
"taro" to "Snapdragon 8 Gen 1", "cape" to "Snapdragon 8+ Gen 1", "kalama" to "Snapdragon 8 Gen 2",
|
||||
"pineapple" to "Snapdragon 8 Gen 3", "sun" to "Snapdragon 8 Elite",
|
||||
)
|
||||
|
||||
/** The chip's model code ("QCS8550", "SG8350P"), or "" when the phone does not say. */
|
||||
fun model(prop: (String) -> String = GpuInfo::systemProperty): String {
|
||||
val sdk = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it != Build.UNKNOWN }.orEmpty() else ""
|
||||
return listOf(prop("ro.soc.model"), prop("ro.vendor.qti.soc_model"), sdk, prop("ro.fota.platform"))
|
||||
.map { it.trim() }
|
||||
// ro.fota.platform is often a branch name ("MSM_13.0"), not a chip.
|
||||
.firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty()
|
||||
}
|
||||
|
||||
/** "Snapdragon 8 Gen 2", "Snapdragon 8 Gen 3 family", or null when neither the model nor the platform is known. */
|
||||
fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? =
|
||||
MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" }
|
||||
|
||||
/** For the GPU card: "Snapdragon 8 Gen 2 (QCS8550)", the bare model, or "". */
|
||||
fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String {
|
||||
val name = name(model, platform)
|
||||
return when {
|
||||
name != null && model.isNotEmpty() -> "$name ($model)"
|
||||
name != null -> name
|
||||
else -> model
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* The phone's own Vulkan driver, as it describes itself (native/deviceinfo): device name, API and
|
||||
* driver versions, a few limits and the extensions DXVK/VKD3D lean on. Asked once per process and
|
||||
* kept; the first [query] creates a Vulkan instance, so it belongs off the main thread.
|
||||
*/
|
||||
object VulkanInfo {
|
||||
@Volatile private var cached: Map<String, String>? = null
|
||||
|
||||
@JvmStatic private external fun nativeQuery(): String
|
||||
|
||||
/** Blocking on first use. Empty-ish map with an "error" key when Vulkan cannot be asked. */
|
||||
@Synchronized
|
||||
fun query(): Map<String, String> {
|
||||
cached?.let { return it }
|
||||
val text = try {
|
||||
System.loadLibrary("deviceinfo")
|
||||
nativeQuery()
|
||||
} catch (t: Throwable) {
|
||||
Log.w("VulkanInfo", "query failed", t)
|
||||
"error=" + (t.message ?: t.javaClass.simpleName)
|
||||
}
|
||||
val map = text.lineSequence().mapNotNull { line ->
|
||||
val eq = line.indexOf('=')
|
||||
if (eq <= 0) null else line.substring(0, eq) to line.substring(eq + 1)
|
||||
}.toMap(LinkedHashMap())
|
||||
cached = map
|
||||
return map
|
||||
}
|
||||
|
||||
/** What [query] found, if it has run in this process; never blocks. */
|
||||
fun cachedOrNull(): Map<String, String>? = cached
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -300,7 +300,10 @@ object BwrapSpawner {
|
||||
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
|
||||
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
|
||||
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX",
|
||||
// Android 16 refuses the size seal Firefox puts on a tab's shared
|
||||
// memory, and the tab then dies on every page (droiddeck-desktop).
|
||||
"MOZ_SHM_NO_SEALS")) {
|
||||
env.putIfAbsent(name, "1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ object GuestCommand {
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
File(File(LinuxRuntime.debugLogDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
|
||||
@@ -42,12 +42,12 @@ 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";
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
private static final String SYSTEM_FONTS = "/system/fonts";
|
||||
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
public static File debugLogDir() {
|
||||
return new File(android.os.Environment.getExternalStoragePublicDirectory(
|
||||
android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR);
|
||||
public static File debugLogDir(Context context) {
|
||||
return new File(context.getFilesDir(), "logs");
|
||||
}
|
||||
|
||||
private LinuxRuntime() {}
|
||||
@@ -207,6 +207,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 +466,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;
|
||||
|
||||
@@ -21,13 +21,15 @@ import java.security.MessageDigest
|
||||
*/
|
||||
object ProotFastPath {
|
||||
const val LIBRARY = "/usr/local/lib/libblfastpath.so"
|
||||
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
|
||||
const val MAX_BINDS = 256
|
||||
|
||||
fun enabled(context: Context): Boolean =
|
||||
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
|
||||
|
||||
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
|
||||
fun key(root: File, binds: List<String>): String? {
|
||||
if (binds.any { it.contains('|') } || root.path.contains('|')) return null
|
||||
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest((root.path + "\n" + binds.joinToString("\n")).toByteArray())
|
||||
return digest.take(12).joinToString("") { "%02x".format(it) }
|
||||
|
||||
@@ -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,10 +1,12 @@
|
||||
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
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
@@ -247,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> {
|
||||
@@ -273,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. */
|
||||
@@ -362,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,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))
|
||||
@@ -439,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
|
||||
|
||||
/**
|
||||
@@ -507,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) {
|
||||
@@ -533,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) {
|
||||
@@ -575,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) }
|
||||
@@ -599,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)
|
||||
@@ -660,16 +674,117 @@ object ComponentsManager {
|
||||
return Catalog(items, o.optLong("fetchedAt"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A package Proton does not ship, required on the GPUs listed here. An empty [models] set
|
||||
* means every model in [families]. A row the user already chose in Components is left alone;
|
||||
* a Steam update that puts the Proton's own files back is replaced on the next session.
|
||||
*/
|
||||
internal data class ForcedPackage(
|
||||
val families: Set<GpuInfo.Family>,
|
||||
val comp: String,
|
||||
val file: String,
|
||||
val release: String,
|
||||
val models: Set<Int> = emptySet(),
|
||||
) {
|
||||
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
|
||||
}
|
||||
|
||||
internal val FORCED_PACKAGES = listOf(
|
||||
// DXVK 3 refuses to start unless the device exposes storageBuffer8BitAccess. Turnip on
|
||||
// Adreno 6xx does not. 2.7.1 is the last DXVK 2 package on the Dxvk-Linux tag.
|
||||
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this Proton should receive the forced package. No active swap means the Proton's
|
||||
* own copy. A record for a different Proton build means a Steam update put that copy back,
|
||||
* whichever package was recorded. A record for this build is a Components choice and stays.
|
||||
*/
|
||||
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
|
||||
activeFile.isNullOrEmpty() || !protonVersionMatches
|
||||
|
||||
/** The catalog row for this file and release, or null. */
|
||||
internal fun catalogMatch(items: List<CatalogItem>, file: String, release: String): CatalogItem? =
|
||||
items.firstOrNull { it.file == file && it.release == release }
|
||||
|
||||
/**
|
||||
* Install every [FORCED_PACKAGES] row this GPU matches into each Proton that
|
||||
* [forcedPackageWanted] selects. Returns a line for the log, or null when this GPU or these
|
||||
* Protons need nothing. A failed fetch throws and the session logs it without stopping.
|
||||
*/
|
||||
fun ensureForcedPackages(context: Context): String? = ensureForcedPackages(
|
||||
context, GpuInfo.detect(),
|
||||
{ file, release -> catalogItem(context, file, release) },
|
||||
{ download(context, it) {} },
|
||||
{ id, file -> swap(context, id, file) },
|
||||
)
|
||||
|
||||
internal fun ensureForcedPackages(
|
||||
context: Context,
|
||||
gpu: GpuInfo,
|
||||
lookup: (file: String, release: String) -> CatalogItem?,
|
||||
fetch: (CatalogItem) -> Unit,
|
||||
apply: (protonId: String, file: String) -> String,
|
||||
): String? {
|
||||
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }
|
||||
if (rules.isEmpty()) return null
|
||||
val installed = protons(context)
|
||||
val lines = mutableListOf<String>()
|
||||
for (rule in rules) {
|
||||
val state = loadState(context)
|
||||
val targets = installed.filter { p ->
|
||||
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
|
||||
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version)
|
||||
}
|
||||
if (targets.isEmpty()) continue
|
||||
val missing = missingPackage(context, rule, lookup, fetch)
|
||||
if (missing != null) {
|
||||
lines.add(missing)
|
||||
continue
|
||||
}
|
||||
lines.add(targets.joinToString("; ") { apply(it.id, rule.file) })
|
||||
}
|
||||
return lines.joinToString("; ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Null when [rule]'s package is stored and readable. Otherwise the log line, after a fetch. */
|
||||
private fun missingPackage(
|
||||
context: Context,
|
||||
rule: ForcedPackage,
|
||||
lookup: (file: String, release: String) -> CatalogItem?,
|
||||
fetch: (CatalogItem) -> Unit,
|
||||
): String? {
|
||||
val wcp = File(packagesDir(context), safeName(rule.file))
|
||||
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"
|
||||
fetch(item)
|
||||
return null
|
||||
}
|
||||
|
||||
/** The cached Nightlies row for this file, fetching the listing when the cache does not have it. */
|
||||
internal fun catalogItem(context: Context, file: String, release: String): CatalogItem? =
|
||||
resolveCatalogItem(file, release, { catalog(context, false).items }, { catalog(context, true).items })
|
||||
|
||||
/** [cached] when it has the row, otherwise [refresh]. [refresh] is not called on a hit. */
|
||||
internal fun resolveCatalogItem(
|
||||
file: String,
|
||||
release: String,
|
||||
cached: () -> List<CatalogItem>,
|
||||
refresh: () -> List<CatalogItem>,
|
||||
): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release)
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ object GameEnvironmentStore {
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
|
||||
val json = encode(resolved)
|
||||
// The Display page's texture filtering, as DXVK options the launcher appends to
|
||||
// DXVK_CONFIG after the profiles - a user's own DXVK_CONFIG entry keeps its options.
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,9 @@ object SessionArtifacts {
|
||||
/** Written last; a folder without it did not get its ending. */
|
||||
const val COMPLETE_MARKER = ".complete"
|
||||
|
||||
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
|
||||
private const val SCRUBBED_MARKER = ".scrubbed-2"
|
||||
|
||||
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
|
||||
fun collect(context: Context, dir: File, reason: String) {
|
||||
SessionLogCapture.stopFor(dir)
|
||||
try {
|
||||
val wayland = File(dir, "wayland.log")
|
||||
if (!wayland.exists()) {
|
||||
@@ -47,8 +45,7 @@ object SessionArtifacts {
|
||||
copySteamLogs(context, dir)
|
||||
// A session the system killed leaves its trace here and nowhere else.
|
||||
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
|
||||
scrubFolder(dir)
|
||||
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
|
||||
scrubTree(dir)
|
||||
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
|
||||
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
|
||||
} catch (e: Exception) {
|
||||
@@ -56,62 +53,52 @@ object SessionArtifacts {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Once, for session folders written before every file was scrubbed and before the device's own
|
||||
* addresses were (network.txt listed them; the client's IPv6 check logs "external address"
|
||||
* into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
|
||||
* A marker records it. Runs at app start with [finishAbandoned].
|
||||
*/
|
||||
@Synchronized
|
||||
fun scrubOlder(context: Context) {
|
||||
val current = SessionPaths.current()
|
||||
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
|
||||
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
|
||||
} ?: return
|
||||
if (dirs.isEmpty()) return
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
dirs.forEach { dir ->
|
||||
scrubFolder(dir)
|
||||
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
|
||||
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
|
||||
}
|
||||
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
|
||||
SessionPaths.sessionFolders(context).filter {
|
||||
it.parentFile == LinuxRuntime.debugLogDir(context) && it != SessionPaths.current() &&
|
||||
!File(it, COMPLETE_MARKER).exists()
|
||||
}.forEach { scrubTree(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's own files (session.log, app.log, desktop.log, the Steam desktop client's
|
||||
* steam-desktop.log, ...) were written as they happened, unscrubbed; this pass puts every one
|
||||
* through the redactor before the folder can be shared. A file is rewritten only if a line
|
||||
* changed. steam/ was scrubbed on the way in.
|
||||
*/
|
||||
private fun scrubFolder(dir: File) {
|
||||
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
|
||||
if (!LogRedactor.isText(f)) return@forEach
|
||||
internal fun scrubTree(dir: File) {
|
||||
SessionLogFiles.candidates(dir).forEach { file ->
|
||||
if (!SessionLogFiles.safeFile(dir, file)) return@forEach
|
||||
var tmp: File? = null
|
||||
try {
|
||||
val tmp = File(dir, ".${f.name}.scrub")
|
||||
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
|
||||
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${f.name}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam's logs: redacted into steam/, never copied verbatim. */
|
||||
private fun copySteamLogs(context: Context, dir: File) {
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
if (!logs.isDirectory) return
|
||||
val out = File(dir, "steam").apply { mkdirs() }
|
||||
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
|
||||
try {
|
||||
File(out, src.name).bufferedWriter().use { w ->
|
||||
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
|
||||
tmp = File.createTempFile(".redacted-", ".tmp", dir)
|
||||
val size = file.length()
|
||||
val modified = file.lastModified()
|
||||
val limit = if (file.parentFile?.name == "steam") SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES
|
||||
tmp.bufferedWriter().use { SessionLogFiles.scrubTo(dir, file, it, limit) }
|
||||
if (file.length() == size && file.lastModified() == modified) {
|
||||
check(tmp.renameTo(file))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${file.name}", e)
|
||||
} finally {
|
||||
tmp?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copySteamLogs(context: Context, dir: File) {
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
if (!logs.isDirectory || java.nio.file.Files.isSymbolicLink(logs.toPath())) return
|
||||
val out = File(dir, "steam")
|
||||
if (java.nio.file.Files.isSymbolicLink(out.toPath()) || !(out.isDirectory || out.mkdirs())) return
|
||||
SessionLogFiles.steamNames.map { File(logs, it) }.filter { it.exists() }.forEach { src ->
|
||||
val tmp = File.createTempFile(".redacted-", ".tmp", out)
|
||||
try {
|
||||
tmp.bufferedWriter().use { SessionLogFiles.scrubTo(logs, src, it, SessionLogFiles.STEAM_MAX_BYTES) }
|
||||
check(tmp.renameTo(File(out, src.name)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${src.name}", e)
|
||||
} finally {
|
||||
tmp?.delete()
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,10 +109,10 @@ object SessionArtifacts {
|
||||
*/
|
||||
@Synchronized
|
||||
fun finishAbandoned(context: Context) {
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
val abandoned = parent.listFiles { f ->
|
||||
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
|
||||
}?.sortedWith(SessionPaths.chronological) ?: return
|
||||
SessionLogMigration.run(context)
|
||||
val abandoned = SessionPaths.sessionFolders(context).filter {
|
||||
it.parentFile == LinuxRuntime.debugLogDir(context) && !File(it, COMPLETE_MARKER).exists()
|
||||
}
|
||||
if (abandoned.isEmpty()) return
|
||||
val current = SessionPaths.current()
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
@@ -143,12 +130,10 @@ object SessionArtifacts {
|
||||
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
|
||||
"session died of a crash, the entry is in there unless the device rebooted.\n"
|
||||
)
|
||||
if (newest) {
|
||||
copySteamLogs(context, dir)
|
||||
}
|
||||
if (newest) copySteamLogs(context, dir)
|
||||
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
|
||||
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
|
||||
scrubFolder(dir)
|
||||
scrubTree(dir)
|
||||
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
|
||||
Log.i(TAG, "finished the abandoned session folder $dir")
|
||||
} catch (e: Exception) {
|
||||
@@ -168,7 +153,7 @@ object SessionArtifacts {
|
||||
val current = SessionPaths.current()
|
||||
val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() }
|
||||
val old = finished.dropLast(SessionPaths.KEEP_SESSIONS)
|
||||
old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
old.forEach { SessionLogFiles.deleteTree(it) }
|
||||
if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}")
|
||||
}
|
||||
|
||||
@@ -181,10 +166,11 @@ object SessionArtifacts {
|
||||
fun clearAll(context: Context): Int {
|
||||
val current = SessionPaths.current()
|
||||
val gone = SessionPaths.sessionFolders(context).filter { it != current }
|
||||
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
|
||||
gone.forEach { SessionLogFiles.deleteTree(it) }
|
||||
LinuxRuntime.debugLogDir(context).listFiles { f -> f.name == SessionPaths.TOOLS_DIR || f.name.matches(Regex("tools-\\d+")) }
|
||||
?.forEach { SessionLogFiles.deleteTree(it) }
|
||||
// Before tools/, those logs sat loose beside the session folders.
|
||||
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
|
||||
LinuxRuntime.debugLogDir(context).listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
|
||||
Log.i(TAG, "cleared ${gone.size} session folder(s)")
|
||||
return gone.size
|
||||
}
|
||||
|
||||
@@ -6,23 +6,40 @@ import android.view.WindowManager
|
||||
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
|
||||
object SessionDisplay {
|
||||
const val MATCH_SCREEN = "screen"
|
||||
const val DEFAULT_RESOLUTION = "1280x720"
|
||||
|
||||
/**
|
||||
* Presets are heights, not sizes: "720p" is the panel's own shape at 720 lines, never narrower
|
||||
* than 16:9 and never taller than the panel (SHAPE_AUTO). The client treats the output as the
|
||||
* games' native resolution, so the height is what bounds a game's default size on a 1440p
|
||||
* phone; the shape is what keeps the client's interface edge to edge on a 20:9 one (1600x720)
|
||||
* and keeps games at 16:9 or wider on a foldable's near-square panel.
|
||||
*/
|
||||
const val DEFAULT_RESOLUTION = "720p"
|
||||
private val PRESET_HEIGHTS = listOf(720, 900, 1080)
|
||||
|
||||
/** The height of a preset choice ("900p"), or null for Match screen and fixed sizes. */
|
||||
fun presetHeight(choice: String): Int? =
|
||||
if (choice.endsWith("p")) choice.dropLast(1).toIntOrNull()?.takeIf { it > 0 } else null
|
||||
|
||||
fun screenSize(panel: Pair<Int, Int>): Pair<Int, Int> =
|
||||
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
|
||||
|
||||
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> =
|
||||
if (choice == MATCH_SCREEN) screenSize(panel) else {
|
||||
// Legacy displays may exceed the custom dialog's size limit. Keep their exact size.
|
||||
val parts = choice.split('x').map { it.toIntOrNull() }
|
||||
if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
|
||||
else 1280 to 720
|
||||
}
|
||||
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> {
|
||||
if (choice == MATCH_SCREEN) return screenSize(panel)
|
||||
presetHeight(choice)?.let { return resolve(panel, it, SessionPrefs.SHAPE_AUTO) }
|
||||
// Fixed sizes (custom, or a preset saved before presets were heights) keep their exact size,
|
||||
// including legacy ones past the custom dialog's limit.
|
||||
val parts = choice.split('x').map { it.toIntOrNull() }
|
||||
return if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
|
||||
else resolve(panel, 720, SessionPrefs.SHAPE_AUTO)
|
||||
}
|
||||
|
||||
/** Fixed dimensions and the current panel, with no duplicate panel-size preset. */
|
||||
fun resolutionOptions(panel: Pair<Int, Int>): List<String> =
|
||||
listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080")
|
||||
/** The height presets the panel is tall enough for, then the panel itself, with no duplicate sizes. */
|
||||
fun resolutionOptions(panel: Pair<Int, Int>): List<String> {
|
||||
val panelHeight = minOf(panel.first, panel.second)
|
||||
return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" }
|
||||
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
|
||||
}
|
||||
|
||||
fun panelSize(context: Context): Pair<Int, Int> {
|
||||
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
|
||||
@@ -28,19 +28,24 @@ object SessionFiles {
|
||||
*/
|
||||
fun stage(context: Context, root: File) {
|
||||
GameEnvironmentStore.publish(context)
|
||||
WinComponents.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
|
||||
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
|
||||
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
|
||||
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
|
||||
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
|
||||
"usr/local/bin/droiddeck-fex" to "usr/local/bin/droiddeck-fex",
|
||||
"libblsession.so" to "usr/local/lib/libblsession.so",
|
||||
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
|
||||
"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/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",
|
||||
@@ -103,6 +108,10 @@ object SessionFiles {
|
||||
"usr/local/lib/mangoapp/libtraceevent.so.1",
|
||||
"usr/local/lib/mangoapp/libtracefs.so.1",
|
||||
).map { it to it }
|
||||
// What the Windows components installer reads .msi packages with (tools/msitools).
|
||||
val msitools = listOf("msiinfo", "cabextract", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
|
||||
.map { "usr/local/lib/droiddeck-msitools/$it" } +
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
// The patched wlroots (tools/wlroots) the desktop loads for its vulkan / gles2 renderers.
|
||||
val wlroots = if (File(root, "usr/bin/labwc").isFile) {
|
||||
arrayOf("usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" to "usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so")
|
||||
@@ -114,7 +123,7 @@ object SessionFiles {
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
|
||||
) + wlroots + mangoapp + fexPreloads).filter { (asset, _) ->
|
||||
) + wlroots + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
@@ -340,26 +349,9 @@ object SessionFiles {
|
||||
indent + "}",
|
||||
).joinToString(newline)
|
||||
|
||||
/**
|
||||
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
|
||||
* folder rather than dug out of app-private storage - but the session script redirects its own
|
||||
* output there with `exec`, and a redirection a non-interactive shell cannot open ends that
|
||||
* shell. So a public directory is used only once it is proven writable; otherwise the app's
|
||||
* own files directory, which is bound into the session anyway, stands in.
|
||||
*/
|
||||
fun logDirectory(context: Context): File {
|
||||
val public = LinuxRuntime.debugLogDir()
|
||||
if (public.isDirectory || public.mkdirs()) {
|
||||
val probe = File(public, ".writable")
|
||||
try {
|
||||
if (probe.createNewFile() || probe.isFile) {
|
||||
probe.delete()
|
||||
return public
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
fun logDirectory(context: Context): File =
|
||||
LinuxRuntime.debugLogDir(context).apply {
|
||||
check(!java.nio.file.Files.isSymbolicLink(toPath()))
|
||||
check(isDirectory || mkdirs())
|
||||
}
|
||||
Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
|
||||
return File(context.filesDir, "logs").apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.io.Writer
|
||||
import java.nio.channels.Channels
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
|
||||
internal object SessionLogFiles {
|
||||
const val MAX_BYTES = 64L * 1024 * 1024
|
||||
const val STEAM_MAX_BYTES = 8L * 1024 * 1024
|
||||
private const val MAX_LINE_CHARS = 64 * 1024
|
||||
|
||||
val steamNames = setOf(
|
||||
"bootstrap_log.txt", "connection_log.txt", "console_log.txt", "content_log.txt",
|
||||
"controller.txt", "compat_log.txt", "cef_log.txt", "cef_log.previous.txt",
|
||||
"shader_log.txt", "systemaudiomanager.txt", "steamwebhelper.log", "webhelper.txt",
|
||||
"steamui_html.txt", "steamui_system.txt", "steamui_library.txt", "steamui_login.txt",
|
||||
"remote_connections.txt", "streaming_log.txt", "video.txt", "vrclient_steam.txt"
|
||||
)
|
||||
private val paths = setOf(
|
||||
"session.log", "session.log.1", "app.log", "crash.log", "crash-app.txt",
|
||||
"wayland.log", "audio.log", "device.txt", "network.txt", "events.jsonl",
|
||||
"desktop.log", "steam-desktop.log", "steam.log", "pad.log", "storage.log",
|
||||
"ended-without-teardown.txt", "droiddeck-esync/launches.log",
|
||||
"droiddeck-esync/tools.tsv", "droiddeck-esync/wanted.tsv",
|
||||
"droiddeck-esync/prefixes.json", "droiddeck-fex/status.json", "droiddeck-fex/Config.json"
|
||||
) + steamNames.map { "steam/$it" }
|
||||
|
||||
fun candidates(folder: File): List<File> = paths.map { File(folder, it) }
|
||||
.filter { Files.exists(it.toPath(), NOFOLLOW_LINKS) }
|
||||
|
||||
fun deleteTree(file: File) {
|
||||
if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return
|
||||
Files.walk(file.toPath()).use { stream ->
|
||||
stream.iterator().asSequence().toList().asReversed().forEach { Files.deleteIfExists(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun safeFile(root: File, file: File): Boolean {
|
||||
val base = root.toPath().toAbsolutePath().normalize()
|
||||
var path = file.toPath().toAbsolutePath().normalize()
|
||||
if (!path.startsWith(base) || path == base) return false
|
||||
while (path != base) {
|
||||
if (Files.isSymbolicLink(path)) return false
|
||||
path = path.parent ?: return false
|
||||
}
|
||||
return !Files.isSymbolicLink(base) && Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS)
|
||||
}
|
||||
|
||||
fun scrubTo(root: File, file: File, out: Writer, maxBytes: Long = MAX_BYTES) {
|
||||
if (!safeFile(root, file)) throw IOException("unsafe path")
|
||||
Files.newByteChannel(file.toPath(), READ, NOFOLLOW_LINKS).use { channel ->
|
||||
var remaining = channel.size()
|
||||
if (remaining >= maxBytes) throw IOException("size limit")
|
||||
val source = Channels.newInputStream(channel)
|
||||
val snapshot = object : InputStream() {
|
||||
override fun read(): Int {
|
||||
val one = ByteArray(1)
|
||||
return if (read(one, 0, 1) < 0) -1 else one[0].toInt() and 255
|
||||
}
|
||||
|
||||
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
|
||||
if (length == 0) return 0
|
||||
if (remaining == 0L) return -1
|
||||
val count = source.read(bytes, offset, minOf(length.toLong(), remaining).toInt())
|
||||
if (count < 0) throw IOException("truncated snapshot")
|
||||
remaining -= count
|
||||
return count
|
||||
}
|
||||
}
|
||||
val decoder = Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
val reader = InputStreamReader(snapshot, decoder)
|
||||
val line = StringBuilder()
|
||||
val buffer = CharArray(4096)
|
||||
fun writeLine() {
|
||||
out.write(LogRedactor.redact(line.toString().removeSuffix("\r")))
|
||||
out.write("\n")
|
||||
line.setLength(0)
|
||||
}
|
||||
while (true) {
|
||||
val count = reader.read(buffer)
|
||||
if (count < 0) break
|
||||
for (i in 0 until count) {
|
||||
val char = buffer[i]
|
||||
if (char == '\u0000') throw IOException("binary data")
|
||||
if (char == '\n') writeLine() else {
|
||||
if (line.length >= MAX_LINE_CHARS) throw IOException("line limit")
|
||||
line.append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (line.isNotEmpty()) {
|
||||
if (channel.size() != channel.position()) out.write("<incomplete live line withheld>\n")
|
||||
else writeLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
|
||||
internal object SessionLogMigration {
|
||||
fun run(context: Context) {
|
||||
val old = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), LinuxRuntime.DEBUG_LOG_DIR)
|
||||
val target = LinuxRuntime.debugLogDir(context)
|
||||
if (!old.isDirectory || Files.isSymbolicLink(old.toPath())) return
|
||||
check(target.isDirectory || target.mkdirs())
|
||||
old.listFiles().orEmpty().filter {
|
||||
SessionPaths.isSessionFolder(it) || it.name == SessionPaths.TOOLS_DIR ||
|
||||
(it.isFile && it.name.endsWith(".log"))
|
||||
}.forEach { source ->
|
||||
try {
|
||||
move(source, target)
|
||||
} catch (e: Exception) {
|
||||
Log.w("SessionLogMigration", "could not move legacy logs into private storage", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun move(source: File, target: File) {
|
||||
check(!Files.isSymbolicLink(target.toPath()))
|
||||
val paths = Files.walk(source.toPath()).use { it.iterator().asSequence().toList() }
|
||||
check(paths.all { !Files.isSymbolicLink(it) &&
|
||||
(Files.isRegularFile(it, NOFOLLOW_LINKS) || Files.isDirectory(it, NOFOLLOW_LINKS)) })
|
||||
var destination = File(target, source.name)
|
||||
var number = 1
|
||||
val stem = Regex("^session-\\d{8}-\\d{6}").find(source.name)?.value ?: source.name
|
||||
while (Files.exists(destination.toPath(), NOFOLLOW_LINKS)) {
|
||||
val name = if (source.isFile) "${source.nameWithoutExtension}-${number++}.${source.extension}"
|
||||
else "$stem-${number++}"
|
||||
destination = File(target, name)
|
||||
}
|
||||
if (source.renameTo(destination)) return
|
||||
val stage = File.createTempFile(".migrating-", ".tmp", target)
|
||||
val versions = paths.associate { it.toFile().let { file -> file to (file.length() to file.lastModified()) } }
|
||||
try {
|
||||
if (source.isDirectory) { check(stage.delete()); check(stage.mkdir()) }
|
||||
paths.forEach { path ->
|
||||
val file = path.toFile()
|
||||
val out = if (file == source) stage else File(stage, file.relativeTo(source).path)
|
||||
if (Files.isDirectory(path, NOFOLLOW_LINKS)) {
|
||||
check(out.isDirectory || out.mkdirs())
|
||||
} else {
|
||||
check(file == source || SessionLogFiles.safeFile(source, file))
|
||||
Files.newByteChannel(path, java.nio.file.StandardOpenOption.READ, NOFOLLOW_LINKS).use { input ->
|
||||
out.outputStream().use { output -> java.nio.channels.Channels.newInputStream(input).copyTo(output) }
|
||||
}
|
||||
check(out.length() == versions.getValue(file).first)
|
||||
}
|
||||
}
|
||||
check(versions.all { (file, version) -> file.length() == version.first && file.lastModified() == version.second })
|
||||
paths.asReversed().forEach { path ->
|
||||
val file = path.toFile()
|
||||
val out = if (file == source) stage else File(stage, file.relativeTo(source).path)
|
||||
check(out.setLastModified(versions.getValue(file).second))
|
||||
}
|
||||
check(stage.renameTo(destination))
|
||||
paths.asReversed().forEach { Files.delete(it) }
|
||||
} finally {
|
||||
stage.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,70 +3,100 @@ package com.droiddeck.launcher.session
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
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
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Zips the most recent session's log folder and hands it to Android's share sheet. Works where
|
||||
* the folder could not be written to Downloads (no storage permission, or a device that refuses
|
||||
* it) and landed in the app's private files instead, which a user cannot otherwise reach.
|
||||
*/
|
||||
object SessionLogShare {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* How far the zip for a share has got, 0..1, or null when none is being made. The Share logs
|
||||
* buttons show it in place of their label, so a tap is answered at once.
|
||||
*/
|
||||
var progress by mutableStateOf<Float?>(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Zips [folder] on a worker thread with [progress] following along, then hands the zip (null
|
||||
* if there was nothing to zip or it failed) to [done] on the main thread. Call on the main
|
||||
* thread; a tap while a zip is being made is ignored.
|
||||
*/
|
||||
fun prepare(context: Context, folder: () -> File?, done: (File?) -> Unit) {
|
||||
if (progress != null) return
|
||||
progress = 0f
|
||||
Thread({
|
||||
val zip = runCatching { folder()?.let { dir -> zipFolder(context, dir) { p -> main.post { if (progress != null) progress = p } } } }
|
||||
.onFailure { Log.w("SessionLogShare", "could not package session logs", it) }
|
||||
.getOrNull()
|
||||
main.post { progress = null; done(zip) }
|
||||
}, "share-logs").start()
|
||||
}
|
||||
|
||||
/** The newest session folder in either place logs are written, or null if there is none. */
|
||||
fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull()
|
||||
|
||||
/** Builds the zip (blocking). Returns null when there is no session to share. */
|
||||
fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) }
|
||||
|
||||
/** Builds a zip for one specific session folder (blocking). */
|
||||
fun zipFolder(context: Context, folder: File): File? {
|
||||
if (!folder.isDirectory) return null
|
||||
val files = folder.walkTopDown().filter { it.isFile }.toList()
|
||||
if (files.isEmpty()) return null
|
||||
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
|
||||
val zip = File(out, "DroidDeck-${folder.name}.zip")
|
||||
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
|
||||
// session pass yet, and the redactor changes nothing in a line that is already clean.
|
||||
@Synchronized
|
||||
fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? {
|
||||
if (!folder.isDirectory || java.nio.file.Files.isSymbolicLink(folder.toPath())) return null
|
||||
val files = SessionLogFiles.candidates(folder).map { Triple(folder, it, it.relativeTo(folder).invariantSeparatorsPath) }
|
||||
val liveRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
val live = if (folder == SessionPaths.current()) SessionLogFiles.steamNames
|
||||
.filter { name -> files.none { it.third == "steam/$name" } }
|
||||
.map { File(liveRoot, it) }.filter { java.nio.file.Files.exists(it.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) }
|
||||
.map { Triple(liveRoot, it, "steam/${it.name}") } else emptyList()
|
||||
val sources = files + live
|
||||
if (sources.isEmpty()) return null
|
||||
val out = File(context.cacheDir, "shared-logs").apply { SessionLogFiles.deleteTree(this); check(mkdirs()) }
|
||||
val name = folder.name.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val zip = File(out, "DroidDeck-$name.zip")
|
||||
val partial = File(out, ".export")
|
||||
val stage = File.createTempFile("redacted-", ".tmp", context.cacheDir)
|
||||
val omitted = ArrayList<String>()
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
|
||||
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
|
||||
try {
|
||||
ZipOutputStream(partial.outputStream().buffered()).use { z ->
|
||||
sources.forEachIndexed { index, (root, src, relative) ->
|
||||
val limit = if (relative.startsWith("steam/")) SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES
|
||||
val failure = runCatching {
|
||||
stage.bufferedWriter().use { SessionLogFiles.scrubTo(root, src, it, limit) }
|
||||
}.exceptionOrNull()
|
||||
if (failure == null) {
|
||||
z.putNextEntry(ZipEntry("$name/$relative"))
|
||||
stage.inputStream().use { it.copyTo(z) }
|
||||
z.closeEntry()
|
||||
} else {
|
||||
omitted += "$relative: omitted because it could not be safely sanitized."
|
||||
}
|
||||
onProgress?.invoke((index + 1).toFloat() / sources.size)
|
||||
}
|
||||
if (omitted.isNotEmpty()) {
|
||||
z.putNextEntry(ZipEntry("$name/omitted.txt"))
|
||||
z.write((omitted.joinToString("\n") + "\n").toByteArray(Charsets.UTF_8))
|
||||
z.closeEntry()
|
||||
}
|
||||
}
|
||||
check(partial.renameTo(zip))
|
||||
return zip
|
||||
} catch (e: Exception) {
|
||||
partial.delete()
|
||||
zip.delete()
|
||||
throw e
|
||||
} finally {
|
||||
stage.delete()
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
|
||||
z.putNextEntry(ZipEntry(name))
|
||||
if (LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubTo(f, w)
|
||||
w.flush()
|
||||
} else {
|
||||
f.inputStream().use { it.copyTo(z) }
|
||||
}
|
||||
z.closeEntry()
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's own logs as they stand, for a session shared while it runs. The session script
|
||||
* copies them into steam/ only as it exits, so a zip made from the drawer had none - and
|
||||
* controller.txt (which pad the client opened, the touch mode it set) is what a controller or
|
||||
* touch report needs most. Only for the running session: an older folder would get this
|
||||
* session's logs. The same files the script copies; nothing holding credentials is in logs/.
|
||||
*/
|
||||
private fun liveSteamLogs(context: Context, folder: File): List<File> {
|
||||
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
|
||||
}
|
||||
|
||||
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
|
||||
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
|
||||
|
||||
fun shareIntent(context: Context, zip: File): Intent {
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
@@ -75,6 +105,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)
|
||||
}
|
||||
}
|
||||
@@ -7,29 +7,6 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
|
||||
* named for the day, that day's session number and what was run, so the one a report is about can
|
||||
* be picked out without opening any:
|
||||
*
|
||||
* ```
|
||||
* Download/DroidDeck/2026-09-30-03-steam/
|
||||
* device.txt what this device is, and every setting the session ran with
|
||||
* session.log the guest session: proot, gamescope, the client's stdout
|
||||
* wayland.log the app's compositor
|
||||
* steam.log the Steam client's own log, scrubbed (Steam mode)
|
||||
* steam/ the rest of the client's logs, scrubbed (Steam mode)
|
||||
* desktop.log labwc, the panel and the programs on it (desktop mode)
|
||||
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
|
||||
* ```
|
||||
*
|
||||
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
|
||||
* pruned with the rest.
|
||||
*
|
||||
* The folder is claimed by whoever starts first - the activity starts the compositor before the
|
||||
* service starts the session - so both write into the same one, and a recreated activity (a
|
||||
* foldable opening mid-session) joins the folder in progress instead of opening another.
|
||||
*/
|
||||
object SessionPaths {
|
||||
private const val TAG = "SessionPaths"
|
||||
|
||||
@@ -50,12 +27,6 @@ object SessionPaths {
|
||||
@Volatile
|
||||
private var ended: File? = null
|
||||
|
||||
/**
|
||||
* The folder for the session now starting, or the one already in progress. With logs turned
|
||||
* off on the main screen the folder lives in the app's cache instead of Downloads - the
|
||||
* scripts and the compositor still need somewhere to write - and [release] deletes it.
|
||||
* [label] says what is being run (see [label]); without one it comes from the session's state.
|
||||
*/
|
||||
@Synchronized
|
||||
fun beginOrCurrent(context: Context, label: String? = null): File {
|
||||
dir?.let { if (it.isDirectory) return it }
|
||||
@@ -104,8 +75,8 @@ object SessionPaths {
|
||||
// next session may have claimed its own by the time the last one's collector gets here.
|
||||
if (dir == ended) dir = null
|
||||
// A folder kept in the cache was never meant to outlive its session.
|
||||
if (ended != null && ended.path.startsWith(context.cacheDir.path)) {
|
||||
com.droiddeck.launcher.core.FileUtils.delete(ended)
|
||||
if (ended.parentFile == File(context.cacheDir, "session-logs")) {
|
||||
SessionLogFiles.deleteTree(ended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,22 +94,28 @@ object SessionPaths {
|
||||
|
||||
/** Whether [f] is a session's folder, in either naming. */
|
||||
fun isSessionFolder(f: File): Boolean =
|
||||
f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name))
|
||||
f.isDirectory && !java.nio.file.Files.isSymbolicLink(f.toPath()) &&
|
||||
(NAMED.matches(f.name) || LEGACY.matches(f.name))
|
||||
|
||||
/**
|
||||
* Oldest first: by day, then by when in the day. A folder from before the new naming sorts
|
||||
* ahead of a new one from the same day, which it always predates.
|
||||
*/
|
||||
val chronological: Comparator<File> = compareBy { f ->
|
||||
val chronological: Comparator<File> = compareBy<File> { f ->
|
||||
NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" }
|
||||
?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" }
|
||||
?: f.name
|
||||
}
|
||||
}.thenBy { it.lastModified() }
|
||||
|
||||
/** Every session folder in the places logs are written, oldest first. */
|
||||
fun sessionFolders(context: Context): List<File> =
|
||||
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
|
||||
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
|
||||
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(context),
|
||||
File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS),
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.DEBUG_LOG_DIR))
|
||||
.flatMap { parent ->
|
||||
if (java.nio.file.Files.isSymbolicLink(parent.toPath())) emptyList()
|
||||
else parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList()
|
||||
}
|
||||
.sortedWith(chronological)
|
||||
|
||||
internal fun slug(text: String): String =
|
||||
|
||||
@@ -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
|
||||
@@ -8,8 +10,11 @@ import org.json.JSONObject
|
||||
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
|
||||
object SessionPrefs {
|
||||
const val SUSPEND_AUTO = "auto"
|
||||
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"
|
||||
@@ -18,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)
|
||||
@@ -155,49 +162,20 @@ 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Steam only: gamescope makes every game window the size of the screen. A game that resizes
|
||||
* its own window when it loses focus (FlatOut) otherwise comes back smaller, drawn in a
|
||||
* corner; a game that sets its own resolution and never looks at its window again (Quake 3)
|
||||
* instead draws small in the bottom-left of the stretched one. A game whose resolution differs
|
||||
* from the screen's (DiRT 3 at 1280x720) fights it: it rebuilds its swapchain on every forced
|
||||
* resize and the picture flickers between its own size and the screen's, often from the first
|
||||
* menu, before the player can reach its resolution setting. Off unless turned on.
|
||||
*/
|
||||
fun forceFullscreen(context: Context): Boolean = prefs(context).getBoolean("forceFullscreen", false)
|
||||
|
||||
fun setForceFullscreen(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("forceFullscreen", on).apply()
|
||||
writeForceFullscreenFlag(context)
|
||||
}
|
||||
|
||||
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
|
||||
|
||||
fun setStretch16x9(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("stretch16x9", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The same choice as a file the running session watches, so the drawer can change it live:
|
||||
* the session hands every change to gamescope, which reads GAMESCOPE_FORCE_WINDOWS_FULLSCREEN
|
||||
* off its root window whenever it changes. Written again at every session start so a file left
|
||||
* by an earlier session never disagrees with the setting.
|
||||
*/
|
||||
fun writeForceFullscreenFlag(context: Context) {
|
||||
runCatching {
|
||||
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-fill")
|
||||
.writeText(if (forceFullscreen(context)) "1\n" else "0\n")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DirectAudio for games: their Wine audio driver talks to the relay helper on this side. On
|
||||
@@ -515,7 +493,7 @@ object SessionPrefs {
|
||||
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
|
||||
val saved = prefs(context)
|
||||
saved.getString("displayResolution.$mode", null)?.let { value ->
|
||||
if (value == SessionDisplay.MATCH_SCREEN) return value
|
||||
if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(value) != null) return value
|
||||
parseResolution(value)?.let { return "${it.first}x${it.second}" }
|
||||
}
|
||||
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
|
||||
@@ -525,7 +503,7 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
fun setResolutionChoice(context: Context, mode: String, choice: String) {
|
||||
val value = if (choice == SessionDisplay.MATCH_SCREEN) choice else {
|
||||
val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(choice) != null) choice else {
|
||||
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
|
||||
"${size.first}x${size.second}"
|
||||
}
|
||||
@@ -542,6 +520,22 @@ object SessionPrefs {
|
||||
|
||||
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
|
||||
|
||||
/**
|
||||
* Force SSBS for Proton games: Wine resumes threads from a Windows CONTEXT that never carries
|
||||
* PSTATE.SSBS, so they run with speculative store bypass disabled; libssbs.so keeps it set
|
||||
* (on DiRT 3 / GE-Proton: from ~99% of a game's threads running without it to none). The
|
||||
* speed-up is reported on Oryon cores (Snapdragon 8 Elite) and was not measurable on an
|
||||
* 8 Gen 3, so it is off unless turned on. A game's own environment can still say
|
||||
* DROIDDECK_FORCE_SSBS=0.
|
||||
*/
|
||||
fun forceSsbs(context: Context): Boolean = prefs(context).getBoolean("forceSsbs", false)
|
||||
|
||||
fun setForceSsbs(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("forceSsbs", on).apply()
|
||||
runCatching { GameEnvironmentStore.publish(context) }
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
fun setFexPreset(context: Context, id: String) {
|
||||
prefs(context).edit().putString("fexPreset", id).apply()
|
||||
runCatching { GameEnvironmentStore.publish(context) }
|
||||
@@ -599,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. */
|
||||
@@ -669,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
|
||||
@@ -680,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
|
||||
@@ -714,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"
|
||||
@@ -722,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
|
||||
@@ -760,13 +798,41 @@ object SessionPrefs {
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
|
||||
?.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
|
||||
?.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 (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 == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
|
||||
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
|
||||
@@ -43,6 +44,7 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Owns the running session: the proot tree, the audio daemon, the network link and the locks that
|
||||
@@ -59,6 +61,9 @@ import java.util.Locale
|
||||
* 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()
|
||||
@@ -71,12 +76,21 @@ 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
|
||||
/** An explicit Steam sleep request is independent of the background policy. */
|
||||
private var steamSleepToken: String? = null
|
||||
private var nativeSleepToken: String? = null
|
||||
private var nativeSleepReady = false
|
||||
private var nativeSleepFallback = false
|
||||
private var nativeSleepTimeout: Runnable? = null
|
||||
private var suspendOperationPending = false
|
||||
private var pipTask = false
|
||||
private var suspendAttemptFailed = false
|
||||
@@ -159,6 +173,7 @@ class SessionService : Service() {
|
||||
ACTION_ACTIVITY_VISIBLE -> {
|
||||
if (!SessionState.pipActive) pipTask = false
|
||||
activityVisible = true
|
||||
suspendDownloadNow = false
|
||||
resumeSteamSleepOnReturn()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
@@ -173,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
|
||||
@@ -191,10 +214,15 @@ 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
|
||||
suspendAttemptFailed = false
|
||||
suspendController = null
|
||||
@@ -249,9 +277,6 @@ class SessionService : Service() {
|
||||
try {
|
||||
SessionArtifacts.collect(this, dir, "session stopped")
|
||||
} finally {
|
||||
// Last, so everything above is in the file it is about - and only this session's:
|
||||
// a session that replaced this one may already own the capture and the folder.
|
||||
SessionLogCapture.stopFor(dir)
|
||||
SessionPaths.release(this, dir)
|
||||
}
|
||||
}
|
||||
@@ -303,6 +328,14 @@ class SessionService : Service() {
|
||||
// The desktop's Steam launchers start the client there (droiddeck-steam-launch), through the
|
||||
// same set-up as a Steam session: it gets what the client and its games are started with.
|
||||
val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
// Some GPUs need a component Proton does not ship. Apply those before the guest, so the
|
||||
// next game launch copies them into the prefix. A download failure is logged and the
|
||||
// session still starts; the next session tries again.
|
||||
if (steamHere) {
|
||||
runCatching { ComponentsManager.ensureForcedPackages(this) }
|
||||
.onSuccess { if (it != null) Log.i(TAG, it) }
|
||||
.onFailure { Log.w(TAG, "forced components", it) }
|
||||
}
|
||||
addClientEnvironment(guest, steamHere)
|
||||
// Where the fast path's description of proot's view goes, once the binds are known.
|
||||
val fastPathAt = guest.size
|
||||
@@ -390,7 +423,7 @@ class SessionService : Service() {
|
||||
// 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(
|
||||
@@ -402,6 +435,9 @@ class SessionService : Service() {
|
||||
guest.addAll(fastPathAt, env)
|
||||
shellGuest.addAll(fastPathAt, env)
|
||||
Log.i(TAG, "proot: fast path on (${prootBinds.size} binds)")
|
||||
} ?: run {
|
||||
Log.w(TAG, "proot: fast path off - ${prootBinds.size} binds (it holds ${ProotFastPath.MAX_BINDS}) or a path it cannot be told")
|
||||
null
|
||||
}
|
||||
} else null
|
||||
|
||||
@@ -505,6 +541,7 @@ class SessionService : Service() {
|
||||
},
|
||||
)
|
||||
mainHandler.post { updateSuspendPolicy() }
|
||||
updateDownloadMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +574,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)
|
||||
@@ -590,9 +633,7 @@ class SessionService : Service() {
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
|
||||
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
|
||||
SessionPrefs.writeForceFullscreenFlag(this)
|
||||
}
|
||||
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
|
||||
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
|
||||
@@ -604,6 +645,7 @@ 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"))
|
||||
guest.add("BANNER_AUDIO_DIRECT_DECAY=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,
|
||||
@@ -732,6 +774,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")
|
||||
@@ -793,7 +837,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.
|
||||
@@ -852,6 +896,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
|
||||
}
|
||||
|
||||
@@ -992,6 +1075,9 @@ 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()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
@@ -1070,11 +1156,28 @@ class SessionService : Service() {
|
||||
|
||||
private fun watchLaunchRequests(dir: File) {
|
||||
launchWatcher?.stopWatching()
|
||||
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready").forEach { File(dir, it).delete() }
|
||||
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready", "steam-wake", "steam-wake.tmp",
|
||||
"steam-native-sleep", "steam-native-sleep.tmp", "steam-native-ready", "steam-native-ready.tmp"
|
||||
).forEach { File(dir, it).delete() }
|
||||
val gen = sessionGen
|
||||
@Suppress("DEPRECATION")
|
||||
val watcher = object : android.os.FileObserver(dir.path, CLOSE_WRITE or MOVED_TO) {
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
if (path == "steam-native-ready") {
|
||||
val request = File(dir, path)
|
||||
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
|
||||
request.delete()
|
||||
mainHandler.post {
|
||||
if (gen != sessionGen || !SessionState.running || token != nativeSleepToken) return@post
|
||||
nativeSleepReady = true
|
||||
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
nativeSleepTimeout = null
|
||||
writeSteamSleepState(token, "paused")
|
||||
SessionEvents.record("steam.native_sleep_prepared")
|
||||
updateSuspendPolicy()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (path == "steam-sleep") {
|
||||
val request = File(dir, path)
|
||||
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
|
||||
@@ -1142,18 +1245,30 @@ class SessionService : Service() {
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
|
||||
manualPauseRequested = true
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
val hidden = !activityVisible || !screenOn
|
||||
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_MANUAL -> manualPauseRequested
|
||||
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
|
||||
else -> false
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
|
||||
val gen = sessionGen
|
||||
suspendOperationPending = true
|
||||
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
|
||||
"policy" to suspendPolicy, "activityVisible" to activityVisible,
|
||||
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
|
||||
"nativeSleep" to (nativeSleepToken != null),
|
||||
))
|
||||
if (shouldSuspend) {
|
||||
controller.freeze { success ->
|
||||
@@ -1166,6 +1281,7 @@ class SessionService : Service() {
|
||||
releaseLocks()
|
||||
refreshNotification()
|
||||
} else {
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
suspendAttemptFailed = true
|
||||
Log.w(TAG, "could not confirm that the session stopped")
|
||||
@@ -1211,7 +1327,63 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
private fun resumeSteamSleepOnReturn() {
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_AUTO && activityVisible && screenOn) completeSteamSleep()
|
||||
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && activityVisible && screenOn) {
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
}
|
||||
}
|
||||
|
||||
/** Let Steam prepare before freezing; retain the wake lock until that freeze completes. */
|
||||
private fun prepareNativeSleep() {
|
||||
val token = UUID.randomUUID().toString().replace("-", "")
|
||||
nativeSleepToken = token
|
||||
nativeSleepReady = false
|
||||
nativeSleepFallback = false
|
||||
val dir = LinuxRuntime.sessionRoot(this)
|
||||
runCatching {
|
||||
val staged = File(dir, "steam-native-sleep.tmp")
|
||||
staged.writeText("$token\n")
|
||||
check(staged.renameTo(File(dir, "steam-native-sleep")))
|
||||
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
|
||||
SessionEvents.record("steam.native_sleep_requested")
|
||||
val gen = sessionGen
|
||||
val timeout = Runnable {
|
||||
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
|
||||
nativeSleepTimeout = null
|
||||
nativeSleepReady = true
|
||||
nativeSleepFallback = true
|
||||
writeSteamSleepState(token, "paused")
|
||||
SessionEvents.record("steam.native_sleep_timeout")
|
||||
updateSuspendPolicy()
|
||||
}
|
||||
nativeSleepTimeout = timeout
|
||||
// A missing or stalled guest helper must not keep the device awake indefinitely.
|
||||
mainHandler.postDelayed(timeout, 4000L)
|
||||
}
|
||||
|
||||
private fun completeNativeSleep() {
|
||||
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
nativeSleepTimeout = null
|
||||
val token = nativeSleepToken ?: return
|
||||
writeSteamSleepState(token, "awake")
|
||||
val dir = LinuxRuntime.sessionRoot(this)
|
||||
File(dir, "steam-native-sleep").delete()
|
||||
File(dir, "steam-native-ready").delete()
|
||||
nativeSleepToken = null
|
||||
nativeSleepReady = false
|
||||
nativeSleepFallback = false
|
||||
}
|
||||
|
||||
/** Resume must also recover a Steam sleep whose request never reached Android. */
|
||||
private fun requestSteamWake() {
|
||||
if (!SessionState.running || SessionState.mode != MODE_STEAM) return
|
||||
val dir = LinuxRuntime.sessionRoot(this)
|
||||
runCatching {
|
||||
val staged = File(dir, "steam-wake.tmp")
|
||||
staged.writeText("wake\n")
|
||||
check(staged.renameTo(File(dir, "steam-wake")))
|
||||
}.onFailure { Log.w(TAG, "could not request Steam wake", it) }
|
||||
}
|
||||
|
||||
private fun finishSessionStop(status: Int, stoppedGen: Int) {
|
||||
@@ -1266,10 +1438,12 @@ class SessionService : Service() {
|
||||
|
||||
private fun stopSession(status: Int) {
|
||||
cancelQueuedSteamGame()
|
||||
completeNativeSleep()
|
||||
synchronized(stopLock) {
|
||||
if (!SessionState.running) return
|
||||
SessionState.running = false
|
||||
}
|
||||
updateDownloadMonitoring()
|
||||
val stoppedGen = sessionGen
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
@@ -1438,11 +1612,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())
|
||||
@@ -1456,6 +1645,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>()
|
||||
@@ -1480,6 +1703,7 @@ class SessionService : Service() {
|
||||
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"
|
||||
@@ -1499,6 +1723,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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
/**
|
||||
* What to call a catalog component on screen. Bannerlator's catalog keys (vcredist2010_dll,
|
||||
* oalinst_dll, d3dcompiler_47...) have no display name, and some descriptions do not fit the entry,
|
||||
* so names come from here: families by pattern, the rest from a table, the key itself otherwise.
|
||||
* The "_dll" twin of an installer (the direct DLL copy) shares the installer's name.
|
||||
*/
|
||||
object WinComponentNames {
|
||||
private val TABLE = mapOf(
|
||||
"oalinst" to "OpenAL", "XLiveRedist" to "Games for Windows Live", "gfw" to "Games for Windows Live",
|
||||
"physx" to "NVIDIA PhysX", "xact" to "DirectX XACT audio", "xact_x64" to "DirectX XACT audio (64-bit)",
|
||||
"xinput" to "XInput", "xna31" to "XNA Framework 3.1", "xna40" to "XNA Framework 4.0",
|
||||
"d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
|
||||
"directmusic" to "DirectMusic", "directplay" to "DirectPlay", "directshow" to "DirectShow",
|
||||
"dsound" to "DirectSound", "dx8vb" to "DirectX 8 for Visual Basic", "mediafoundation" to "Media Foundation",
|
||||
"quartz" to "DirectShow (quartz)", "amstream" to "DirectShow streams (amstream)", "devenum" to "DirectShow devices (devenum)",
|
||||
"cnc-ddraw" to "cnc-ddraw (DirectDraw)", "gdiplus" to "GDI+", "gecko" to "Wine Gecko", "mono" to "Wine Mono",
|
||||
"vcredist6" to "Visual C++ 6", "vcredist6sp6" to "Visual C++ 6 SP6",
|
||||
"vcredist2015" to "Visual C++ 2015", "vcredist2019" to "Visual C++ 2015-2019", "vcredist2022" to "Visual C++ 2015-2022",
|
||||
"vbrun6" to "Visual Basic 6 runtime", "mfc40" to "MFC 4.0", "mfc42" to "MFC 4.2",
|
||||
"dotnetcore3" to ".NET Core 3", "dotnetcoredesktop3" to ".NET Desktop 3", "dotnetcoredesktop6" to ".NET Desktop 6",
|
||||
"dotnetcoredesktop7" to ".NET Desktop 7", "dotnetcoredesktop8" to ".NET Desktop 8", "dotnet50" to ".NET 5",
|
||||
"webview2" to "Microsoft WebView2", "powershell" to "PowerShell", "powershell_core" to "PowerShell Core",
|
||||
"K-Lite" to "K-Lite Codec Pack", "VulkanRT" to "Vulkan Runtime", "ffdshow" to "ffdshow codecs",
|
||||
"lavfilters702" to "LAV Filters 0.70.2", "lavfilters741" to "LAV Filters 0.74.1", "quicktime72" to "QuickTime 7.2",
|
||||
"cjkfonts" to "CJK fonts", "jet40" to "Jet 4.0 database", "mdac28" to "MDAC 2.8", "wsh57" to "Windows Script Host 5.7",
|
||||
"riched20" to "RichEdit 2.0", "msftedit" to "RichEdit 4.1 (msftedit)", "sqlite3" to "SQLite 3",
|
||||
"win7" to "Windows 7 mode", "winXP" to "Windows XP mode", "gmdls" to "General MIDI sounds (gm.dls)",
|
||||
)
|
||||
private val VCREDIST = Regex("vcredist(\\d{4})")
|
||||
private val DOTNET = Regex("dotnet(\\d)(\\d)(\\d?)(sp\\d)?")
|
||||
private val D3DCOMPILER = Regex("d3dcompiler_(\\d+)")
|
||||
private val MSXML = Regex("msxml(\\d)")
|
||||
private val MONO = Regex("mono-([\\d.]+)")
|
||||
private val XAUDIO = Regex("xaudio([\\d.]+)")
|
||||
|
||||
fun of(id: String): String {
|
||||
val base = id.removeSuffix("_dll")
|
||||
TABLE[base]?.let { return it }
|
||||
VCREDIST.matchEntire(base)?.let { return "Visual C++ " + it.groupValues[1] }
|
||||
DOTNET.matchEntire(base)?.let { m ->
|
||||
val (major, minor, patch, sp) = m.destructured
|
||||
return ".NET Framework $major.$minor" + (if (patch.isNotEmpty()) ".$patch" else "") +
|
||||
(if (sp.isNotEmpty()) " " + sp.uppercase() else "")
|
||||
}
|
||||
D3DCOMPILER.matchEntire(base)?.let { return "D3DCompiler " + it.groupValues[1] }
|
||||
MSXML.matchEntire(base)?.let { return "MSXML " + it.groupValues[1] }
|
||||
MONO.matchEntire(base)?.let { return "Wine Mono " + it.groupValues[1] }
|
||||
XAUDIO.matchEntire(base)?.let { return "XAudio " + it.groupValues[1] }
|
||||
if (base.startsWith("dm") || base == "dswave" || base == "dsdmo") return "DirectMusic ($base)"
|
||||
return id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.ArchivePaths
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.GuestCommand
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Windows components (OpenAL, d3dx9, XAudio, VC++ DLLs...) for a game's Proton prefix, picked per
|
||||
* game, from Bannerlator's catalog (components.json on winlator-contents, Bottles' manifest format).
|
||||
*
|
||||
* Installing one runs its steps once, into the runtime: the archives it names are downloaded and
|
||||
* its DLLs copied to /opt/droiddeck/wincomponents/<name>/{system32,syswow64}, with component.json
|
||||
* holding its DLL overrides and the components it bundles. The picks go to the runtime as
|
||||
* ~/.config/droiddeck/wincomponents.json, and droiddeck-wincomponents copies the picked components
|
||||
* into the game's prefix at every launch (see that script for why every launch).
|
||||
*
|
||||
* A component whose installer is a Windows Installer package (.msi: XNA, PhysX, Games for Windows
|
||||
* Live, MSXML...) installs here too, without running it: droiddeck-msi-install reads the package's
|
||||
* tables in the runtime and lays out its files (Program Files, the .NET GAC, winsxs) and registry
|
||||
* values the way Wine's msiexec would, under the same store folder. One that runs any other
|
||||
* installer (.exe), unpacks a cabinet or edits the prefix's registry by itself is listed with the
|
||||
* reason it cannot be installed yet.
|
||||
*/
|
||||
object WinComponents {
|
||||
private const val TAG = "WinComponents"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/components.json"
|
||||
private const val SELECTION = "wincomponents.json"
|
||||
private const val GUEST_SELECTION = "root/.config/droiddeck/wincomponents.json"
|
||||
private const val STORE = "opt/droiddeck/wincomponents"
|
||||
private val ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
||||
// register_dll is left out on purpose: the DLLs it names are ones Wine ships and has registered
|
||||
// at the same path, so the native file at that path is the one the registration loads.
|
||||
private val FILE_STEPS = setOf("download_archive", "archive_extract", "copy_dll", "copy_file", "override_dll", "register_dll")
|
||||
private val INSTALLER_STEPS = setOf("install_exe", "install_msi")
|
||||
private const val MSI_INSTALL = "/usr/local/bin/droiddeck-msi-install"
|
||||
/** Where a downloaded .msi waits for the installer, inside the runtime so it can read it. */
|
||||
private const val MSI_CACHE = "$STORE/.packages"
|
||||
/** Catalog entries Proton already provides newer copies of, which an install would only shadow. */
|
||||
private val PROTON_PROVIDES = setOf("gecko")
|
||||
|
||||
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
|
||||
|
||||
class Step(val action: String, val json: JSONObject) {
|
||||
fun str(key: String): String = json.optString(key, "")
|
||||
}
|
||||
|
||||
class Component(
|
||||
val name: String, val description: String, val provider: String, val status: String,
|
||||
val dependencies: List<String>, val steps: List<Step>,
|
||||
)
|
||||
|
||||
enum class Support { READY, NEEDS_INSTALLER, UNSUPPORTED }
|
||||
|
||||
/** A step that installs a Windows Installer package, which droiddeck-msi-install does here. */
|
||||
private fun isMsi(step: Step): Boolean = step.action in INSTALLER_STEPS && step.str("url").startsWith("https://") &&
|
||||
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
|
||||
.any { it.endsWith(".msi", ignoreCase = true) })
|
||||
|
||||
fun fetch(): List<Component>? {
|
||||
val body = Downloader.downloadString(CATALOG_URL) ?: return null
|
||||
return try {
|
||||
val arr = JSONObject(body).getJSONArray("components")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val name = o.optString("name")
|
||||
if (!validId(name)) return@mapNotNull null
|
||||
val steps = o.optJSONArray("steps") ?: JSONArray()
|
||||
val deps = o.optJSONArray("dependencies") ?: JSONArray()
|
||||
Component(
|
||||
name, o.optString("description"), o.optString("provider"), o.optString("status"),
|
||||
(0 until deps.length()).map { deps.getString(it) }.filter { validId(it) },
|
||||
(0 until steps.length()).map { steps.getJSONObject(it).let { s -> Step(s.optString("action"), s) } },
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "catalog: $e"); null
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether [c] can be installed here, its bundled components included. */
|
||||
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
|
||||
if (c.status != "ready" || depth > 8 || c.name in PROTON_PROVIDES) return Support.UNSUPPORTED
|
||||
val installers = c.steps.filter { it.action in INSTALLER_STEPS }
|
||||
val own = when {
|
||||
installers.isNotEmpty() && installers.all { isMsi(it) } &&
|
||||
c.steps.all { it in installers || it.action == "delete_dlls" || it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
|
||||
installers.isNotEmpty() -> Support.NEEDS_INSTALLER
|
||||
c.steps.all { it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
|
||||
else -> Support.UNSUPPORTED
|
||||
}
|
||||
if (own != Support.READY) return own
|
||||
return c.dependencies.map { all[it]?.let { d -> support(d, all, depth + 1) } ?: Support.UNSUPPORTED }
|
||||
.maxOrNull() ?: Support.READY
|
||||
}
|
||||
|
||||
private fun fileStepOk(step: Step): Boolean = when (step.action) {
|
||||
"download_archive", "archive_extract" -> step.str("url").let { url ->
|
||||
!url.startsWith("http") || url.startsWith("https://github.com/") &&
|
||||
url.substringBefore('?').let { it.endsWith(".tar.xz") || it.endsWith(".zip") }
|
||||
}
|
||||
"copy_dll", "copy_file" -> ".." !in step.str("dest")
|
||||
else -> true
|
||||
}
|
||||
|
||||
/** The installed version of a component, or null. */
|
||||
fun installed(context: Context, id: String): String? = runCatching {
|
||||
JSONObject(File(LinuxRuntime.rootDir(context), "$STORE/$id/component.json").readText()).optString("version", "?")
|
||||
}.getOrNull()
|
||||
|
||||
/** Every component in the runtime, by id. */
|
||||
fun installedIds(context: Context): List<String> =
|
||||
File(LinuxRuntime.rootDir(context), STORE).listFiles { f -> f.isDirectory && validId(f.name) }
|
||||
.orEmpty().map { it.name }.filter { installed(context, it) != null }.sorted()
|
||||
|
||||
/**
|
||||
* Installs [c] and the components it bundles that are not installed yet. [onProgress] gets a
|
||||
* line for the step and 0..100 (or -1). Returns null on success, else a message.
|
||||
*/
|
||||
fun install(context: Context, c: Component, all: Map<String, Component>, onProgress: (String, Int) -> Unit): String? {
|
||||
if (support(c, all) != Support.READY) return "${c.name} cannot be installed here yet"
|
||||
val order = ArrayList<Component>()
|
||||
fun visit(x: Component, depth: Int) {
|
||||
if (depth > 8 || order.any { it.name == x.name }) return
|
||||
x.dependencies.mapNotNull { all[it] }.forEach { visit(it, depth + 1) }
|
||||
if (x.name == c.name || installed(context, x.name) == null) order.add(x)
|
||||
}
|
||||
visit(c, 0)
|
||||
// One download per archive however many components copy out of it (DirectMusic's eleven
|
||||
// parts all come from dxnt, 100 MB).
|
||||
val work = File(context.cacheDir, "wincomponents").apply { FileUtils.delete(this); mkdirs() }
|
||||
val archives = HashMap<String, File>()
|
||||
return try {
|
||||
for (x in order) {
|
||||
onProgress(x.name, -1)
|
||||
installOne(context, x, work, archives, onProgress)?.let { return "${x.name}: $it" }
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${c.name}", e)
|
||||
e.message ?: "Install failed"
|
||||
} finally {
|
||||
FileUtils.delete(work)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installOne(context: Context, c: Component, work: File, archives: HashMap<String, File>, onProgress: (String, Int) -> Unit): String? {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (!root.isDirectory) return "The Linux runtime is not installed"
|
||||
val staging = File(root, "$STORE/.${c.name}.new").apply { FileUtils.delete(this); mkdirs() }
|
||||
val overrides = ArrayList<String>()
|
||||
var source: File? = null
|
||||
var msi: JSONObject? = null
|
||||
for (step in c.steps) when (step.action) {
|
||||
// A component may have several packages (PowerShell's 32- and 64-bit): each adds to the
|
||||
// same folder, the first one names the component.
|
||||
"install_msi", "install_exe" -> {
|
||||
val (result, problem) = installMsi(context, c, step, staging, onProgress)
|
||||
if (result == null) return problem
|
||||
msi = msi?.apply {
|
||||
val notes = optJSONArray("notes") ?: JSONArray().also { put("notes", it) }
|
||||
result.optJSONArray("notes")?.let { for (i in 0 until it.length()) notes.put(it.get(i)) }
|
||||
} ?: result
|
||||
}
|
||||
// A catalog step that makes room for the installer's own copy; ours is copied anyway.
|
||||
"delete_dlls" -> Unit
|
||||
"download_archive", "archive_extract" -> {
|
||||
val url = step.str("url")
|
||||
if (!url.startsWith("http")) continue
|
||||
source = archives[url] ?: run {
|
||||
val name = url.substringBefore('?').substringAfterLast('/')
|
||||
val file = File(work, "${archives.size}-$name")
|
||||
val ok = Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }
|
||||
if (!ok) return "download failed: $name"
|
||||
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
|
||||
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return "checksum mismatch: $name"
|
||||
}
|
||||
onProgress("${c.name}: unpacking $name", -1)
|
||||
val dir = File(work, "${archives.size}-x").apply { mkdirs() }
|
||||
if (!extract(file, dir)) return "could not unpack $name"
|
||||
file.delete()
|
||||
dir.also { archives[url] = it }
|
||||
}
|
||||
}
|
||||
"copy_dll", "copy_file" -> {
|
||||
val from = source ?: return "a copy step before any archive"
|
||||
val dest = step.str("dest")
|
||||
val (arch, base) = when (dest.substringBefore('/').lowercase()) {
|
||||
"win64", "system32" -> "win64" to "system32"
|
||||
"win32", "syswow64" -> "win32" to "syswow64"
|
||||
else -> null to "system32"
|
||||
}
|
||||
val sub = dest.substringAfter('/', "")
|
||||
copyMatching(from, step.str("file_name"), File(staging, if (sub.isEmpty()) base else "$base/$sub"), arch)
|
||||
}
|
||||
"override_dll" -> {
|
||||
fun add(dll: String, type: String) {
|
||||
if (dll.isEmpty()) return
|
||||
overrides += when (type.ifEmpty { "native,builtin" }) {
|
||||
"native,builtin" -> dll
|
||||
"native" -> "$dll=n"
|
||||
"builtin" -> "$dll=b"
|
||||
"builtin,native" -> "$dll=b,n"
|
||||
else -> dll
|
||||
}
|
||||
}
|
||||
add(step.str("dll"), step.str("type"))
|
||||
step.json.optJSONArray("bundle")?.let { b ->
|
||||
for (i in 0 until b.length()) b.getJSONObject(i).let { add(it.optString("value"), it.optString("data")) }
|
||||
}
|
||||
}
|
||||
"register_dll" -> Unit
|
||||
else -> return "unsupported step ${step.action}"
|
||||
}
|
||||
val version = msi?.optString("version")?.takeIf { it.isNotEmpty() }
|
||||
?: Regex("""\b(\d+\.\d+(\.\d+)*)\b""").find(c.description)?.value ?: "catalog"
|
||||
val meta = JSONObject()
|
||||
.put("id", c.name).put("version", version)
|
||||
.put("overrides", JSONArray(overrides.distinct()))
|
||||
.put("requires", JSONArray(c.dependencies))
|
||||
msi?.let { meta.put("kind", "msi").put("product", it.optString("product")).put("notes", it.optJSONArray("notes") ?: JSONArray()) }
|
||||
FileUtils.writeString(File(staging, "component.json"), meta.toString(1))
|
||||
val target = File(root, "$STORE/${c.name}")
|
||||
FileUtils.delete(target)
|
||||
if (!staging.renameTo(target)) return "could not place the files"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the component's .msi into the runtime and has droiddeck-msi-install lay it out in
|
||||
* [staging]. Returns the installer's result (product, version, counts, notes), or null and why.
|
||||
* Everything the installer prints also goes to Download/DroidDeck/tools.
|
||||
*/
|
||||
private fun installMsi(context: Context, c: Component, step: Step, staging: File, onProgress: (String, Int) -> Unit): Pair<JSONObject?, String> {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
val url = step.str("url")
|
||||
val name = url.substringBefore('?').substringAfterLast('/')
|
||||
val cache = File(root, MSI_CACHE).apply { mkdirs() }
|
||||
val file = File(cache, "${c.name}.msi")
|
||||
try {
|
||||
if (!Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }) {
|
||||
return null to "download failed: $name"
|
||||
}
|
||||
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
|
||||
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return null to "checksum mismatch: $name"
|
||||
}
|
||||
onProgress("${c.name}: installing", -1)
|
||||
var result: JSONObject? = null
|
||||
var problem: String? = null
|
||||
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
|
||||
logName = "wincomponents-${c.name}") { line ->
|
||||
when {
|
||||
line.startsWith("progress ") -> onProgress("${c.name}: ${line.removePrefix("progress ")}", -1)
|
||||
line.startsWith("result ") -> result = runCatching { JSONObject(line.removePrefix("result ")) }.getOrNull()
|
||||
line.startsWith("error ") -> problem = line.removePrefix("error ")
|
||||
}
|
||||
}
|
||||
val done = result
|
||||
if (status != 0 || done == null) return null to (problem ?: "the installer stopped (status $status)")
|
||||
return done to ""
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "msi ${c.name}", e)
|
||||
return null to (e.message ?: "the installer failed")
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun digest(file: File, algorithm: String): String {
|
||||
val md = MessageDigest.getInstance(algorithm)
|
||||
FileInputStream(file).use { input ->
|
||||
val buffer = ByteArray(1 shl 16)
|
||||
while (true) { val n = input.read(buffer); if (n < 0) break; md.update(buffer, 0, n) }
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun extract(archive: File, dest: File): Boolean = try {
|
||||
if (archive.name.endsWith(".zip", ignoreCase = true)) ZipInputStream(BufferedInputStream(FileInputStream(archive))).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val out = ArchivePaths.inside(dest, entry.name)
|
||||
if (out != null && !entry.isDirectory) {
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { FileUtils.copy(zip, it) }
|
||||
}
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
} else TarArchiveInputStream(XZCompressorInputStream(BufferedInputStream(FileInputStream(archive), 1 shl 16))).use { tar ->
|
||||
var entry = tar.nextEntry
|
||||
while (entry != null) {
|
||||
val out = ArchivePaths.inside(dest, entry.name)
|
||||
if (out != null && entry.isFile) {
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { FileUtils.copy(tar, it) }
|
||||
}
|
||||
entry = tar.nextEntry
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "extract ${archive.name}", e); false
|
||||
}
|
||||
|
||||
/**
|
||||
* Bannerlator's rule: the manifest's own temp paths are Bottles', so the files are found by name
|
||||
* anywhere in the unpacked tree, preferring the ones under the matching win32/win64 folder.
|
||||
*/
|
||||
private fun copyMatching(srcRoot: File, pattern: String, dest: File, arch: String?) {
|
||||
if (pattern.isEmpty()) return
|
||||
val rx = Regex("^" + pattern.split("*").joinToString(".*") { Regex.escape(it) } + "$", RegexOption.IGNORE_CASE)
|
||||
var matches = srcRoot.walkTopDown().filter { it.isFile && rx.matches(it.name) }.toList()
|
||||
if (arch != null) {
|
||||
val inArch = matches.filter { it.path.contains("/$arch/", true) }
|
||||
val other = if (arch == "win64") "win32" else "win64"
|
||||
matches = inArch.ifEmpty { matches.filterNot { it.path.contains("/$other/", true) } }
|
||||
}
|
||||
dest.mkdirs()
|
||||
matches.forEach { f -> f.copyTo(File(dest, f.name.lowercase()), overwrite = true) }
|
||||
}
|
||||
|
||||
/** Removes a component from the runtime and from every game's picks. */
|
||||
@Synchronized
|
||||
fun uninstall(context: Context, id: String) {
|
||||
require(validId(id))
|
||||
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
|
||||
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
/** Each game's picks, by the appid its prefix is named after. */
|
||||
@Synchronized
|
||||
fun read(context: Context): Map<String, List<String>> = try {
|
||||
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
|
||||
val games = json.optJSONObject("games") ?: JSONObject()
|
||||
games.keys().asSequence().associateWith { app ->
|
||||
val ids = games.getJSONArray(app)
|
||||
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
|
||||
}
|
||||
} catch (_: java.io.FileNotFoundException) {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
|
||||
|
||||
@Synchronized
|
||||
fun setPicks(context: Context, appKey: String, ids: List<String>) {
|
||||
require(ids.all { validId(it) })
|
||||
val all = read(context).toMutableMap()
|
||||
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
private fun save(context: Context, games: Map<String, List<String>>) {
|
||||
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
|
||||
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
|
||||
}).toString()
|
||||
write(File(context.filesDir, SELECTION), text)
|
||||
publish(context, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the picks where the launch script reads them; SessionFiles calls this at every session
|
||||
* start too, so a runtime reinstalled since keeps them. An empty selection still writes a file,
|
||||
* which is how a component turned off for every game is taken out of their prefixes.
|
||||
*/
|
||||
@Synchronized
|
||||
fun publish(context: Context, text: String? = null) {
|
||||
val body = text ?: runCatching { AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8) }.getOrNull() ?: return
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (root.isDirectory) write(File(root, GUEST_SELECTION), body)
|
||||
}
|
||||
|
||||
private fun write(file: File, text: String) {
|
||||
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
|
||||
val atomic = AtomicFile(file)
|
||||
val output = atomic.startWrite()
|
||||
try {
|
||||
output.write(text.toByteArray(Charsets.UTF_8))
|
||||
atomic.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
atomic.failWrite(output)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -90,7 +90,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
}
|
||||
}
|
||||
Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) }
|
||||
Rise(2) { LaunchSettings(s, a, host) }
|
||||
Rise(2) { LaunchSettings(s, a, host, current) }
|
||||
Rise(3) { GameFileFolderActions(s, a) }
|
||||
}
|
||||
return
|
||||
@@ -109,7 +109,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
GameActions(current, s, a)
|
||||
}
|
||||
SectionTitle(stringResource(R.string.games_launch_settings), null)
|
||||
LaunchSettings(s, a, host)
|
||||
LaunchSettings(s, a, host, current)
|
||||
GameFileFolderActions(s, a)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,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)
|
||||
|
||||
@@ -101,6 +101,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,
|
||||
@@ -169,6 +171,7 @@ class FrontEndActions(
|
||||
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 +182,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,17 +307,19 @@ internal fun SetupPanel(
|
||||
}
|
||||
}
|
||||
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
|
||||
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs)
|
||||
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 ->
|
||||
@@ -322,10 +331,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
|
||||
}
|
||||
@@ -421,10 +430,20 @@ private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
|
||||
* app-wide settings - each card opens the same page or menu Setup does.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
|
||||
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null) {
|
||||
// Three across, two on a narrow page; each row's cards share one height.
|
||||
val columns = if (LocalNarrowPane.current) 2 else 3
|
||||
val controller = a.controller
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||
// The game's prefix is compatdata/<id>, as droiddeck-game-env reads it: a Steam title's appid, an
|
||||
// added game's shortcut appid (unsigned). Recommended scans the game's own folder either way.
|
||||
val wincompKey = game?.let {
|
||||
if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId)
|
||||
else it.appId.takeIf { id -> id > 0 }?.toString()
|
||||
}
|
||||
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
|
||||
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
|
||||
val cards = buildList<@Composable (Modifier) -> Unit> {
|
||||
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
|
||||
add { m ->
|
||||
@@ -436,7 +455,19 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
}
|
||||
}
|
||||
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
|
||||
if (wincompKey != null) add { m ->
|
||||
SettingCard(
|
||||
stringResource(R.string.wincomp_title),
|
||||
if (wincompPicks.isEmpty()) stringResource(R.string.wincomp_card_none) else wincompPicks.joinToString(", ") { com.droiddeck.launcher.session.WinComponentNames.of(it) },
|
||||
"card:wincomp", m,
|
||||
) { wincompOpen = inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard }
|
||||
}
|
||||
}
|
||||
val opened = wincompOpen
|
||||
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
|
||||
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
|
||||
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
|
||||
) { wincompOpen = null }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
for (row in cards.chunked(columns)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
@@ -470,3 +501,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user