diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2d479537..cf1cf715e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -295,6 +295,9 @@ jobs: set -o pipefail ./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log + - name: Unit tests + run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION" + - name: Check the native dependency closure run: | set -euo pipefail diff --git a/app/build.gradle b/app/build.gradle index 568a8ab49..666501a65 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -140,6 +140,11 @@ android { buildConfig true } + testOptions { + // Plain JVM tests: a stray android.util.Log call returns a default instead of throwing. + unitTests.returnDefaultValues = true + } + compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 @@ -152,7 +157,7 @@ android { 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.20' + implementation 'org.apache.commons:commons-compress:1.27.1' // 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') @@ -169,4 +174,6 @@ dependencies { implementation 'com.flyfishxu:kadb-android:1.2.1' // Android terminal view/emulator used by the in-session secondary-display Linux shell. implementation 'com.termux.termux-app:terminal-view:0.118.0' + + testImplementation 'junit:junit:4.13.2' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index be31238eb..fba4727ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,7 +48,8 @@ + android:screenOrientation="sensorLandscape" + android:configChanges="keyboard|keyboardHidden|navigation"> @@ -125,6 +126,10 @@ android:name=".session.SessionService" android:exported="false" android:stopWithTask="false" /> + + :-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>) target_include_directories(bannerwayland PRIVATE generated prebuilt/include diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 6fb0d32c0..c206dfb2c 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -51,6 +51,7 @@ #include "effects_chain.h" #include "banner_ext.h" #include "banner_color.h" +#include "compositor_internal.h" #define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__) #define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__) @@ -161,21 +162,9 @@ static int log_budget(void) { static struct wl_display *g_display; -/* Which program each Wayland client is (from /proc//cmdline), for the session log. */ -struct client_info { - struct wl_client *client; - struct wl_listener destroy; - pid_t pid; - char name[64]; - /* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's - * Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */ - unsigned asked_feedback : 1, shm_gl_said : 1; - unsigned dmabuf_buffers, shm_frames; - struct client_info *next; -}; static struct client_info *g_clients; -static struct client_info *client_info_of(struct wl_client *client) { +struct client_info *client_info_of(struct wl_client *client) { for (struct client_info *ci = g_clients; ci; ci = ci->next) if (ci->client == client) return ci; return NULL; @@ -500,7 +489,7 @@ static void cursor_publish_hidden(void) { } static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t stride, int hx, int hy) { - if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return; + if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return; pthread_mutex_lock(&g_cursor_lock); for (int y = 0; y < h; y++) memcpy(&g_cursor_px[y * w], src + (size_t)y * stride, (size_t)w * 4); @@ -513,10 +502,13 @@ static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t strid static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) { int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm); - if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return; + int32_t stride = wl_shm_buffer_get_stride(shm); + if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return; + /* libwayland only promises stride >= width; each row copied here is width * 4 bytes, so a + * shorter stride would read past the end of the client's pool. */ + if ((int64_t)stride < (int64_t)w * 4) return; wl_shm_buffer_begin_access(shm); - cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h, - (size_t)wl_shm_buffer_get_stride(shm), hx, hy); + cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h, (size_t)stride, hx, hy); wl_shm_buffer_end_access(shm); } @@ -557,40 +549,7 @@ static void constraints_focus_entered(struct wl_resource *target, struct wl_clie /* ------------------------------------------------------------------ dmabuf buffers */ -#define FOURCC(a, b, c, d) \ - ((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24)) -#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4') -#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4') -#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4') -#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4') -#define MOD_LINEAR VKP_MOD_LINEAR -#define MOD_INVALID VKP_MOD_INVALID -#define MAX_PLANES 4 - -struct dmabuf_params { - int fd[MAX_PLANES]; - uint32_t offset[MAX_PLANES], stride[MAX_PLANES]; - uint64_t modifier[MAX_PLANES]; - int n_planes; -}; -struct dmabuf_buffer { - int fd[MAX_PLANES]; - uint32_t offset[MAX_PLANES], stride[MAX_PLANES]; - int n_planes; - int32_t width, height; - uint32_t format; - uint64_t modifier; - struct vkp_image *img; /* imported once, reused for every frame */ - int import_failed; - /* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys - * a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last - * committed one is still what is on screen: the import (and the dma-buf memory it pins) stays - * until the surface commits something newer, so the picture never blinks to black. */ - int refs; - void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */ -}; - -static void dmabuf_buffer_unref(struct dmabuf_buffer *b) { +void dmabuf_buffer_unref(struct dmabuf_buffer *b) { if (!b || --b->refs > 0) return; vkp_image_destroy(b->img); for (int i = 0; i < b->n_planes; i++) @@ -601,7 +560,7 @@ static void dmabuf_buffer_unref(struct dmabuf_buffer *b) { static void dbuf_buffer_destroy_req(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } -static const struct wl_buffer_interface dbuf_buffer_impl = { +const struct wl_buffer_interface dbuf_buffer_impl = { .destroy = dbuf_buffer_destroy_req, }; @@ -1018,7 +977,8 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) { cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */ } else if (shm) { cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy); - } else if (db && db->n_planes >= 1 && db->width * db->height <= CURSOR_MAX_PX) { + } else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 && + (int64_t)db->width * db->height <= CURSOR_MAX_PX) { if (!db->img && !db->import_failed) { db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height, db->stride[0], db->offset[0]); @@ -1570,319 +1530,6 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t wl_resource_set_implementation(r, &desktop_impl, NULL, NULL); } -/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */ - -static void dbuf_buffer_resource_destroy(struct wl_resource *r) { - dmabuf_buffer_unref(wl_resource_get_user_data(r)); -} - -static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } -static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane, - uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) { - struct dmabuf_params *p = wl_resource_get_user_data(r); - if (plane >= MAX_PLANES) { close(fd); return; } - if (p->fd[plane] >= 0) close(p->fd[plane]); - p->fd[plane] = fd; - p->offset[plane] = offset; - p->stride[plane] = stride; - p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo; - if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1; -} -static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id, - int32_t w, int32_t h, uint32_t format, uint32_t flags) { - struct dmabuf_params *p = wl_resource_get_user_data(r); - struct dmabuf_buffer *b = calloc(1, sizeof(*b)); - if (!b) return NULL; - b->n_planes = p->n_planes; - b->refs = 1; /* the wl_buffer resource's */ - b->width = w; b->height = h; b->format = format; - b->modifier = p->modifier[0]; - for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1; - for (int i = 0; i < p->n_planes; i++) { - b->fd[i] = p->fd[i]; - b->offset[i] = p->offset[i]; - b->stride[i] = p->stride[i]; - p->fd[i] = -1; /* ownership moves to the buffer */ - } - struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id); - if (!buf) { - for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]); - free(b); - wl_client_post_no_memory(c); - return NULL; - } - wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy); - struct client_info *ci = client_info_of(c); - if (ci) ci->dmabuf_buffers++; - return buf; -} -static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h, - uint32_t format, uint32_t flags) { - struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags); - if (buf) zwp_linux_buffer_params_v1_send_created(r, buf); - else zwp_linux_buffer_params_v1_send_failed(r); -} -static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id, - int32_t w, int32_t h, uint32_t format, uint32_t flags) { - params_do_create(c, r, buffer_id, w, h, format, flags); -} -static const struct zwp_linux_buffer_params_v1_interface params_impl = { - .destroy = params_destroy, - .add = params_add, - .create = params_create, - .create_immed = params_create_immed, -}; -static void params_resource_destroy(struct wl_resource *r) { - struct dmabuf_params *p = wl_resource_get_user_data(r); - if (!p) return; - for (int i = 0; i < MAX_PLANES; i++) - if (p->fd[i] >= 0) close(p->fd[i]); - free(p); -} - -static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } -static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) { - struct dmabuf_params *p = calloc(1, sizeof(*p)); - if (!p) { wl_client_post_no_memory(c); return; } - for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1; - struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface, - wl_resource_get_version(r), id); - if (!pr) { free(p); wl_client_post_no_memory(c); return; } - wl_resource_set_implementation(pr, ¶ms_impl, p, params_resource_destroy); -} -static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id); -static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id, - struct wl_resource *surface); -static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = { - .destroy = dmabuf_destroy, - .create_params = dmabuf_create_params, - .get_default_feedback = dmabuf_get_default_feedback, - .get_surface_feedback = dmabuf_get_surface_feedback, -}; -/* The advertised format/modifier table, built at the first bind from what the renderer's driver - * can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients - * may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */ -/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa - * lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI - * can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY - * while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that - * takes the first format it sees - gets exactly the table it always had. */ -#define DMABUF_NFMT_MAX 6 -#define DMABUF_NFMT_SDR 4 -#define DMABUF_NMOD 4 -#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0') -#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0') -static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = { - {DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}}; -static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */ -static int g_dmabuf_fmts_ready; -static void dmabuf_build_feedback(void); - -static void dmabuf_build_formats(void) { - char line[320]; - int pos = 0, compressed = 0; - g_dmabuf_fmts_ready = 1; - g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR; - for (int f = 0; f < g_dmabuf_nfmt; f++) { - uint64_t got[DMABUF_NMOD]; - int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0; - /* LINEAR first: it is the layout every client and the shm fallback agree on, and the one - * we advertised before this table existed. */ - g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR; - for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) { - if (got[i] == MOD_LINEAR) continue; - if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue; - g_dmabuf_fmts[f].mods[k++] = got[i]; - if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++; - } - g_dmabuf_fmts[f].mods[k++] = MOD_INVALID; - g_dmabuf_fmts[f].n = k; - uint32_t fmt = g_dmabuf_fmts[f].fmt; - pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "", - fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff); - for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++) - pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ", - vkp_modifier_name(g_dmabuf_fmts[f].mods[i])); - if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1; - } - banner_log("dmabuf", "formats: %s%s", line, - !g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : ""); - if (g_ubwc && !compressed) - banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: " - "game swapchains stay linear", vkp_gpu_name()); - if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) { - /* The rows above always carry LINEAR; say what the compositor's own driver can really import - * for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */ - uint64_t got[DMABUF_NMOD]; - int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0; - for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1; - banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) " - "imports XB30 %s", vkp_gpu_name(), - n == 0 ? "with no layout it reports (copy path unlikely; display layer only)" - : ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)"); - } - dmabuf_build_feedback(); -} - -/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4) - * - * Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game - * worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU - * (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops - * to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall - * back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is - * never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame - * at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work. - * - * What a client needs from us is one tranche describing "everything the compositor can import": - * a format table it mmaps read-only, the device to allocate on, and the indices it may use. - * Clients binding versions 1-3 keep getting the old format/modifier events instead. */ - -#ifndef MFD_CLOEXEC -#define MFD_CLOEXEC 0x0001U -#endif -#ifndef MFD_ALLOW_SEALING -#define MFD_ALLOW_SEALING 0x0002U -#endif -#ifndef F_ADD_SEALS -#define F_ADD_SEALS 1033 -#define F_SEAL_SEAL 0x0001 -#define F_SEAL_SHRINK 0x0002 -#define F_SEAL_GROW 0x0004 -#define F_SEAL_WRITE 0x0008 -#endif - -struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */ - -static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */ -static size_t g_fmt_table_size; -static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */ -static dev_t g_main_device; /* the render node clients should allocate on */ - -/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own - * to name. Clients only use main_device to match "the same device as the compositor" and to pick - * a driver; the one DRM render node this platform has is the right answer, and Zink ignores it - * anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */ -static dev_t dmabuf_render_node(void) { - static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"}; - struct stat st; - if (g_no_render_node) return 0; - for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++) - if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev; - return 0; -} - -static void dmabuf_build_feedback(void) { - struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD]; - int n = 0; - - g_main_device = dmabuf_render_node(); - for (int f = 0; f < g_dmabuf_nfmt; f++) - for (int m = 0; m < g_dmabuf_fmts[f].n; m++) { - if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */ - entries[n].format = g_dmabuf_fmts[f].fmt; - entries[n].pad = 0; - entries[n].modifier = g_dmabuf_fmts[f].mods[m]; - n++; - } - if (!n) return; - - int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats", - MFD_CLOEXEC | MFD_ALLOW_SEALING); - if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; } - size_t size = (size_t)n * sizeof(entries[0]); - if (write(fd, entries, size) != (ssize_t)size) { - WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno)); - close(fd); - return; - } - /* The client mmaps this read-only and trusts it not to change under it. */ - fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE); - g_fmt_table_fd = fd; - g_fmt_table_size = size; - g_fmt_table_n = (uint16_t)n; - banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u", - n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device)); - /* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without - * one it fell back to a software path that draws nothing here (black window, sound plays). */ - if (!g_main_device) - banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, " - "which runs OpenGL on the GPU without a DRM node; older layers show a black window", - g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)" - : "this device gives apps no display (DRM) device (/dev/dri)"); -} - -/* One tranche: our device, every pair in the table, no scanout flag. */ -static void dmabuf_feedback_send(struct wl_resource *fb) { - struct wl_array dev, idx; - uint16_t *ind; - - if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; } - - zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size); - - wl_array_init(&dev); - memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t)); - zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev); - zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev); - wl_array_release(&dev); - - wl_array_init(&idx); - ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t)); - if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i; - zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx); - wl_array_release(&idx); - - zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0); - zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb); - zwp_linux_dmabuf_feedback_v1_send_done(fb); -} - -static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) { - wl_resource_destroy(r); -} -static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = { - .destroy = dmabuf_feedback_destroy, -}; - -static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) { - struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface, - wl_resource_get_version(parent), id); - if (!fb) { wl_client_post_no_memory(c); return; } - wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL); - if (!g_dmabuf_fmts_ready) dmabuf_build_formats(); - dmabuf_feedback_send(fb); -} - -static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) { - struct client_info *ci = client_info_of(c); - if (ci) ci->asked_feedback = 1; - dmabuf_new_feedback(c, r, id); -} -/* Per-surface feedback would let us hint a different tranche for a window on its own display - * layer; we have nothing better to say per surface, so it is the default one. */ -static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id, - struct wl_resource *surface) { - dmabuf_new_feedback(c, r, id); -} - -static void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) { - struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id); - wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL); - if (!g_dmabuf_fmts_ready) dmabuf_build_formats(); - /* From version 4 the format and modifier events are gone: the client asks for feedback - * instead, and sending both would only confuse it about which list is authoritative. */ - if (ver >= 4) return; - for (int f = 0; f < g_dmabuf_nfmt; f++) { - zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt); - if (ver >= 3) - for (int m = 0; m < g_dmabuf_fmts[f].n; m++) - zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt, - (uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32), - (uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff)); - } -} - /* ------------------------------------------------------------------ wl_output */ /* wl_output v3+ has a request (release); without an implementation the first client to send it diff --git a/app/src/main/cpp/waylandcomp/src/compositor_internal.h b/app/src/main/cpp/waylandcomp/src/compositor_internal.h new file mode 100644 index 000000000..d6e07c5f5 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/compositor_internal.h @@ -0,0 +1,76 @@ +/* + * What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf + * buffer records, the per-client bookkeeping and the few functions and globals both sides use. + * Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI. + */ +#pragma once +#include +#include +#include +#include +#include "vk_present.h" + +#define COMPOSITOR_INTERNAL __attribute__((visibility("hidden"))) + +#ifndef WLOGE +#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__) +#endif + +/* ------------------------------------------------------------------ dmabuf buffers */ + +#define FOURCC(a, b, c, d) \ + ((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24)) +#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4') +#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4') +#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4') +#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4') +#define MOD_LINEAR VKP_MOD_LINEAR +#define MOD_INVALID VKP_MOD_INVALID +#define MAX_PLANES 4 + +struct dmabuf_params { + int fd[MAX_PLANES]; + uint32_t offset[MAX_PLANES], stride[MAX_PLANES]; + uint64_t modifier[MAX_PLANES]; + int n_planes; +}; +struct dmabuf_buffer { + int fd[MAX_PLANES]; + uint32_t offset[MAX_PLANES], stride[MAX_PLANES]; + int n_planes; + int32_t width, height; + uint32_t format; + uint64_t modifier; + struct vkp_image *img; /* imported once, reused for every frame */ + int import_failed; + /* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys + * a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last + * committed one is still what is on screen: the import (and the dma-buf memory it pins) stays + * until the surface commits something newer, so the picture never blinks to black. */ + int refs; + void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */ +}; + +/* Which program each Wayland client is (from /proc//cmdline), for the session log. */ +struct client_info { + struct wl_client *client; + struct wl_listener destroy; + pid_t pid; + char name[64]; + /* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's + * Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */ + unsigned asked_feedback : 1, shm_gl_said : 1; + unsigned dmabuf_buffers, shm_frames; + struct client_info *next; +}; + +/* ---- compositor.c */ +COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client); +COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b); +COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl; +extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */ +extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */ + +/* ---- wl_dmabuf.c */ +COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */ +COMPOSITOR_INTERNAL void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id); diff --git a/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c b/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c new file mode 100644 index 000000000..bdda9d75e --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c @@ -0,0 +1,334 @@ +/* + * zwp_linux_dmabuf_v1: the buffer params a client builds a dma-buf wl_buffer from, the format and + * modifier table the renderer can import, and dma-buf feedback (version 4). Split out of + * compositor.c; the buffer records themselves are in compositor_internal.h. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "linux-dmabuf-v1-server-protocol.h" +#include "vk_present.h" +#include "banner_ext.h" +#include "banner_color.h" +#include "compositor_internal.h" + +/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */ + +static void dbuf_buffer_resource_destroy(struct wl_resource *r) { + dmabuf_buffer_unref(wl_resource_get_user_data(r)); +} + +static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } +static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane, + uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) { + struct dmabuf_params *p = wl_resource_get_user_data(r); + if (plane >= MAX_PLANES) { close(fd); return; } + if (p->fd[plane] >= 0) close(p->fd[plane]); + p->fd[plane] = fd; + p->offset[plane] = offset; + p->stride[plane] = stride; + p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo; + if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1; +} +static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id, + int32_t w, int32_t h, uint32_t format, uint32_t flags) { + struct dmabuf_params *p = wl_resource_get_user_data(r); + struct dmabuf_buffer *b = calloc(1, sizeof(*b)); + if (!b) return NULL; + b->n_planes = p->n_planes; + b->refs = 1; /* the wl_buffer resource's */ + b->width = w; b->height = h; b->format = format; + b->modifier = p->modifier[0]; + for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1; + for (int i = 0; i < p->n_planes; i++) { + b->fd[i] = p->fd[i]; + b->offset[i] = p->offset[i]; + b->stride[i] = p->stride[i]; + p->fd[i] = -1; /* ownership moves to the buffer */ + } + struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id); + if (!buf) { + for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]); + free(b); + wl_client_post_no_memory(c); + return NULL; + } + wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy); + struct client_info *ci = client_info_of(c); + if (ci) ci->dmabuf_buffers++; + return buf; +} +static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h, + uint32_t format, uint32_t flags) { + struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags); + if (buf) zwp_linux_buffer_params_v1_send_created(r, buf); + else zwp_linux_buffer_params_v1_send_failed(r); +} +static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id, + int32_t w, int32_t h, uint32_t format, uint32_t flags) { + params_do_create(c, r, buffer_id, w, h, format, flags); +} +static const struct zwp_linux_buffer_params_v1_interface params_impl = { + .destroy = params_destroy, + .add = params_add, + .create = params_create, + .create_immed = params_create_immed, +}; +static void params_resource_destroy(struct wl_resource *r) { + struct dmabuf_params *p = wl_resource_get_user_data(r); + if (!p) return; + for (int i = 0; i < MAX_PLANES; i++) + if (p->fd[i] >= 0) close(p->fd[i]); + free(p); +} + +static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } +static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) { + struct dmabuf_params *p = calloc(1, sizeof(*p)); + if (!p) { wl_client_post_no_memory(c); return; } + for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1; + struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface, + wl_resource_get_version(r), id); + if (!pr) { free(p); wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(pr, ¶ms_impl, p, params_resource_destroy); +} +static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id); +static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id, + struct wl_resource *surface); +static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = { + .destroy = dmabuf_destroy, + .create_params = dmabuf_create_params, + .get_default_feedback = dmabuf_get_default_feedback, + .get_surface_feedback = dmabuf_get_surface_feedback, +}; +/* The advertised format/modifier table, built at the first bind from what the renderer's driver + * can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients + * may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */ +/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa + * lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI + * can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY + * while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that + * takes the first format it sees - gets exactly the table it always had. */ +#define DMABUF_NFMT_MAX 6 +#define DMABUF_NFMT_SDR 4 +#define DMABUF_NMOD 4 +#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0') +#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0') +static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = { + {DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}}; +static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */ +static int g_dmabuf_fmts_ready; +static void dmabuf_build_feedback(void); + +static void dmabuf_build_formats(void) { + char line[320]; + int pos = 0, compressed = 0; + g_dmabuf_fmts_ready = 1; + g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR; + for (int f = 0; f < g_dmabuf_nfmt; f++) { + uint64_t got[DMABUF_NMOD]; + int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0; + /* LINEAR first: it is the layout every client and the shm fallback agree on, and the one + * we advertised before this table existed. */ + g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR; + for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) { + if (got[i] == MOD_LINEAR) continue; + if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue; + g_dmabuf_fmts[f].mods[k++] = got[i]; + if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++; + } + g_dmabuf_fmts[f].mods[k++] = MOD_INVALID; + g_dmabuf_fmts[f].n = k; + uint32_t fmt = g_dmabuf_fmts[f].fmt; + pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "", + fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff); + for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++) + pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ", + vkp_modifier_name(g_dmabuf_fmts[f].mods[i])); + if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1; + } + banner_log("dmabuf", "formats: %s%s", line, + !g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : ""); + if (g_ubwc && !compressed) + banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: " + "game swapchains stay linear", vkp_gpu_name()); + if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) { + /* The rows above always carry LINEAR; say what the compositor's own driver can really import + * for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */ + uint64_t got[DMABUF_NMOD]; + int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0; + for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1; + banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) " + "imports XB30 %s", vkp_gpu_name(), + n == 0 ? "with no layout it reports (copy path unlikely; display layer only)" + : ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)"); + } + dmabuf_build_feedback(); +} + +/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4) + * + * Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game + * worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU + * (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops + * to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall + * back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is + * never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame + * at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work. + * + * What a client needs from us is one tranche describing "everything the compositor can import": + * a format table it mmaps read-only, the device to allocate on, and the indices it may use. + * Clients binding versions 1-3 keep getting the old format/modifier events instead. */ + +#ifndef MFD_CLOEXEC +#define MFD_CLOEXEC 0x0001U +#endif +#ifndef MFD_ALLOW_SEALING +#define MFD_ALLOW_SEALING 0x0002U +#endif +#ifndef F_ADD_SEALS +#define F_ADD_SEALS 1033 +#define F_SEAL_SEAL 0x0001 +#define F_SEAL_SHRINK 0x0002 +#define F_SEAL_GROW 0x0004 +#define F_SEAL_WRITE 0x0008 +#endif + +struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */ + +static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */ +static size_t g_fmt_table_size; +static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */ +dev_t g_main_device; /* the render node clients should allocate on */ + +/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own + * to name. Clients only use main_device to match "the same device as the compositor" and to pick + * a driver; the one DRM render node this platform has is the right answer, and Zink ignores it + * anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */ +static dev_t dmabuf_render_node(void) { + static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"}; + struct stat st; + if (g_no_render_node) return 0; + for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++) + if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev; + return 0; +} + +static void dmabuf_build_feedback(void) { + struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD]; + int n = 0; + + g_main_device = dmabuf_render_node(); + for (int f = 0; f < g_dmabuf_nfmt; f++) + for (int m = 0; m < g_dmabuf_fmts[f].n; m++) { + if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */ + entries[n].format = g_dmabuf_fmts[f].fmt; + entries[n].pad = 0; + entries[n].modifier = g_dmabuf_fmts[f].mods[m]; + n++; + } + if (!n) return; + + int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats", + MFD_CLOEXEC | MFD_ALLOW_SEALING); + if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; } + size_t size = (size_t)n * sizeof(entries[0]); + if (write(fd, entries, size) != (ssize_t)size) { + WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno)); + close(fd); + return; + } + /* The client mmaps this read-only and trusts it not to change under it. */ + fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE); + g_fmt_table_fd = fd; + g_fmt_table_size = size; + g_fmt_table_n = (uint16_t)n; + banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u", + n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device)); + /* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without + * one it fell back to a software path that draws nothing here (black window, sound plays). */ + if (!g_main_device) + banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, " + "which runs OpenGL on the GPU without a DRM node; older layers show a black window", + g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)" + : "this device gives apps no display (DRM) device (/dev/dri)"); +} + +/* One tranche: our device, every pair in the table, no scanout flag. */ +static void dmabuf_feedback_send(struct wl_resource *fb) { + struct wl_array dev, idx; + uint16_t *ind; + + if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; } + + zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size); + + wl_array_init(&dev); + memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t)); + zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev); + zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev); + wl_array_release(&dev); + + wl_array_init(&idx); + ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t)); + if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i; + zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx); + wl_array_release(&idx); + + zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0); + zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb); + zwp_linux_dmabuf_feedback_v1_send_done(fb); +} + +static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = { + .destroy = dmabuf_feedback_destroy, +}; + +static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) { + struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface, + wl_resource_get_version(parent), id); + if (!fb) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL); + if (!g_dmabuf_fmts_ready) dmabuf_build_formats(); + dmabuf_feedback_send(fb); +} + +static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) { + struct client_info *ci = client_info_of(c); + if (ci) ci->asked_feedback = 1; + dmabuf_new_feedback(c, r, id); +} +/* Per-surface feedback would let us hint a different tranche for a window on its own display + * layer; we have nothing better to say per surface, so it is the default one. */ +static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id, + struct wl_resource *surface) { + dmabuf_new_feedback(c, r, id); +} + +void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) { + struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id); + wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL); + if (!g_dmabuf_fmts_ready) dmabuf_build_formats(); + /* From version 4 the format and modifier events are gone: the client asks for feedback + * instead, and sending both would only confuse it about which list is authoritative. */ + if (ver >= 4) return; + for (int f = 0; f < g_dmabuf_nfmt; f++) { + zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt); + if (ver >= 3) + for (int m = 0; m < g_dmabuf_fmts[f].n; m++) + zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt, + (uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32), + (uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff)); + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt new file mode 100644 index 000000000..3cbb18656 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt @@ -0,0 +1,84 @@ +package com.droiddeck.launcher + +import android.os.Handler +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.session.ComponentsManager + +/** + * The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies + * catalog, downloads and the swap/restore actions. The launcher screen keeps one and shows it. + */ +internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) { + var compSnapshot by mutableStateOf(null) + var compCatalog by mutableStateOf>(emptyList()) + var compCatalogAt by mutableStateOf(0L) + var compProton by mutableStateOf(null) + var compComp by mutableStateOf("dxvk") + var compChecking by mutableStateOf(false) + var compBusy by mutableStateOf(null) + var compDownloads by mutableStateOf>(emptyMap()) + + /** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */ + fun refreshComponents(snapshotFirst: Boolean = false) { + Thread({ + if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(activity) } + runCatching { ComponentsManager.applyQueued(activity) } + val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull() + val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull() + ui.post { + compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList()) + if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt } + if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id + } + }, "components").start() + } + + fun componentAction(label: String, work: () -> String) { + if (compBusy != null) return + compBusy = label + Thread({ + val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" } + ui.post { + compBusy = null + android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show() + refreshComponents() + } + }, "components-action").start() + } + + fun refreshComponentCatalog() { + if (compChecking) return + compChecking = true + Thread({ + 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() + else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt } + } + }, "components-catalog").start() + } + + fun downloadComponent(item: ComponentsManager.CatalogItem) { + if (compDownloads.containsKey(item.file)) return + compDownloads = compDownloads + (item.file to -1) + 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}" } + ui.post { + compDownloads = compDownloads - item.file + android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show() + refreshComponents() + } + }, "components-download").start() + } + + private companion object { + const val TAG = "MainActivity" + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt new file mode 100644 index 000000000..f38f87922 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt @@ -0,0 +1,54 @@ +package com.droiddeck.launcher + +import android.os.Handler +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.runtime.DeckyManager +import com.droiddeck.launcher.session.SessionState + +/** + * Decky Loader's state in the launcher: what is installed, the releases on offer and an install + * in progress. The launcher screen keeps one and shows it. + */ +internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) { + var deckyInstalled by mutableStateOf(null) + var deckyReleases by mutableStateOf>(emptyList()) + var deckyChecking by mutableStateOf(false) + var deckyStage by mutableStateOf(null) + var deckyPercent by mutableIntStateOf(-1) + var deckySupervisor by mutableStateOf(false) + var deckyReleaseRequest = 0 + + fun refreshDecky() { + val request = ++deckyReleaseRequest + deckyChecking = true + Thread({ + val channels = runCatching { DeckyManager.releaseChannels(activity) } + .getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) } + ui.post { + if (request == deckyReleaseRequest) { + // Use the newest compatible stable build, or the newest compatible + // prerelease when the fork has not published a stable ARM64 asset. + deckyReleases = channels.stable.ifEmpty { channels.prerelease } + deckyChecking = false + } + } + }, "decky-releases").start() + } + + fun installDecky(release: DeckyManager.Release) { + if (deckyStage != null || SessionState.running) return + deckyStage = "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}" } + 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() + } + }, "install-decky").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt new file mode 100644 index 000000000..88c2e8853 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -0,0 +1,217 @@ +package com.droiddeck.launcher + +import android.app.Activity +import android.content.Context +import java.io.File +import android.net.Uri +import android.provider.OpenableColumns +import android.os.Handler +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.gpu.LinuxVulkanDriver +import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager +import com.droiddeck.launcher.gpu.TurnipDriver +import com.droiddeck.launcher.gpu.TurnipReleases +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.ui.DriverRow + +/** + * The Linux and Android driver menus: the drivers each can pick from, the release downloads the + * last check found, and what each mode is set to. The launcher screen keeps one and hands its + * rows to the mode settings dialog. + */ +internal class DriverMenus(private val activity: Activity, private val ui: Handler) { + var linuxRows by mutableStateOf>(emptyList()) + /** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */ + var linuxDownloads by mutableStateOf>(emptyList()) + var androidDownloads by mutableStateOf>(emptyList()) + var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers") + var releaseChecking by mutableStateOf(false) + var canRestoreBundled by mutableStateOf(false) + /** Asset name -> download percent, while it downloads. */ + private val releaseProgress = HashMap() + var linuxSteam by mutableStateOf("") + var linuxDesktop by mutableStateOf("") + var androidRows by mutableStateOf>(emptyList()) + var androidSelected by mutableStateOf("") + + fun refreshDrivers() { + val lm = LinuxVulkanDriverManager(activity) + fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED + linuxRows = LinuxVulkanDriver.optionValues(activity).map { id -> + if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false) + else DriverRow( + id, lm.getDriverName(id), + listOfNotNull( + lm.getDriverVersion(id).takeIf { it.isNotEmpty() }, + lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" }, + ).joinToString(" · "), + true, origin(id), + ) + } + linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM) + linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP) + val td = TurnipDriver(activity) + 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)", + false, + )) + for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED)) + for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id))) + } + canRestoreBundled = td.hiddenBundled().isNotEmpty() + androidSelected = SessionPrefs.androidDriver(activity) + refreshReleaseRows() + } + + /** + * Import off the main thread - a driver zip is a few MB and the glibc check reads the whole + * library - then say what happened. A refusal's message is the user-facing reason. + */ + fun importDriver(uri: Uri, linux: Boolean) { + val name = activity.displayNameOf(uri) + Thread({ + val problem = try { + if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name) + else TurnipDriver(activity).installFromZip(uri, name) + null + } catch (e: IllegalArgumentException) { + e.message + } catch (e: Exception) { + Log.w(TAG, "driver import", e) + "Import failed: ${e.message}" + } + ui.post { + android.widget.Toast.makeText( + activity, problem ?: "Imported ${name ?: "driver"}", + if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT, + ).show() + refreshDrivers() + } + }, "import-driver").start() + } + + /** + * Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a + * session never starts on a driver that is gone; a release download is forgotten, so the menu + * offers it again. + */ + fun deleteDriver(id: String, linux: Boolean) { + if (linux) { + LinuxVulkanDriverManager(activity).removeDriver(id) + for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) { + if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "") + } + } else { + val td = TurnipDriver(activity) + if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id) + 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() + refreshDrivers() + } + + /** The download entries and the refresh line, from what the last check found. */ + fun refreshReleaseRows() { + val check = TurnipReleases.cached(activity) + val lm = LinuxVulkanDriverManager(activity) + val td = TurnipDriver(activity) + fun rows(linux: Boolean) = check?.assets.orEmpty() + .filter { it.linux == linux } + .filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null } + .map { a -> + val mb = "%.1f MB".format(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)}" + } + } + + 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" + } + } + + /** Only when the user taps refresh: nothing goes online on its own. */ + fun checkLatestTurnip() { + if (releaseChecking) return + releaseChecking = true + releaseStatus = "Checking Banners-Turnip and WinNative…" + Thread({ + val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) { + Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed" + } + ui.post { + releaseChecking = false + refreshReleaseRows() + if (problem != null) releaseStatus = "Couldn't check: $problem" + } + }, "turnip-release-check").start() + } + + /** Download one release driver and import it through the same importer a picked zip uses. */ + fun downloadReleaseDriver(assetName: String) { + val asset = TurnipReleases.cached(activity)?.assets?.firstOrNull { it.name == assetName } ?: return + if (releaseProgress.containsKey(assetName)) return + releaseProgress[assetName] = 0 + refreshReleaseRows() + Thread({ + var file: java.io.File? = null + val problem = try { + file = TurnipReleases.download(activity, asset) { pct -> + ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } + } + val uri = Uri.fromFile(file) + val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name) + else TurnipDriver(activity).installFromZip(uri, asset.name) + TurnipReleases.recordDownload(activity, asset, id) + null + } catch (e: IllegalArgumentException) { + e.message + } catch (e: Exception) { + Log.w(TAG, "release driver download", e) + "Download failed: ${e.message}" + } finally { + file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) } + } + ui.post { + releaseProgress.remove(assetName) + android.widget.Toast.makeText( + activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu", + android.widget.Toast.LENGTH_LONG, + ).show() + refreshDrivers() + } + }, "download-turnip").start() + } + + private companion object { + const val TAG = "MainActivity" + } +} + +internal fun Context.displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try { + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { c -> if (c.moveToFirst()) c.getString(0) else null } +} catch (e: Exception) { + null +} + diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 4c8b16e9f..0df4cf372 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -9,7 +9,6 @@ import java.io.File import android.content.pm.PackageManager import android.hardware.display.DisplayManager import android.net.Uri -import android.provider.OpenableColumns import android.os.Bundle import android.os.Handler import android.os.Looper @@ -26,10 +25,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.droiddeck.launcher.gpu.FrameGen -import com.droiddeck.launcher.gpu.LinuxVulkanDriver -import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager import com.droiddeck.launcher.gpu.TurnipDriver -import com.droiddeck.launcher.gpu.TurnipReleases import com.droiddeck.launcher.gpu.LsfgNative import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.runtime.DesktopCatalog @@ -44,7 +40,6 @@ import com.droiddeck.launcher.ui.ComponentsPage import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.ui.ProtonPage -import com.droiddeck.launcher.ui.ProtonRow import com.droiddeck.launcher.core.CpuCores import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus @@ -54,7 +49,6 @@ import com.droiddeck.launcher.ui.PerformancePage import com.droiddeck.launcher.ui.ModeSettingsPage import com.droiddeck.launcher.ui.ModeSettings import com.droiddeck.launcher.ui.ModeSettingsActions -import com.droiddeck.launcher.ui.DriverRow import com.droiddeck.launcher.ui.ConfirmDialog import com.droiddeck.launcher.ui.ControllerActions import com.droiddeck.launcher.ui.ControllerMappingPage @@ -82,6 +76,10 @@ import com.droiddeck.launcher.input.SecondScreenDisplays */ class MainActivity : ComponentActivity() { private val ui = Handler(Looper.getMainLooper()) + private val drivers = DriverMenus(this, ui) + private val components = ComponentsMenu(this, ui) + private val decky = DeckyMenu(this, ui) + private val protons = ProtonMenu(this, ui) // The screen's state. Compose redraws whatever reads these when they change. private var installed by mutableStateOf(null) @@ -102,21 +100,6 @@ class MainActivity : ComponentActivity() { // Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager). private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) - private var compSnapshot by mutableStateOf(null) - private var compCatalog by mutableStateOf>(emptyList()) - private var compCatalogAt by mutableStateOf(0L) - private var compProton by mutableStateOf(null) - private var compComp by mutableStateOf("dxvk") - private var compChecking by mutableStateOf(false) - private var compBusy by mutableStateOf(null) - private var compDownloads by mutableStateOf>(emptyMap()) - private var deckyInstalled by mutableStateOf(null) - private var deckyReleases by mutableStateOf>(emptyList()) - private var deckyChecking by mutableStateOf(false) - private var deckyStage by mutableStateOf(null) - private var deckyPercent by mutableIntStateOf(-1) - private var deckySupervisor by mutableStateOf(false) - private var deckyReleaseRequest = 0 private var showMapping by mutableStateOf(false) private var controllerSettings by mutableStateOf(null) private var catalog by mutableStateOf?>(null) @@ -128,10 +111,6 @@ class MainActivity : ComponentActivity() { private var desktopInstalled by mutableStateOf(false) private var offlineAccount by mutableStateOf(null) private var offline by mutableStateOf(false) - private var protonRows by mutableStateOf>(emptyList()) - private var protonBusyId by mutableStateOf(null) - private var protonStage by mutableStateOf(null) - private var protonPercent by mutableIntStateOf(-1) private var showPerformance by mutableStateOf(false) private var clientOverride by mutableStateOf(false) private var clientCores by mutableStateOf>(emptySet()) @@ -148,19 +127,6 @@ class MainActivity : ComponentActivity() { private var forceFullscreen by mutableStateOf(true) private var launcherFullscreen by mutableStateOf(true) private var mic by mutableStateOf(false) - private var linuxRows by mutableStateOf>(emptyList()) - /** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */ - private var linuxDownloads by mutableStateOf>(emptyList()) - private var androidDownloads by mutableStateOf>(emptyList()) - private var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers") - private var releaseChecking by mutableStateOf(false) - private var canRestoreBundled by mutableStateOf(false) - /** Asset name -> download percent, while it downloads. */ - private val releaseProgress = HashMap() - private var linuxSteam by mutableStateOf("") - private var linuxDesktop by mutableStateOf("") - private var androidRows by mutableStateOf>(emptyList()) - private var androidSelected by mutableStateOf("") // The app's own picker (files/), once per kind of pick: the two driver lists validate // differently, and the reason a zip is refused names the list it belongs in. @@ -168,10 +134,10 @@ class MainActivity : ComponentActivity() { if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importComponent(it) } } private val pickLinuxDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> - if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = true) } + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = true) } } private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> - if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = false) } + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) } } private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> @@ -249,7 +215,7 @@ class MainActivity : ComponentActivity() { override fun startActivity(intent: Intent?) { if (intent?.component?.className == SessionActivity::class.java.name) { when { - protonBusyId != null || ProtonExtras.installInProgress -> { + protons.protonBusyId != null || ProtonExtras.installInProgress -> { android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show() return } @@ -543,7 +509,7 @@ class MainActivity : ComponentActivity() { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { val all = ComponentsManager.COMPONENTS val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1 - compComp = all[(all.indexOf(compComp).coerceAtLeast(0) + step) % all.size] + components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size] } return true } @@ -559,7 +525,7 @@ class MainActivity : ComponentActivity() { 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() - if (showComponents) refreshComponents() + if (showComponents) components.refreshComponents() } }, "components-queue").start() oscMode = SessionPrefs.oscMode(this) @@ -567,9 +533,9 @@ class MainActivity : ComponentActivity() { refreshHomeAppState() refreshSecondScreenDisplays() refresh() - deckyInstalled = DeckyManager.installed(this) + decky.deckyInstalled = DeckyManager.installed(this) DeckyManager.syncCefMarker(this) - deckySupervisor = DeckyManager.supervisorEnabled(this) + decky.deckySupervisor = DeckyManager.supervisorEnabled(this) // Added games' art (a store lookup for what the folders lack) starts here, not only when // the cog opens. refreshAddedGames() @@ -638,38 +604,7 @@ class MainActivity : ComponentActivity() { showComponents = false showMapping = false showProtons = true - refreshProtons() - } - - private fun refreshDecky() { - val request = ++deckyReleaseRequest - deckyChecking = true - Thread({ - val channels = runCatching { DeckyManager.releaseChannels(this) } - .getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) } - ui.post { - if (request == deckyReleaseRequest) { - // Use the newest compatible stable build, or the newest compatible - // prerelease when the fork has not published a stable ARM64 asset. - deckyReleases = channels.stable.ifEmpty { channels.prerelease } - deckyChecking = false - } - } - }, "decky-releases").start() - } - - private fun installDecky(release: DeckyManager.Release) { - if (deckyStage != null || SessionState.running) return - deckyStage = "Starting…"; deckyPercent = -1 - Thread({ - val problem = runCatching { - DeckyManager.install(this, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } } - }.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" } - ui.post { - deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(this) - if (problem != null) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show() - } - }, "install-decky").start() + protons.refreshProtons() } private fun refreshController() { @@ -694,69 +629,12 @@ class MainActivity : ComponentActivity() { showProtons = false showMapping = false showComponents = true - refreshComponents(snapshotFirst = true) - } - - /** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */ - private fun refreshComponents(snapshotFirst: Boolean = false) { - Thread({ - if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(this) } - runCatching { ComponentsManager.applyQueued(this) } - val snap = runCatching { ComponentsManager.snapshot(this) }.onFailure { Log.w(TAG, "components", it) }.getOrNull() - val cat = runCatching { ComponentsManager.catalog(this, false) }.getOrNull() - ui.post { - compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList()) - if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt } - if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id - } - }, "components").start() - } - - private fun componentAction(label: String, work: () -> String) { - if (compBusy != null) return - compBusy = label - Thread({ - val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" } - ui.post { - compBusy = null - android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() - refreshComponents() - } - }, "components-action").start() - } - - private fun refreshComponentCatalog() { - if (compChecking) return - compChecking = true - Thread({ - val cat = runCatching { ComponentsManager.catalog(this, true) }.getOrNull() - ui.post { - compChecking = false - if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(this, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show() - else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt } - } - }, "components-catalog").start() - } - - private fun downloadComponent(item: ComponentsManager.CatalogItem) { - if (compDownloads.containsKey(item.file)) return - compDownloads = compDownloads + (item.file to -1) - Thread({ - val message = runCatching { - val pkg = ComponentsManager.download(this, 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}" } - ui.post { - compDownloads = compDownloads - item.file - android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show() - refreshComponents() - } - }, "components-download").start() + components.refreshComponents(snapshotFirst = true) } private fun importComponent(uri: Uri) { val name = displayNameOf(uri) ?: "imported.wcp" - componentAction("Importing") { + components.componentAction("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() } @@ -766,24 +644,24 @@ class MainActivity : ComponentActivity() { @Composable private fun ComponentsHost() { ComponentsPage( - snapshot = compSnapshot, - catalog = compCatalog, - catalogAt = compCatalogAt, - protonId = compProton, - comp = compComp, - checking = compChecking, - busy = compBusy, - downloads = compDownloads, + snapshot = components.compSnapshot, + catalog = components.compCatalog, + catalogAt = components.compCatalogAt, + protonId = components.compProton, + comp = components.compComp, + checking = components.compChecking, + busy = components.compBusy, + downloads = components.compDownloads, requestInitialFocus = focusComponentsContent, - onProton = { compProton = it }, - onComp = { compComp = it }, - onSwap = { file -> compProton?.let { pid -> componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } }, - onRestore = { version -> compProton?.let { pid -> componentAction("Restoring") { ComponentsManager.restore(this, pid, compComp, version) } } }, - onCancelQueued = { compProton?.let { pid -> componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, compComp); "The waiting swap was cancelled." } } }, - onDeletePackage = { file -> componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } }, - onDeleteOriginal = { version -> compProton?.let { pid -> componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, compComp, version) } } }, - onDownload = { downloadComponent(it) }, - onRefresh = { refreshComponentCatalog() }, + onProton = { components.compProton = 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) } } }, + onDownload = { components.downloadComponent(it) }, + onRefresh = { components.refreshComponentCatalog() }, onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) }, onBack = { showComponents = false }, ) @@ -792,56 +670,19 @@ class MainActivity : ComponentActivity() { @Composable private fun ProtonHost() { ProtonPage( - rows = protonRows, - busyId = protonBusyId, - stage = protonStage, - percent = protonPercent, + rows = protons.protonRows, + busyId = protons.protonBusyId, + stage = protons.protonStage, + percent = protons.protonPercent, runtimeReady = ready, sessionRunning = SessionState.running, - onInstall = { id -> installProton(id) }, - onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; refreshProtons() }, - onRemove = { id -> removeProton(id) }, + onInstall = { id -> protons.installProton(id) }, + onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() }, + onRemove = { id -> protons.removeProton(id) }, onBack = { showProtons = false }, ) } - private fun installProton(id: String) { - val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return - if (protonBusyId != null || SessionState.running) return - ProtonExtras.unqueue(this, tool) - protonBusyId = id - protonStage = "Starting…" - protonPercent = -1 - Thread({ - val problem = ProtonExtras.install(this, tool) { label, value -> - ui.post { protonStage = label; protonPercent = value } - } - ui.post { - protonBusyId = null - protonStage = null - protonPercent = -1 - refreshProtons() - if (problem != null) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show() - } - }, "install-proton-$id").start() - } - - private fun removeProton(id: String) { - val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return - if (protonBusyId != null || SessionState.running) return - protonBusyId = id - protonStage = "Removing ${tool.name}…" - protonPercent = -1 - Thread({ - ProtonExtras.remove(this, tool) - ui.post { - protonBusyId = null - protonStage = null - refreshProtons() - } - }, "remove-proton-$id").start() - } - private fun refreshPackages() { packageRows = catalog?.map { PackageRow(it.id, it.kind, it.notes) } desktopInstalled = DesktopCatalog.desktopInstalled(this) @@ -890,9 +731,9 @@ class MainActivity : ComponentActivity() { ModeSettings( mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode, hdr = hdrOn, hdrReason = hdrReason, - linuxRows = linuxRows, - linuxSelected = if (mode == SessionService.MODE_STEAM) linuxSteam else linuxDesktop, - androidRows = androidRows, androidSelected = androidSelected, + linuxRows = drivers.linuxRows, + linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop, + androidRows = drivers.androidRows, androidSelected = drivers.androidSelected, touchMode = touchMode, suspendPolicy = suspendPolicy, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, @@ -910,27 +751,27 @@ class MainActivity : ComponentActivity() { addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null, addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), addedGamesArt = addedGamesArt, - linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, releaseStatus = releaseStatus, - releaseChecking = releaseChecking, canRestoreBundled = canRestoreBundled, - deckyInstalled = if (mode == SessionService.MODE_STEAM) deckyInstalled else null, - deckyLatestRelease = if (mode == SessionService.MODE_STEAM) deckyReleases.firstOrNull() else null, - deckyChecking = deckyChecking, deckyStage = deckyStage, deckyPercent = deckyPercent, - deckyEnabled = deckySupervisor, deckySessionRunning = SessionState.running, + linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus, + releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled, + 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, + deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running, ), ModeSettingsActions( onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap }, onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size }, onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape }, onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on }, - onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); refreshDrivers() }, + onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() }, onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) }, - onRemoveLinux = { id -> deleteDriver(id, linux = true) }, - onRefreshReleases = { checkLatestTurnip() }, - onDownloadDriver = { name -> downloadReleaseDriver(name) }, - onRestoreBundled = { TurnipDriver(this).restoreBundled(); refreshDrivers() }, - onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); refreshDrivers() }, + onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) }, + onRefreshReleases = { drivers.checkLatestTurnip() }, + onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) }, + onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() }, + onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() }, onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) }, - onRemoveAndroid = { id -> deleteDriver(id, linux = false) }, + onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) }, onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t }, onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, @@ -969,13 +810,13 @@ class MainActivity : ComponentActivity() { pendingAddedGame = folder pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder)) }, - onDeckyInstall = { release -> installDecky(release) }, - onDeckyCheck = { refreshDecky() }, - onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); deckySupervisor = enabled }, + onDeckyInstall = { release -> decky.installDecky(release) }, + onDeckyCheck = { decky.refreshDecky() }, + onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled }, onDeckyUninstall = { DeckyManager.uninstall(this, wipeData = false) - deckyInstalled = null - deckySupervisor = false + decky.deckyInstalled = null + decky.deckySupervisor = false }, onDismiss = { settingsMode = null }, ), @@ -1034,7 +875,7 @@ class MainActivity : ComponentActivity() { showProtons = false showComponents = false showMapping = false - refreshDrivers() + drivers.refreshDrivers() resolutionCap = SessionPrefs.resolutionCap(this, mode) customResolution = SessionPrefs.customResolution(this, mode) fexPreset = SessionPrefs.fexPreset(this) @@ -1057,9 +898,9 @@ class MainActivity : ComponentActivity() { gameStorage = SessionPrefs.gameStorage(this) storageOptions = GameStorage.options(this).map { it.label to it.path } if (mode == SessionService.MODE_STEAM) { - deckyInstalled = DeckyManager.installed(this) - deckySupervisor = DeckyManager.supervisorEnabled(this) - refreshDecky() + decky.deckyInstalled = DeckyManager.installed(this) + decky.deckySupervisor = DeckyManager.supervisorEnabled(this) + decky.refreshDecky() } settingsMode = mode } @@ -1077,178 +918,6 @@ class MainActivity : ComponentActivity() { gameStorage = path } - private fun refreshDrivers() { - val lm = LinuxVulkanDriverManager(this) - fun origin(id: String) = if (TurnipReleases.isDownloaded(this, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED - linuxRows = LinuxVulkanDriver.optionValues(this).map { id -> - if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false) - else DriverRow( - id, lm.getDriverName(id), - listOfNotNull( - lm.getDriverVersion(id).takeIf { it.isNotEmpty() }, - lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" }, - ).joinToString(" · "), - true, origin(id), - ) - } - linuxSteam = SessionPrefs.linuxDriver(this, SessionService.MODE_STEAM) - linuxDesktop = SessionPrefs.linuxDriver(this, SessionService.MODE_DESKTOP) - val td = TurnipDriver(this) - 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)", - false, - )) - for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED)) - for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id))) - } - canRestoreBundled = td.hiddenBundled().isNotEmpty() - androidSelected = SessionPrefs.androidDriver(this) - refreshReleaseRows() - } - - /** - * Import off the main thread - a driver zip is a few MB and the glibc check reads the whole - * library - then say what happened. A refusal's message is the user-facing reason. - */ - private fun importDriver(uri: Uri, linux: Boolean) { - val name = displayNameOf(uri) - Thread({ - val problem = try { - if (linux) LinuxVulkanDriverManager(this).installDriver(uri, name) - else TurnipDriver(this).installFromZip(uri, name) - null - } catch (e: IllegalArgumentException) { - e.message - } catch (e: Exception) { - Log.w(TAG, "driver import", e) - "Import failed: ${e.message}" - } - ui.post { - android.widget.Toast.makeText( - this, problem ?: "Imported ${name ?: "driver"}", - if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT, - ).show() - refreshDrivers() - } - }, "import-driver").start() - } - - /** - * Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a - * session never starts on a driver that is gone; a release download is forgotten, so the menu - * offers it again. - */ - private fun deleteDriver(id: String, linux: Boolean) { - if (linux) { - LinuxVulkanDriverManager(this).removeDriver(id) - for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) { - if (SessionPrefs.linuxDriver(this, mode) == id) SessionPrefs.setLinuxDriver(this, mode, "") - } - } else { - val td = TurnipDriver(this) - if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id) - if (SessionPrefs.androidDriver(this) == id) SessionPrefs.setAndroidDriver(this, TurnipDriver.AUTO) - } - TurnipReleases.forget(this, id) - android.widget.Toast.makeText(this, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show() - refreshDrivers() - } - - /** The download entries and the refresh line, from what the last check found. */ - private fun refreshReleaseRows() { - val check = TurnipReleases.cached(this) - val lm = LinuxVulkanDriverManager(this) - val td = TurnipDriver(this) - fun rows(linux: Boolean) = check?.assets.orEmpty() - .filter { it.linux == linux } - .filter { a -> TurnipReleases.installedId(this, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null } - .map { a -> - val mb = "%.1f MB".format(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)}" - } - } - - 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" - } - } - - /** Only when the user taps refresh: nothing goes online on its own. */ - private fun checkLatestTurnip() { - if (releaseChecking) return - releaseChecking = true - releaseStatus = "Checking Banners-Turnip and WinNative…" - Thread({ - val problem = try { TurnipReleases.refresh(this); null } catch (e: Exception) { - Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed" - } - ui.post { - releaseChecking = false - refreshReleaseRows() - if (problem != null) releaseStatus = "Couldn't check: $problem" - } - }, "turnip-release-check").start() - } - - /** Download one release driver and import it through the same importer a picked zip uses. */ - private fun downloadReleaseDriver(assetName: String) { - val asset = TurnipReleases.cached(this)?.assets?.firstOrNull { it.name == assetName } ?: return - if (releaseProgress.containsKey(assetName)) return - releaseProgress[assetName] = 0 - refreshReleaseRows() - Thread({ - var file: java.io.File? = null - val problem = try { - file = TurnipReleases.download(this, asset) { pct -> - ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } - } - val uri = Uri.fromFile(file) - val id = if (asset.linux) LinuxVulkanDriverManager(this).installDriver(uri, asset.name) - else TurnipDriver(this).installFromZip(uri, asset.name) - TurnipReleases.recordDownload(this, asset, id) - null - } catch (e: IllegalArgumentException) { - e.message - } catch (e: Exception) { - Log.w(TAG, "release driver download", e) - "Download failed: ${e.message}" - } finally { - file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) } - } - ui.post { - releaseProgress.remove(assetName) - android.widget.Toast.makeText( - this, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu", - android.widget.Toast.LENGTH_LONG, - ).show() - refreshDrivers() - } - }, "download-turnip").start() - } - - private fun displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try { - contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) - ?.use { c -> if (c.moveToFirst()) c.getString(0) else null } - } catch (e: Exception) { - null - } - /** The two masks as the dialog shows them; an empty stored list shows as every core ticked. */ private fun refreshCores() { clientOverride = SessionPrefs.clientCpusOverride(this) @@ -1264,11 +933,8 @@ class MainActivity : ComponentActivity() { refreshPhantomStatus() } - private fun refreshProtons() { - protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(this, it), ProtonExtras.queued(this, it)) } - } - private fun refresh() { + if (!busy && LinuxRuntimeInstaller.isInstalling()) followInstall { LinuxRuntimeInstaller.attach(it) } desktopInstalled = DesktopCatalog.desktopInstalled(this) offlineAccount = OfflineMode.account(this) offline = OfflineMode.enabled(this) @@ -1354,17 +1020,28 @@ class MainActivity : ComponentActivity() { } private fun install(release: LinuxRuntimeInstaller.Release) { + // The service keeps the process alive if the user switches away; this screen joins the + // same install (or starts it, if it gets there first) to show the progress. + com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) + followInstall { listener -> LinuxRuntimeInstaller.install(this, release, listener) } + } + + /** + * Shows an install's progress until it ends. [run] either starts one or joins the one already + * running (null: nothing was), which is how a launcher rebuilt mid-install picks it back up. + */ + private fun followInstall(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) { busy = true failed = false stage = "Starting…" percent = -1 Thread({ - val ok = LinuxRuntimeInstaller.install(this, release) { s, p -> + val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p -> ui.post { stage = s; percent = p } - } + }) ui.post { busy = false - failed = !ok + if (ok != null) failed = !ok refresh() } }, "install").start() diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt new file mode 100644 index 000000000..ee055bb32 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt @@ -0,0 +1,62 @@ +package com.droiddeck.launcher + +import android.os.Handler +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.session.ProtonExtras +import com.droiddeck.launcher.ui.ProtonRow +import com.droiddeck.launcher.session.SessionState + +/** + * The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or + * removal in progress. The launcher screen keeps one and shows it. + */ +internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) { + var protonRows by mutableStateOf>(emptyList()) + var protonBusyId by mutableStateOf(null) + var protonStage by mutableStateOf(null) + var protonPercent by mutableIntStateOf(-1) + + fun installProton(id: String) { + val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return + if (protonBusyId != null || SessionState.running) return + ProtonExtras.unqueue(activity, tool) + protonBusyId = id + protonStage = "Starting…" + protonPercent = -1 + Thread({ + val problem = ProtonExtras.install(activity, tool) { label, value -> + ui.post { protonStage = label; protonPercent = value } + } + ui.post { + protonBusyId = null + protonStage = null + protonPercent = -1 + refreshProtons() + if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show() + } + }, "install-proton-$id").start() + } + + fun removeProton(id: String) { + val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return + if (protonBusyId != null || SessionState.running) return + protonBusyId = id + protonStage = "Removing ${tool.name}…" + protonPercent = -1 + Thread({ + ProtonExtras.remove(activity, tool) + ui.post { + protonBusyId = null + protonStage = null + refreshProtons() + } + }, "remove-proton-$id").start() + } + + fun refreshProtons() { + protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 0b24f188d..be96e6328 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -252,118 +252,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } readPrefs() - // One Compose layer for every menu and overlay. Touches nothing in it consumes fall - // through to the pad and the game underneath. - sessionOverlay = ComposeView(this).apply { - isFocusable = true - isFocusableInTouchMode = true - setContent { - DroidDeckTheme { - CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, - cursorImage, cursorHotX, cursorHotY, cursorImageScale) - if (hud.text.isNotEmpty()) HudText(hud.text) - if (loading.visible) LoadingOverlay( - loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended, - 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() }, - ) - // Opening the drawer takes the controller away from the game: release its pad. - androidx.compose.runtime.LaunchedEffect(drawerOpen) { - if (drawerOpen) { - padBridge?.releaseAll() - androidx.compose.runtime.withFrameNanos { } - val requested = sessionOverlay.requestFocus() - Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}") - } else { - releaseDrawerDirection() - } - } - // The PC keyboard takes it too, for moving over the keys; the drawer opens over it. - androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() } - if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard( - sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) }, - onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, - onClose = { pcKeyboardOpen = false }, - ) - 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, - isHomeApp = isHomeApp, - androidApps = androidApps, - hudOn = hudOn, - fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null, - frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier, - lsfgReady = LsfgNative.isInstalled(this@SessionActivity), - oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode, - touchAuto = if (usingTouchpad()) "touchpad" else "direct", - shapeMode = shapeMode, 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 }, - onFrameGenPick = { engine, multiplier -> - FrameGen.set(this@SessionActivity, engine, multiplier) - readPrefs() - applyFrameGen() - }, - onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, - onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, - onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, - onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null, - // The desktop with Steam's desktop client in it, in this session's place: the - // session ends with status 0 and onSessionEnded starts the relaunch (Stop's - // finish() would skip it). - onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({ - drawerOpen = false - SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java) - .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP) - .putExtra(SessionService.EXTRA_STEAM_UI, "desktop") - SessionService.stop(this@SessionActivity) - }) else null, - backActionsInverted = backActionsInverted, - onBackActionsInverted = { inverted -> - SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) - backActionsInverted = inverted - }, - onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() }, - onSuspendPolicy = { policy -> - SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy) - suspendPolicy = policy - SessionService.suspendPolicyChanged(this@SessionActivity) - }, - onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() }, - onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() }, - onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, - onSecondScreenMode = ::selectSecondScreenMode, - onSecondScreenDisplay = ::selectSecondScreenDisplay, - onLaunchAndroidApp = { app, displayId -> - drawerOpen = false - 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() - } - }, - onBackground = { drawerOpen = false; moveTaskToBack(true) }, - onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() }, - onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() }, - onClose = { drawerOpen = false }, - components = drawerComponents, - onComponentsRefresh = { refreshDrawerComponents() }, - onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) }, - )) - if (SessionState.suspended) SessionPausedOverlay( - title = pausedTitle(), - onResume = { SessionService.resume(this@SessionActivity) }, - ) - } - } - } + sessionOverlay = createSessionOverlay() root.addView(sessionOverlay) setContentView(root) handleHomeGuideIntent(intent) @@ -393,6 +282,121 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { watchSession() } + /** + * One Compose layer for every menu and overlay. Touches nothing in it consumes fall through to + * the pad and the game underneath. + */ + private fun createSessionOverlay(): ComposeView = ComposeView(this).apply { + isFocusable = true + isFocusableInTouchMode = true + setContent { + DroidDeckTheme { + CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, + cursorImage, cursorHotX, cursorHotY, cursorImageScale) + if (hud.text.isNotEmpty()) HudText(hud.text) + if (loading.visible) LoadingOverlay( + loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended, + 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() }, + ) + // Opening the drawer takes the controller away from the game: release its pad. + androidx.compose.runtime.LaunchedEffect(drawerOpen) { + if (drawerOpen) { + padBridge?.releaseAll() + androidx.compose.runtime.withFrameNanos { } + val requested = sessionOverlay.requestFocus() + Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}") + } else { + releaseDrawerDirection() + } + } + // The PC keyboard takes it too, for moving over the keys; the drawer opens over it. + androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() } + if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard( + sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) }, + onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, + onClose = { pcKeyboardOpen = false }, + ) + 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, + isHomeApp = isHomeApp, + androidApps = androidApps, + hudOn = hudOn, + fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null, + frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier, + lsfgReady = LsfgNative.isInstalled(this@SessionActivity), + oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode, + touchAuto = if (usingTouchpad()) "touchpad" else "direct", + shapeMode = shapeMode, 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 }, + onFrameGenPick = { engine, multiplier -> + FrameGen.set(this@SessionActivity, engine, multiplier) + readPrefs() + applyFrameGen() + }, + onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, + onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, + onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, + onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null, + // The desktop with Steam's desktop client in it, in this session's place: the + // session ends with status 0 and onSessionEnded starts the relaunch (Stop's + // finish() would skip it). + onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({ + drawerOpen = false + SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP) + .putExtra(SessionService.EXTRA_STEAM_UI, "desktop") + SessionService.stop(this@SessionActivity) + }) else null, + backActionsInverted = backActionsInverted, + onBackActionsInverted = { inverted -> + SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) + backActionsInverted = inverted + }, + onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() }, + onSuspendPolicy = { policy -> + SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy) + suspendPolicy = policy + SessionService.suspendPolicyChanged(this@SessionActivity) + }, + onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() }, + onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() }, + onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, + onSecondScreenMode = ::selectSecondScreenMode, + onSecondScreenDisplay = ::selectSecondScreenDisplay, + onLaunchAndroidApp = { app, displayId -> + drawerOpen = false + 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() + } + }, + onBackground = { drawerOpen = false; moveTaskToBack(true) }, + onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() }, + onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() }, + onClose = { drawerOpen = false }, + components = drawerComponents, + onComponentsRefresh = { refreshDrawerComponents() }, + onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) }, + )) + if (SessionState.suspended) SessionPausedOverlay( + title = pausedTitle(), + onResume = { SessionService.resume(this@SessionActivity) }, + ) + } + } + } + private fun refreshHomeApp() { isHomeApp = HomeApp.isDefault(this) androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList() diff --git a/app/src/main/java/com/droiddeck/launcher/core/ArchivePaths.java b/app/src/main/java/com/droiddeck/launcher/core/ArchivePaths.java new file mode 100644 index 000000000..3f9eaee44 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/ArchivePaths.java @@ -0,0 +1,25 @@ +package com.droiddeck.launcher.core; + +import java.io.File; +import java.io.IOException; + +/** + * Where an archive entry may land. An entry name ("../x", "/etc/x") or a symlink already written + * by an earlier entry can point outside the directory being filled; the canonical path follows + * both, so anything that does not resolve inside it is refused. + */ +public final class ArchivePaths { + private ArchivePaths() {} + + /** + * The file for {@code name} under {@code destination}, or null when it would resolve outside + * it. The destination itself counts as inside (a "./" entry). + */ + public static File inside(File destination, String name) throws IOException { + String base = destination.getCanonicalPath(); + File file = new File(destination, name); + String path = file.getCanonicalPath(); + if (path.equals(base) || path.startsWith(base + File.separator)) return file; + return null; + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/Hashes.kt b/app/src/main/java/com/droiddeck/launcher/core/Hashes.kt new file mode 100644 index 000000000..072874c87 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/Hashes.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.core + +import java.io.File +import java.security.MessageDigest + +/** File checksums as lowercase hex, and GitHub's release-asset digests, in one place. */ +object Hashes { + private val GITHUB_SHA256 = Regex("(?i)sha256:[0-9a-f]{64}") + + @JvmStatic + fun sha256(file: File): String = hex(file, "SHA-256") + + @JvmStatic + fun sha512(file: File): String = hex(file, "SHA-512") + + /** True when [digest] is GitHub's asset digest form, "sha256:" and 64 hex digits. */ + @JvmStatic + fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256) + + /** The hex of a GitHub asset digest ("sha256:"), or null when [digest] is not one. */ + @JvmStatic + fun githubSha256(digest: String?): String? = digest?.takeIf { isGithubSha256(it) }?.substringAfter(':') + + private fun hex(file: File, algorithm: String): String { + val digest = MessageDigest.getInstance(algorithm) + file.inputStream().buffered(1 shl 16).use { input -> + val buffer = ByteArray(1 shl 20) + while (true) { + val n = input.read(buffer) + if (n < 0) break + digest.update(buffer, 0, n) + } + } + return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/TarZst.java b/app/src/main/java/com/droiddeck/launcher/core/TarZst.java index 0f0a71636..0a1110f23 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/TarZst.java +++ b/app/src/main/java/com/droiddeck/launcher/core/TarZst.java @@ -37,11 +37,10 @@ public final class TarZst { destination.mkdirs(); try (InputStream in = new ZstdCompressorInputStream(new BufferedInputStream(source, 1 << 16)); TarArchiveInputStream tar = new TarArchiveInputStream(in)) { - String base = destination.getCanonicalPath() + File.separator; TarArchiveEntry entry; while ((entry = tar.getNextTarEntry()) != null) { - File file = new File(destination, entry.getName()); - if (!file.getCanonicalPath().startsWith(base)) continue; + File file = ArchivePaths.inside(destination, entry.getName()); + if (file == null) continue; if (entry.isDirectory()) { //noinspection ResultOfMethodCallIgnored file.mkdirs(); diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt b/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt new file mode 100644 index 000000000..60c359bf4 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt @@ -0,0 +1,93 @@ +package com.droiddeck.launcher.files + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import java.io.File + +// Reading and setting a file's read-only and hidden attributes (the DOS attribute xattr). + +// ── DOS file attributes (Read-only / Hidden) ── +// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the +// same two toggles for files browsed from the app side. No root - we only touch permission bits the +// app owns (container files) and the Wine DOS-attribute xattr. + +// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute. +private const val FILE_ATTRIBUTE_HIDDEN = 0x2 + +private const val DOSATTRIB_XATTR = "user.DOSATTRIB" + +// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying +// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then +// disabled while Read-only keeps working. +internal data class FileAttrState( + val readOnly: Boolean, + val hidden: Boolean, + val hiddenSupported: Boolean, +) + +// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing +// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x" +// prefix and stop at the first non-hex character. +private fun parseDosAttrib(raw: ByteArray): Int { + val s = String(raw, Charsets.US_ASCII).trim() + val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s + val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + return digits.toIntOrNull(16) ?: 0 +} + +// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem +// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves. +internal fun readFileAttrs(file: File): FileAttrState { + val path = file.absolutePath + // Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY. + val readOnly = runCatching { + (android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0 + }.getOrDefault(!file.canWrite()) + + var hidden = false + var supported = true + try { + hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0 + } catch (e: android.system.ErrnoException) { + // ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable. + // ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle. + supported = e.errno == android.system.OsConstants.ENODATA + } catch (e: Exception) { + supported = false + } + return FileAttrState(readOnly, hidden, supported) +} + +// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting +// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which +// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is +// somehow refused. Returns true on success. +internal fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching { + val path = file.absolutePath + val mode = android.system.Os.stat(path).st_mode + val writeBits = android.system.OsConstants.S_IWUSR or + android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH + val newMode = if (readOnly) mode and writeBits.inv() + else mode or android.system.OsConstants.S_IWUSR + android.system.Os.chmod(path, newMode) + true +}.getOrElse { file.setWritable(!readOnly, true) } + +// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits +// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent. +// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't +// store the xattr, so the caller can disable just the Hidden toggle. +internal fun setHidden(file: File, hidden: Boolean): Boolean = try { + val path = file.absolutePath + var attr = try { + parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) + } catch (e: android.system.ErrnoException) { + if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e + } + attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv() + val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII) + android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0) + true +} catch (e: Exception) { + false +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt new file mode 100644 index 000000000..054430090 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt @@ -0,0 +1,599 @@ +package com.droiddeck.launcher.files + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.Checklist +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileCopy +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.ContentCut +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +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 coil.compose.AsyncImage +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// The File Manager's rows, tiles, favourites and context menu. + +// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme +// accent colours - they identify the storage source at a glance. Returns (background, foreground). +private fun badgeColors(loc: FavLocation): Pair { + val white = Color(0xFFFFFFFF) + return when { + loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue + loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green + else -> Color(0xFF555555) to white // grey + } +} + +// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles +// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is +// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses +// the menu first, then runs its action. +@Composable +private fun FileContextMenuItems( + file: File, + isFavorite: Boolean, + onSelect: () -> Unit, + onRename: () -> Unit, + onCopy: () -> Unit, + onCut: () -> Unit, + onDelete: () -> Unit, + onToggleFavorite: () -> Unit, + onProperties: () -> Unit, + onDismissMenu: () -> Unit, +) { + val isDir = file.isDirectory + DropdownMenuItem( + text = { Text("Select") }, + leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onSelect() }, + ) + MenuItemDivider() + // 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") }, + leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onProperties() }, + ) + MenuItemDivider() + // Favorites are directories - only folders get the pin toggle. + if (isDir) { + DropdownMenuItem( + text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") }, + leadingIcon = { + Icon( + if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder, + null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { onToggleFavorite() }, + ) + MenuItemDivider() + } + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onRename() }, + ) + MenuItemDivider() + DropdownMenuItem( + text = { Text("Copy") }, + leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onCopy() }, + ) + MenuItemDivider() + DropdownMenuItem( + text = { Text("Cut") }, + leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onCut() }, + ) + MenuItemDivider() + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onDelete() }, + ) +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +internal fun FileItemRow( + file: File, + showActions: Boolean = true, + compact: Boolean = false, + selectionMode: Boolean = false, + selected: Boolean = false, + onLongPress: () -> Unit = {}, + onToggleSelect: () -> Unit = {}, + onTap: () -> Unit, + onMenu: () -> Unit, + menuExpanded: Boolean, + onDismissMenu: () -> Unit, + onSelect: () -> Unit = {}, + onCopy: () -> Unit, + onCut: () -> Unit, + onDelete: () -> Unit, + onRename: () -> Unit, + isFavorite: Boolean = false, + onToggleFavorite: () -> Unit = {}, + onProperties: () -> Unit = {}, +) { + val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } + 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 + // wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth. + val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS + + // For real PE executables, try to pull out the embedded application icon (async, off the main thread). + var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } + if (!isDir && file.name.lowercase().endsWith(".exe")) { + LaunchedEffect(file.absolutePath) { + val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } + if (bmp != null) exeIcon = bmp.asImageBitmap() + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 3.dp) + .combinedClickable( + // In selection mode a tap toggles instead of opening, so you can rattle through a + // folder without long-pressing every single row. + onClick = { if (selectionMode) onToggleSelect() else onTap() }, + onLongClick = onLongPress, + ), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + else MaterialTheme.colorScheme.surfaceContainer, + ), + border = BorderStroke( + 1.dp, + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp), + ) { + if (selectionMode) { + androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() }) + Spacer(Modifier.width(4.dp)) + } + when { + // Show the executable's own embedded icon when we managed to extract one. + exeIcon != null -> Image( + bitmap = exeIcon!!, + contentDescription = null, + modifier = Modifier.size(if (compact) 24.dp else 36.dp), + ) + isExe -> Icon( + painter = painterResource(R.drawable.icon_menu_container), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(if (compact) 24.dp else 36.dp), + ) + isDir -> Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(if (compact) 24.dp else 36.dp), + ) + // Real image preview. Falls back to the generic file icon while loading or on decode failure. + isImage -> AsyncImage( + model = file, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile), + error = rememberVectorPainter(Icons.Filled.InsertDriveFile), + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(6.dp)), + ) + else -> Icon( + imageVector = Icons.Filled.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(if (compact) 24.dp else 36.dp), + ) + } + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = buildString { + if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ") + append(dateFormat.format(Date(file.lastModified()))) + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + if (showActions) Box { + IconButton(onClick = onMenu) { + Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissMenu, + modifier = Modifier.outlinedMenuCard(), + ) { + FileContextMenuItems( + file = file, + isFavorite = isFavorite, + onSelect = onSelect, + onRename = onRename, + onCopy = onCopy, + onCut = onCut, + onDelete = onDelete, + onToggleFavorite = onToggleFavorite, + onProperties = onProperties, + onDismissMenu = onDismissMenu, + ) + } + } + } + } +} + +// 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 +internal fun FavoritesList( + currentDir: File, + favTick: Int, + onPinCurrent: () -> Unit, + onJump: (File) -> Unit, + onUnpin: (File) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val favorites = remember(favTick) { + FavoritesStore.list(context).map(::File).filter { it.exists() } + } + val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) { + FavoritesStore.isFavorite(context, currentDir.absolutePath) + } + + LazyColumn(modifier = modifier) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Favorites", + color = MaterialTheme.colorScheme.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = onPinCurrent, + enabled = !currentAlreadyPinned, + ) { + Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = "Pin current folder", + color = MaterialTheme.colorScheme.primary, + fontSize = 13.sp, + ) + } + } + } + + if (favorites.isEmpty()) { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } + } else { + items(favorites, key = { it.absolutePath }) { file -> + val loc = remember(file.absolutePath) { describeLocation(file) } + FavoriteCard( + file = file, + loc = loc, + onJump = { onJump(file) }, + onUnpin = { onUnpin(file) }, + ) + } + } + } +} + +// A single favourite - matches the FileItemRow card style (surfaceContainer + outline + +// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text, +// and the full display path; tapping jumps into it, the filled star unpins. +@Composable +private fun FavoriteCard( + file: File, + loc: FavLocation, + onJump: () -> Unit, + onUnpin: () -> Unit, +) { + val (badgeBg, badgeFg) = badgeColors(loc) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 3.dp) + .clickable(onClick = onJump), + 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 = 8.dp), + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp), + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + // Origin line: coloured drive badge + source description. + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = loc.driveLabel, + color = badgeFg, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(badgeBg) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = when (loc.storage) { + FavStorage.INTERNAL -> "Internal storage" + FavStorage.SD -> "SD card" + FavStorage.OTHER -> "Storage" + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = loc.displayPath, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onUnpin) { + Icon( + Icons.Filled.Star, + contentDescription = "Remove from favorites", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } + } +} + +/** + * One entry in the File Manager's grid view: a big thumbnail with the name under it. + * + * Deliberately drops size and date - at this width they truncate to noise. The grid is for + * recognising things by sight (screenshots, covers, game folders); the list stays the view for + * reading details. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun FileGridTile( + file: File, + selectionMode: Boolean, + selected: Boolean, + onLongPress: () -> Unit, + onToggleSelect: () -> Unit, + onTap: () -> Unit, + onMenu: () -> Unit, + menuExpanded: Boolean = false, + onDismissMenu: () -> Unit = {}, + isFavorite: Boolean = false, + onSelect: () -> Unit = {}, + onRename: () -> Unit = {}, + onCopy: () -> Unit = {}, + onCut: () -> Unit = {}, + onDelete: () -> Unit = {}, + onToggleFavorite: () -> Unit = {}, + onProperties: () -> Unit = {}, +) { + val isDir = file.isDirectory + val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS + var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } + if (!isDir && file.name.lowercase().endsWith(".exe")) { + LaunchedEffect(file.absolutePath) { + val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } + if (bmp != null) exeIcon = bmp.asImageBitmap() + } + } + // The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card. + Box { + Card( + modifier = Modifier + .padding(4.dp) + .combinedClickable( + onClick = { if (selectionMode) onToggleSelect() else onTap() }, + onLongClick = onLongPress, + ), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + else MaterialTheme.colorScheme.surfaceContainer, + ), + border = BorderStroke( + 1.dp, + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) { + Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) { + when { + exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp)) + isDir -> Icon( + Icons.Filled.Folder, null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + isImage -> AsyncImage( + model = file, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile), + error = rememberVectorPainter(Icons.Filled.InsertDriveFile), + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)), + ) + else -> Icon( + Icons.Filled.InsertDriveFile, null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(44.dp), + ) + } + if (selectionMode) { + androidx.compose.material3.Checkbox( + checked = selected, + onCheckedChange = { onToggleSelect() }, + modifier = Modifier.align(Alignment.TopStart), + ) + } + } + Spacer(Modifier.height(4.dp)) + // Always reserve two lines (issue #475): a one-line name used to make its card shorter than + // its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size. + Text( + file.name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 12.sp, + minLines = 2, + maxLines = 2, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + overflow = TextOverflow.Ellipsis, + ) + } + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissMenu, + modifier = Modifier.outlinedMenuCard(), + ) { + FileContextMenuItems( + file = file, + isFavorite = isFavorite, + onSelect = onSelect, + onRename = onRename, + onCopy = onCopy, + onCut = onCut, + onDelete = onDelete, + onToggleFavorite = onToggleFavorite, + onProperties = onProperties, + onDismissMenu = onDismissMenu, + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt new file mode 100644 index 000000000..b1705146f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt @@ -0,0 +1,101 @@ +package com.droiddeck.launcher.files + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Storage +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 java.io.File + +// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations. + +/* + * Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same + * browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and + * progress, rename, delete, new folder, properties, favourites and the locations rail, and the + * same pick modes. What this app has no use for is not here: running a file in a Wine container, + * adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive + * unpacker that installs into a prefix. Everything else is line for line the same so a fix in + * either app carries to the other. + */ + +/** + * What to do when a pasted item already exists at the destination. + * + * OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing + * directory and truncates existing files - but they mean different things to the user, so both are + * offered and the wording is chosen per item type (files overwrite, folders merge). + */ +enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP } + +/** + * Shortens a path from the LEFT, keeping whole segments. + * + * Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that + * matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are. + */ +internal fun elidePathStart(path: String, max: Int): String { + if (path.length <= max) return path + val parts = path.split('/').filter { it.isNotEmpty() } + val out = StringBuilder() + for (part in parts.asReversed()) { + if (out.length + part.length + 1 > max - 2) break + out.insert(0, "/$part") + } + return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out" +} + +/** Folder-first ordering stays fixed across both sort directions. */ +internal fun comparatorFor(sortBy: String, desc: Boolean): Comparator { + val inner: Comparator = when (sortBy) { + "date" -> compareBy { it.lastModified() } + // Directory length() is meaningless, so folders sort by name within the size ordering + // instead of pretending to have one. + "size" -> compareBy { if (it.isDirectory) -1L else it.length() } + else -> compareBy { it.name.lowercase() } + } + val directed = if (desc) inner.reversed() else inner + return compareBy { if (it.isDirectory) 0 else 1 }.then(directed) +} + +// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon. +internal val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif") + +// True when [child] is [ancestor] itself or lives anywhere inside it. +internal fun isWithin(child: File, ancestor: File): Boolean { + val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath) + val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath) + return c == a || c.startsWith(a + File.separator) +} + +// ── Favorites: origin resolution ── + +enum class FavStorage { INTERNAL, SD, OTHER } + +data class FavLocation( + val storage: FavStorage, + val driveLabel: String, // "Internal", "SD card", or "Storage" + val displayPath: String // the unix absolute path +) + +// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path. +fun describeLocation(file: File): FavLocation { + val abs = file.absolutePath + + val internal = "/storage/emulated/0" + if (abs == internal || abs.startsWith("$internal/")) { + return FavLocation(FavStorage.INTERNAL, "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.OTHER, "Storage", abs) +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 3f2a33667..ecae61e2f 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -7,12 +7,7 @@ import androidx.compose.foundation.Image import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -21,7 +16,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -33,41 +27,25 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.rememberLazyListState 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.ArrowBack -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.FileCopy import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.GridView -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.InsertDriveFile -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.ContentCut -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SdStorage import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.Unarchive import androidx.compose.material.icons.filled.ViewList import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.SportsEsports -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -95,217 +73,22 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.painterResource import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import coil.compose.AsyncImage -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.droiddeck.launcher.R import com.droiddeck.launcher.session.SessionPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale - -/* - * Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same - * browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and - * progress, rename, delete, new folder, properties, favourites and the locations rail, and the - * same pick modes. What this app has no use for is not here: running a file in a Wine container, - * adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive - * unpacker that installs into a prefix. Everything else is line for line the same so a fix in - * either app carries to the other. - */ - -/** - * What to do when a pasted item already exists at the destination. - * - * OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing - * directory and truncates existing files - but they mean different things to the user, so both are - * offered and the wording is chosen per item type (files overwrite, folders merge). - */ -enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP } - -/** - * Shortens a path from the LEFT, keeping whole segments. - * - * Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that - * matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are. - */ -private fun elidePathStart(path: String, max: Int): String { - if (path.length <= max) return path - val parts = path.split('/').filter { it.isNotEmpty() } - val out = StringBuilder() - for (part in parts.asReversed()) { - if (out.length + part.length + 1 > max - 2) break - out.insert(0, "/$part") - } - return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out" -} - -/** Folder-first ordering stays fixed across both sort directions. */ -private fun comparatorFor(sortBy: String, desc: Boolean): Comparator { - val inner: Comparator = when (sortBy) { - "date" -> compareBy { it.lastModified() } - // Directory length() is meaningless, so folders sort by name within the size ordering - // instead of pretending to have one. - "size" -> compareBy { if (it.isDirectory) -1L else it.length() } - else -> compareBy { it.name.lowercase() } - } - val directed = if (desc) inner.reversed() else inner - return compareBy { if (it.isDirectory) 0 else 1 }.then(directed) -} - -// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon. -private val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif") - -// True when [child] is [ancestor] itself or lives anywhere inside it. -private fun isWithin(child: File, ancestor: File): Boolean { - val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath) - val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath) - return c == a || c.startsWith(a + File.separator) -} - -// ── DOS file attributes (Read-only / Hidden) ── -// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the -// same two toggles for files browsed from the app side. No root - we only touch permission bits the -// app owns (container files) and the Wine DOS-attribute xattr. - -// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute. -private const val FILE_ATTRIBUTE_HIDDEN = 0x2 -private const val DOSATTRIB_XATTR = "user.DOSATTRIB" - -// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying -// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then -// disabled while Read-only keeps working. -private data class FileAttrState( - val readOnly: Boolean, - val hidden: Boolean, - val hiddenSupported: Boolean, -) - -// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing -// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x" -// prefix and stop at the first non-hex character. -private fun parseDosAttrib(raw: ByteArray): Int { - val s = String(raw, Charsets.US_ASCII).trim() - val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s - val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } - return digits.toIntOrNull(16) ?: 0 -} - -// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem -// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves. -private fun readFileAttrs(file: File): FileAttrState { - val path = file.absolutePath - // Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY. - val readOnly = runCatching { - (android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0 - }.getOrDefault(!file.canWrite()) - - var hidden = false - var supported = true - try { - hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0 - } catch (e: android.system.ErrnoException) { - // ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable. - // ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle. - supported = e.errno == android.system.OsConstants.ENODATA - } catch (e: Exception) { - supported = false - } - return FileAttrState(readOnly, hidden, supported) -} - -// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting -// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which -// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is -// somehow refused. Returns true on success. -private fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching { - val path = file.absolutePath - val mode = android.system.Os.stat(path).st_mode - val writeBits = android.system.OsConstants.S_IWUSR or - android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH - val newMode = if (readOnly) mode and writeBits.inv() - else mode or android.system.OsConstants.S_IWUSR - android.system.Os.chmod(path, newMode) - true -}.getOrElse { file.setWritable(!readOnly, true) } - -// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits -// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent. -// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't -// store the xattr, so the caller can disable just the Hidden toggle. -private fun setHidden(file: File, hidden: Boolean): Boolean = try { - val path = file.absolutePath - var attr = try { - parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) - } catch (e: android.system.ErrnoException) { - if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e - } - attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv() - val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII) - android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0) - true -} catch (e: Exception) { - false -} - -// ── Favorites: origin resolution ── - -enum class FavStorage { INTERNAL, SD, OTHER } - -data class FavLocation( - val storage: FavStorage, - val driveLabel: String, // "Internal", "SD card", or "Storage" - val displayPath: String // the unix absolute path -) - -// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path. -fun describeLocation(file: File): FavLocation { - val abs = file.absolutePath - - val internal = "/storage/emulated/0" - if (abs == internal || abs.startsWith("$internal/")) { - return FavLocation(FavStorage.INTERNAL, "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.OTHER, "Storage", abs) -} - -// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme -// accent colours - they identify the storage source at a glance. Returns (background, foreground). -private fun badgeColors(loc: FavLocation): Pair { - val white = Color(0xFFFFFFFF) - return when { - loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue - loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green - else -> Color(0xFF555555) to white // grey - } -} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -1414,675 +1197,3 @@ fun FileManagerScreen( } // end rail + content Row } } - -// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles -// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is -// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses -// the menu first, then runs its action. -@Composable -private fun FileContextMenuItems( - file: File, - isFavorite: Boolean, - onSelect: () -> Unit, - onRename: () -> Unit, - onCopy: () -> Unit, - onCut: () -> Unit, - onDelete: () -> Unit, - onToggleFavorite: () -> Unit, - onProperties: () -> Unit, - onDismissMenu: () -> Unit, -) { - val isDir = file.isDirectory - DropdownMenuItem( - text = { Text("Select") }, - leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onSelect() }, - ) - MenuItemDivider() - // 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") }, - leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onProperties() }, - ) - MenuItemDivider() - // Favorites are directories - only folders get the pin toggle. - if (isDir) { - DropdownMenuItem( - text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") }, - leadingIcon = { - Icon( - if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder, - null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - onClick = { onToggleFavorite() }, - ) - MenuItemDivider() - } - DropdownMenuItem( - text = { Text("Rename") }, - leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onRename() }, - ) - MenuItemDivider() - DropdownMenuItem( - text = { Text("Copy") }, - leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onCopy() }, - ) - MenuItemDivider() - DropdownMenuItem( - text = { Text("Cut") }, - leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onCut() }, - ) - MenuItemDivider() - DropdownMenuItem( - text = { Text("Delete") }, - leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onDelete() }, - ) -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun FileItemRow( - file: File, - showActions: Boolean = true, - compact: Boolean = false, - selectionMode: Boolean = false, - selected: Boolean = false, - onLongPress: () -> Unit = {}, - onToggleSelect: () -> Unit = {}, - onTap: () -> Unit, - onMenu: () -> Unit, - menuExpanded: Boolean, - onDismissMenu: () -> Unit, - onSelect: () -> Unit = {}, - onCopy: () -> Unit, - onCut: () -> Unit, - onDelete: () -> Unit, - onRename: () -> Unit, - isFavorite: Boolean = false, - onToggleFavorite: () -> Unit = {}, - onProperties: () -> Unit = {}, -) { - val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } - 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 - // wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth. - val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS - - // For real PE executables, try to pull out the embedded application icon (async, off the main thread). - var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } - if (!isDir && file.name.lowercase().endsWith(".exe")) { - LaunchedEffect(file.absolutePath) { - val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } - if (bmp != null) exeIcon = bmp.asImageBitmap() - } - } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 3.dp) - .combinedClickable( - // In selection mode a tap toggles instead of opening, so you can rattle through a - // folder without long-pressing every single row. - onClick = { if (selectionMode) onToggleSelect() else onTap() }, - onLongClick = onLongPress, - ), - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors( - containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) - else MaterialTheme.colorScheme.surfaceContainer, - ), - border = BorderStroke( - 1.dp, - if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp), - ) { - if (selectionMode) { - androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() }) - Spacer(Modifier.width(4.dp)) - } - when { - // Show the executable's own embedded icon when we managed to extract one. - exeIcon != null -> Image( - bitmap = exeIcon!!, - contentDescription = null, - modifier = Modifier.size(if (compact) 24.dp else 36.dp), - ) - isExe -> Icon( - painter = painterResource(R.drawable.icon_menu_container), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(if (compact) 24.dp else 36.dp), - ) - isDir -> Icon( - imageVector = Icons.Filled.Folder, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(if (compact) 24.dp else 36.dp), - ) - // Real image preview. Falls back to the generic file icon while loading or on decode failure. - isImage -> AsyncImage( - model = file, - contentDescription = null, - contentScale = ContentScale.Crop, - placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile), - error = rememberVectorPainter(Icons.Filled.InsertDriveFile), - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(6.dp)), - ) - else -> Icon( - imageVector = Icons.Filled.InsertDriveFile, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(if (compact) 24.dp else 36.dp), - ) - } - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = file.name, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = buildString { - if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ") - append(dateFormat.format(Date(file.lastModified()))) - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - if (showActions) Box { - IconButton(onClick = onMenu) { - Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = onDismissMenu, - modifier = Modifier.outlinedMenuCard(), - ) { - FileContextMenuItems( - file = file, - isFavorite = isFavorite, - onSelect = onSelect, - onRename = onRename, - onCopy = onCopy, - onCut = onCut, - onDelete = onDelete, - onToggleFavorite = onToggleFavorite, - onProperties = onProperties, - onDismissMenu = onDismissMenu, - ) - } - } - } - } -} - -// 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 -private fun FavoritesList( - currentDir: File, - favTick: Int, - onPinCurrent: () -> Unit, - onJump: (File) -> Unit, - onUnpin: (File) -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val favorites = remember(favTick) { - FavoritesStore.list(context).map(::File).filter { it.exists() } - } - val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) { - FavoritesStore.isFavorite(context, currentDir.absolutePath) - } - - LazyColumn(modifier = modifier) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = "Favorites", - color = MaterialTheme.colorScheme.onBackground, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - TextButton( - onClick = onPinCurrent, - enabled = !currentAlreadyPinned, - ) { - Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text( - text = "Pin current folder", - color = MaterialTheme.colorScheme.primary, - fontSize = 13.sp, - ) - } - } - } - - if (favorites.isEmpty()) { - item { - Box( - modifier = Modifier.fillParentMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 13.sp, - modifier = Modifier.padding(horizontal = 32.dp), - ) - } - } - } else { - items(favorites, key = { it.absolutePath }) { file -> - val loc = remember(file.absolutePath) { describeLocation(file) } - FavoriteCard( - file = file, - loc = loc, - onJump = { onJump(file) }, - onUnpin = { onUnpin(file) }, - ) - } - } - } -} - -// A single favourite - matches the FileItemRow card style (surfaceContainer + outline + -// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text, -// and the full display path; tapping jumps into it, the filled star unpins. -@Composable -private fun FavoriteCard( - file: File, - loc: FavLocation, - onJump: () -> Unit, - onUnpin: () -> Unit, -) { - val (badgeBg, badgeFg) = badgeColors(loc) - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 3.dp) - .clickable(onClick = onJump), - 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 = 8.dp), - ) { - Icon( - imageVector = Icons.Filled.Folder, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp), - ) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = file.name, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(2.dp)) - // Origin line: coloured drive badge + source description. - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = loc.driveLabel, - color = badgeFg, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(badgeBg) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) - Spacer(Modifier.width(6.dp)) - Text( - text = when (loc.storage) { - FavStorage.INTERNAL -> "Internal storage" - FavStorage.SD -> "SD card" - FavStorage.OTHER -> "Storage" - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - Spacer(Modifier.height(2.dp)) - Text( - text = loc.displayPath, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton(onClick = onUnpin) { - Icon( - Icons.Filled.Star, - contentDescription = "Remove from favorites", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - } - } - } -} - -/** - * One entry in the File Manager's grid view: a big thumbnail with the name under it. - * - * Deliberately drops size and date - at this width they truncate to noise. The grid is for - * recognising things by sight (screenshots, covers, game folders); the list stays the view for - * reading details. - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun FileGridTile( - file: File, - selectionMode: Boolean, - selected: Boolean, - onLongPress: () -> Unit, - onToggleSelect: () -> Unit, - onTap: () -> Unit, - onMenu: () -> Unit, - menuExpanded: Boolean = false, - onDismissMenu: () -> Unit = {}, - isFavorite: Boolean = false, - onSelect: () -> Unit = {}, - onRename: () -> Unit = {}, - onCopy: () -> Unit = {}, - onCut: () -> Unit = {}, - onDelete: () -> Unit = {}, - onToggleFavorite: () -> Unit = {}, - onProperties: () -> Unit = {}, -) { - val isDir = file.isDirectory - val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS - var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } - if (!isDir && file.name.lowercase().endsWith(".exe")) { - LaunchedEffect(file.absolutePath) { - val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } - if (bmp != null) exeIcon = bmp.asImageBitmap() - } - } - // The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card. - Box { - Card( - modifier = Modifier - .padding(4.dp) - .combinedClickable( - onClick = { if (selectionMode) onToggleSelect() else onTap() }, - onLongClick = onLongPress, - ), - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors( - containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) - else MaterialTheme.colorScheme.surfaceContainer, - ), - border = BorderStroke( - 1.dp, - if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - ), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth().padding(8.dp), - ) { - Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) { - when { - exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp)) - isDir -> Icon( - Icons.Filled.Folder, null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp), - ) - isImage -> AsyncImage( - model = file, - contentDescription = null, - contentScale = ContentScale.Crop, - placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile), - error = rememberVectorPainter(Icons.Filled.InsertDriveFile), - modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)), - ) - else -> Icon( - Icons.Filled.InsertDriveFile, null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(44.dp), - ) - } - if (selectionMode) { - androidx.compose.material3.Checkbox( - checked = selected, - onCheckedChange = { onToggleSelect() }, - modifier = Modifier.align(Alignment.TopStart), - ) - } - } - Spacer(Modifier.height(4.dp)) - // Always reserve two lines (issue #475): a one-line name used to make its card shorter than - // its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size. - Text( - file.name, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 12.sp, - minLines = 2, - maxLines = 2, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - overflow = TextOverflow.Ellipsis, - ) - } - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = onDismissMenu, - modifier = Modifier.outlinedMenuCard(), - ) { - FileContextMenuItems( - file = file, - isFavorite = isFavorite, - onSelect = onSelect, - onRename = onRename, - onCopy = onCopy, - onCut = onCut, - onDelete = onDelete, - onToggleFavorite = onToggleFavorite, - onProperties = onProperties, - onDismissMenu = onDismissMenu, - ) - } - } -} - -/** - * Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes - * the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies - * immediately (off the main thread) and refreshes the listing via [onChanged]. - * - * State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that - * can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled; - * Read-only keeps working everywhere. - */ -@Composable -private fun FilePropertiesDialog( - file: File, - onDismiss: () -> Unit, - onChanged: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } - var attrs by remember(file.absolutePath) { mutableStateOf(null) } - // Guards against a second toggle landing while the first is still being applied off-thread. - var busy by remember(file.absolutePath) { mutableStateOf(false) } - LaunchedEffect(file.absolutePath) { - attrs = withContext(Dispatchers.IO) { readFileAttrs(file) } - } - - OutlinedAlertDialog( - onDismissRequest = onDismiss, - title = { Text("Properties") }, - text = { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - // ── Basic info ── - Text( - text = file.name, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(6.dp)) - PropertyLine("Location", file.parent ?: "-") - PropertyLine( - "Type", - if (file.isDirectory) "Folder" - else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" }, - ) - if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length())) - PropertyLine("Modified", dateFormat.format(Date(file.lastModified()))) - - Spacer(Modifier.height(10.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outline) - Spacer(Modifier.height(6.dp)) - - 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.", - checked = state?.readOnly == true, - enabled = state != null && !busy, - onToggle = { want -> - busy = true - scope.launch { - val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) } - busy = false - if (ok) { - attrs = attrs?.copy(readOnly = want) - onChanged() - } else { - Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show() - } - } - }, - ) - Spacer(Modifier.height(2.dp)) - // ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr. - val hiddenSupported = state?.hiddenSupported == true - AttributeToggleRow( - label = "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).", - checked = state?.hidden == true, - enabled = state != null && hiddenSupported && !busy, - onToggle = { want -> - busy = true - scope.launch { - val ok = withContext(Dispatchers.IO) { setHidden(file, want) } - busy = false - if (ok) { - attrs = attrs?.copy(hidden = want) - onChanged() - } 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() - } - } - }, - ) - } - }, - confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, - ) -} - -// One "label: value" line in the Properties info block. -@Composable -private fun PropertyLine(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - Text( - text = label, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - modifier = Modifier.width(76.dp), - ) - Text( - text = value, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } -} - -// A labelled attribute switch with a one-line description, used by the Properties sheet. -@Composable -private fun AttributeToggleRow( - label: String, - description: String, - checked: Boolean, - enabled: Boolean, - onToggle: (Boolean) -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - Spacer(Modifier.width(8.dp)) - androidx.compose.material3.Switch( - checked = checked, - enabled = enabled, - onCheckedChange = onToggle, - ) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt new file mode 100644 index 000000000..8c302b853 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt @@ -0,0 +1,202 @@ +package com.droiddeck.launcher.files + +import android.widget.Toast +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// The File Manager's properties dialog. + +/** + * Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes + * the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies + * immediately (off the main thread) and refreshes the listing via [onChanged]. + * + * State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that + * can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled; + * Read-only keeps working everywhere. + */ +@Composable +internal fun FilePropertiesDialog( + file: File, + onDismiss: () -> Unit, + onChanged: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } + var attrs by remember(file.absolutePath) { mutableStateOf(null) } + // Guards against a second toggle landing while the first is still being applied off-thread. + var busy by remember(file.absolutePath) { mutableStateOf(false) } + LaunchedEffect(file.absolutePath) { + attrs = withContext(Dispatchers.IO) { readFileAttrs(file) } + } + + OutlinedAlertDialog( + onDismissRequest = onDismiss, + title = { Text("Properties") }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + // ── Basic info ── + Text( + text = file.name, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(6.dp)) + PropertyLine("Location", file.parent ?: "-") + PropertyLine( + "Type", + if (file.isDirectory) "Folder" + else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" }, + ) + if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length())) + PropertyLine("Modified", dateFormat.format(Date(file.lastModified()))) + + Spacer(Modifier.height(10.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outline) + Spacer(Modifier.height(6.dp)) + + 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.", + checked = state?.readOnly == true, + enabled = state != null && !busy, + onToggle = { want -> + busy = true + scope.launch { + val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) } + busy = false + if (ok) { + attrs = attrs?.copy(readOnly = want) + onChanged() + } else { + Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show() + } + } + }, + ) + Spacer(Modifier.height(2.dp)) + // ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr. + val hiddenSupported = state?.hiddenSupported == true + AttributeToggleRow( + label = "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).", + checked = state?.hidden == true, + enabled = state != null && hiddenSupported && !busy, + onToggle = { want -> + busy = true + scope.launch { + val ok = withContext(Dispatchers.IO) { setHidden(file, want) } + busy = false + if (ok) { + attrs = attrs?.copy(hidden = want) + onChanged() + } 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() + } + } + }, + ) + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + ) +} + +// One "label: value" line in the Properties info block. +@Composable +private fun PropertyLine(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text( + text = label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + modifier = Modifier.width(76.dp), + ) + Text( + text = value, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +// A labelled attribute switch with a one-line description, used by the Properties sheet. +@Composable +private fun AttributeToggleRow( + label: String, + description: String, + checked: Boolean, + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + Spacer(Modifier.width(8.dp)) + androidx.compose.material3.Switch( + checked = checked, + enabled = enabled, + onCheckedChange = onToggle, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt index e9fd3e188..14e7e81e6 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.gpu +import com.droiddeck.launcher.core.Hashes import android.content.Context import com.droiddeck.launcher.core.FileUtils import org.json.JSONArray @@ -27,7 +28,9 @@ object TurnipReleases { private const val KEY_DOWNLOADS = "downloads" /** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */ - class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String) + /** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */ + class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String, + val sha256: String = "") /** [latest] = each source's newest release tag, for the refresh line. */ class Check(val assets: List, val latest: List>, val failed: List, val checkedAt: Long) @@ -89,11 +92,13 @@ object TurnipReleases { val a = list.getJSONObject(j) val name = a.getString("name") val (linux, label) = src.classify(name, tag) ?: continue + // Only assets GitHub has a sha256 for are offered: the download is checked against it. + val sha = Hashes.githubSha256(a.optString("digest")) ?: continue // Newest first: the first release carrying a variant is the one offered. if (!seen.add("$linux|$label")) continue assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name) .put("url", a.getString("browser_download_url")).put("size", a.optLong("size")) - .put("linux", linux).put("label", label)) + .put("linux", linux).put("label", label).put("sha256", sha)) } } if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest)) @@ -131,7 +136,7 @@ object TurnipReleases { for (i in 0 until list.length()) { val a = list.getJSONObject(i) out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"), - a.optLong("size"), a.getBoolean("linux"), a.getString("label"))) + a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""))) } val latest = ArrayList>() val l = json.optJSONArray("latest") ?: JSONArray() @@ -167,6 +172,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") val target = File(context.cacheDir, asset.name) val c = URL(asset.url).openConnection() as HttpURLConnection c.connectTimeout = 15_000 @@ -191,6 +197,9 @@ object TurnipReleases { } } } + if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) { + throw IOException("Checksum mismatch - the download was discarded") + } return target } catch (e: Exception) { FileUtils.delete(target) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index cef72df49..601527623 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -1,12 +1,11 @@ package com.droiddeck.launcher.runtime +import com.droiddeck.launcher.core.Hashes import android.content.Context import android.util.Log import com.droiddeck.launcher.core.Downloader import com.droiddeck.launcher.session.SessionState import java.io.File -import java.io.FileInputStream -import java.security.MessageDigest import org.json.JSONArray /** Optional Decky Loader installation for the ARM64 Linux guest. */ @@ -84,7 +83,7 @@ object DeckyManager { val n = it.optString("name") (n == "$name.sha256" || n == "$name.sha256sum" || n == "$name.sha256.txt") && it.optString("browser_download_url").startsWith("https://") }?.optString("browser_download_url")?.takeIf { it.startsWith("https://") } - val digest = binary.optString("digest").takeIf { it.matches(Regex("(?i)sha256:[0-9a-f]{64}")) } + val digest = binary.optString("digest").takeIf { Hashes.isGithubSha256(it) } if (digest == null && checksum == null) continue val size = binary.optLong("size", 0) if (size <= 0) continue @@ -109,7 +108,7 @@ object DeckyManager { 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(sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" } + 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" } @@ -148,15 +147,6 @@ object DeckyManager { if (wipeData) File(root, "root/homebrew").deleteRecursively() } - private fun sha256(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - FileInputStream(file).buffered().use { input -> - val buffer = ByteArray(1024 * 1024) - while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) } - } - return digest.digest().joinToString("") { "%02x".format(it.toInt() and 255) } - } - private fun validElf(file: File, machine: Int): Boolean = runCatching { file.inputStream().use { input -> val h = ByteArray(20) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index cda22eb17..da69edf43 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.runtime +import com.droiddeck.launcher.core.Hashes import android.content.Context import android.util.Log import com.droiddeck.launcher.core.Downloader @@ -81,6 +82,8 @@ object DesktopCatalog { 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" + // 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}" val download = File(context.cacheDir, "pkg-${entry.id}.download") try { listener?.onProgress("Downloading ${entry.name}", 0) @@ -88,11 +91,9 @@ object DesktopCatalog { listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f)) } if (!ok) return "Download failed" - if (entry.sha256.isNotEmpty()) { - listener?.onProgress("Verifying", -1) - val actual = LinuxRuntimeInstaller.sha256(download) - if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed" - } + listener?.onProgress("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) when (entry.kind) { "appimage" -> { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index 1a6e07c60..66f8f918a 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -4,7 +4,9 @@ import android.content.Context; import android.util.Log; +import com.droiddeck.launcher.core.ArchivePaths; import com.droiddeck.launcher.core.Downloader; +import com.droiddeck.launcher.core.Hashes; import com.droiddeck.launcher.core.FileUtils; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; @@ -23,7 +25,8 @@ import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardCopyOption; -import java.security.MessageDigest; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; /** * Downloads and unpacks the Linux runtime rootfs into {@code files/linuxfs}. It is opt-in: nothing @@ -52,7 +55,7 @@ public final class LinuxRuntimeInstaller { public final String sha256; public final long size; - Release(String version, String url, String sha256, long size) { + public Release(String version, String url, String sha256, long size) { this.version = version; this.url = url; this.sha256 = sha256; @@ -60,6 +63,23 @@ public final class LinuxRuntimeInstaller { } } + /** + * One install per process. The launcher and the session screen can both start one, and a + * launcher rebuilt mid-install (a pad plugged in, dark mode switched) forgets it had: a second + * install would share the first one's archive and staging directory and wreck both. + */ + private static final class Job { + final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + final CountDownLatch done = new CountDownLatch(1); + volatile String stage = "Starting\u2026"; + volatile int percent = -1; + volatile boolean ok; + } + + private static final Object JOB_LOCK = new Object(); + private static Job running; + private static boolean removing; + private LinuxRuntimeInstaller() {} /** The build currently unpacked, or null when the runtime is not installed. */ @@ -104,6 +124,66 @@ public final class LinuxRuntimeInstaller { * not. An update therefore keeps Steam, the login and the installed games. */ public static boolean install(Context context, Release release, ProgressListener listener) { + Job job; + boolean owner; + synchronized (JOB_LOCK) { + if (removing) return false; + owner = running == null; + if (owner) running = new Job(); + 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); + }); + return job.ok; + } finally { + synchronized (JOB_LOCK) { + running = null; + } + job.done.countDown(); + } + } + + /** True while an install is running in this process, whoever started it. */ + public static boolean isInstalling() { + synchronized (JOB_LOCK) { + return running != null; + } + } + + /** + * Follows the install already running, for a screen that was rebuilt mid-install. Blocks until + * it ends and returns its result, or returns null at once when nothing is installing. + */ + public static Boolean attach(ProgressListener listener) { + Job job; + synchronized (JOB_LOCK) { + job = running; + if (job == null) return null; + if (listener != null) job.listeners.add(listener); + } + return join(job, listener); + } + + private static boolean join(Job job, ProgressListener listener) { + try { + if (listener != null) listener.onProgress(job.stage, job.percent); + job.done.await(); + return job.ok; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } finally { + if (listener != null) job.listeners.remove(listener); + } + } + + private static boolean installOnce(Context context, Release release, ProgressListener listener) { File archive = new File(context.getCacheDir(), "linuxfs.tar.zst"); try { if (listener != null) listener.onProgress("Downloading", 0); @@ -120,7 +200,7 @@ public final class LinuxRuntimeInstaller { } if (listener != null) listener.onProgress("Verifying", -1); - String actual = sha256(archive); + String actual = Hashes.sha256(archive); if (!release.sha256.equalsIgnoreCase(actual)) { Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual); return false; @@ -177,8 +257,20 @@ public final class LinuxRuntimeInstaller { } } - public static void uninstall(Context context) { - FileUtils.delete(LinuxRuntime.rootDir(context)); + /** Removes the runtime; returns false without touching it while an install is running. */ + public static boolean uninstall(Context context) { + synchronized (JOB_LOCK) { + if (running != null || removing) return false; + removing = true; + } + try { + FileUtils.delete(LinuxRuntime.rootDir(context)); + return true; + } finally { + synchronized (JOB_LOCK) { + removing = false; + } + } } /** @@ -193,12 +285,10 @@ public final class LinuxRuntimeInstaller { new BufferedInputStream(new FileInputStream(archive), 1 << 16)); TarArchiveInputStream tar = new TarArchiveInputStream(in)) { TarArchiveEntry entry; - String base = destination.getCanonicalPath() + File.separator; while ((entry = tar.getNextTarEntry()) != null) { - File file = new File(destination, entry.getName()); // Refuse anything that would land outside the runtime directory. - if (!(file.getCanonicalPath() + (entry.isDirectory() ? File.separator : "")).startsWith(base) - && !file.getCanonicalPath().equals(destination.getCanonicalPath())) { + File file = ArchivePaths.inside(destination, entry.getName()); + if (file == null) { Log.w(TAG, "skipping entry outside the rootfs: " + entry.getName()); continue; } @@ -215,7 +305,13 @@ public final class LinuxRuntimeInstaller { } else if (entry.isLink()) { // A hard link to an earlier entry. Link where the filesystem allows it and // fall back to a copy, which costs space but always works. - File target = new File(destination, entry.getLinkName()); + // The target is checked too: a link to "../../shared_prefs/x" would pull a + // file from outside the rootfs into it. + File target = ArchivePaths.inside(destination, entry.getLinkName()); + if (target == null) { + Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName()); + continue; + } file.delete(); try { Files.createLink(file.toPath(), target.toPath()); @@ -244,16 +340,4 @@ public final class LinuxRuntimeInstaller { return false; } } - - public static String sha256(File file) throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - try (InputStream in = new BufferedInputStream(new FileInputStream(file), 1 << 16)) { - byte[] buffer = new byte[1 << 16]; - int read; - while ((read = in.read(buffer)) != -1) digest.update(buffer, 0, read); - } - StringBuilder sb = new StringBuilder(); - for (byte b : digest.digest()) sb.append(String.format("%02x", b)); - return sb.toString(); - } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt new file mode 100644 index 000000000..31a26fd34 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -0,0 +1,100 @@ +package com.droiddeck.launcher.runtime + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import com.droiddeck.launcher.MainActivity +import com.droiddeck.launcher.R + +/** + * Keeps the process in the foreground while the Linux runtime downloads and unpacks. The runtime + * is several hundred megabytes; without a foreground service Android may kill the app the moment + * the user switches away, and the install starts over from the resumable part file. + * + * The install itself is [LinuxRuntimeInstaller.install], which runs one job per process: the + * screen that asked for it joins the same job to show progress, so it does not matter which of the + * two gets there first. + */ +class RuntimeInstallService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val release = intent?.let(::releaseFrom) + startForeground(NOTIFICATION_ID, notification("Starting…", -1)) + if (release == null) { + stopSelf(startId) + return START_NOT_STICKY + } + Thread({ + val manager = getSystemService(NotificationManager::class.java) + var shown = Int.MIN_VALUE + LinuxRuntimeInstaller.install(applicationContext, release) { stage, percent -> + // Every percent at most once: a notification per read would flood the system. + if (percent != shown) { + shown = percent + manager?.notify(NOTIFICATION_ID, notification(stage, percent)) + } + } + @Suppress("DEPRECATION") + stopForeground(true) + stopSelf(startId) + }, "runtime-install").start() + return START_NOT_STICKY + } + + private fun notification(stage: String, percent: Int): Notification { + val manager = getSystemService(NotificationManager::class.java) + manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Runtime install", + NotificationManager.IMPORTANCE_LOW).apply { + description = "Shows while the Linux runtime downloads and installs" + setShowBadge(false) + setSound(null, null) + enableVibration(false) + }) + val open = PendingIntent.getActivity(this, 0, + Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), + PendingIntent.FLAG_IMMUTABLE) + return Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_session) + .setContentTitle("Installing the Linux runtime") + .setContentText(stage) + .setProgress(100, percent.coerceIn(0, 100), percent < 0) + .setContentIntent(open) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .build() + } + + companion object { + private const val CHANNEL_ID = "runtime-install" + private const val NOTIFICATION_ID = 2 + private const val EXTRA_VERSION = "version" + private const val EXTRA_URL = "url" + private const val EXTRA_SHA256 = "sha256" + private const val EXTRA_SIZE = "size" + + /** Starts the install of [release] in the foreground; a running install is joined instead. */ + fun start(context: Context, release: LinuxRuntimeInstaller.Release) { + val intent = Intent(context, RuntimeInstallService::class.java) + .putExtra(EXTRA_VERSION, release.version) + .putExtra(EXTRA_URL, release.url) + .putExtra(EXTRA_SHA256, release.sha256) + .putExtra(EXTRA_SIZE, release.size) + if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) + } + + private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? { + val version = intent.getStringExtra(EXTRA_VERSION) ?: return null + val url = intent.getStringExtra(EXTRA_URL) ?: return null + val sha256 = intent.getStringExtra(EXTRA_SHA256) ?: return null + return LinuxRuntimeInstaller.Release(version, url, sha256, intent.getLongExtra(EXTRA_SIZE, 0L)) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index c256c56a2..da00b1b39 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.session +import com.droiddeck.launcher.core.Hashes import android.content.Context import android.util.Log import com.droiddeck.launcher.core.Downloader @@ -16,7 +17,6 @@ import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream -import java.security.MessageDigest /** * FEX, DXVK and VKD3D-Proton per Proton, for the Linux Steam client. @@ -245,17 +245,8 @@ object ComponentsManager { private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel } - private fun sha256(f: File): String { - val md = MessageDigest.getInstance("SHA-256") - FileInputStream(f).use { input -> - val buf = ByteArray(1 shl 16) - while (true) { val n = input.read(buf); if (n < 0) break; md.update(buf, 0, n) } - } - return md.digest().joinToString("") { "%02x".format(it.toInt() and 255) } - } - private fun fingerprint(dir: File, comp: String): Map = - compFiles(dir, comp).filter { owned(comp, it) }.associateWith { sha256(File(dir, it)) } + compFiles(dir, comp).filter { owned(comp, it) }.associateWith { Hashes.sha256(File(dir, it)) } // ------------------------------------------------------------------ packages @@ -316,7 +307,7 @@ object ComponentsManager { if (rel.endsWith(".so")) tmp.setExecutable(true, false) tmp.setReadable(true, false) if (!tmp.renameTo(dst)) { dst.delete(); check(tmp.renameTo(dst)) { "could not place $rel" } } - hashes[rel] = sha256(dst) + hashes[rel] = Hashes.sha256(dst) } return hashes } finally { @@ -629,6 +620,8 @@ object ComponentsManager { val a = assets.getJSONObject(i) val name = a.optString("name") if (!name.endsWith(".wcp")) continue + // Only packages GitHub has a sha256 for are offered: the download is checked against it. + if (!Hashes.isGithubSha256(a.optString("digest"))) continue items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag) .put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest"))) } @@ -650,13 +643,12 @@ object ComponentsManager { /** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */ fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package { require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" } + require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" } 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 (item.digest.startsWith("sha256:")) { - if (!sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") } - } + if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") } val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it } synchronized(lock) { part.renameTo(dest) } return info.copy(file = dest.name) diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt index fcb920079..2a94134b8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.session +import com.droiddeck.launcher.core.Hashes import android.content.Context import android.os.StatFs import android.util.Log @@ -7,7 +8,6 @@ import com.droiddeck.launcher.core.Downloader import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.File -import java.security.MessageDigest import org.json.JSONArray /** Immediate download and installation of the optional ARM64 Proton builds. */ @@ -21,7 +21,9 @@ object ProtonExtras { class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex) - private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long) + /** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */ + private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long, + val sha256: String? = null) val tools = listOf( Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")), @@ -99,14 +101,24 @@ object ProtonExtras { return "Download was incomplete; the partial file is kept for a resumable retry" } - asset.sha512?.let { checksumUrl -> - onProgress("Verifying download", -1) - val checksumText = Downloader.downloadString(checksumUrl) - val expected = checksumText?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value } - if (expected != null && !expected.equals(sha512(archive), ignoreCase = true)) { - archive.delete() - return "Checksum mismatch; the download was deleted" + // 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) + 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" + expected.equals(Hashes.sha512(archive), ignoreCase = true) } + else -> { + archive.delete() + return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed" + } + } + if (!verified) { + archive.delete() + return "Checksum mismatch; the download was deleted" } if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools" @@ -170,6 +182,7 @@ object ProtonExtras { release.optString("tag_name"), name, archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum, archive.optLong("size", 0L), + Hashes.githubSha256(archive.optString("digest")), ) } null @@ -179,19 +192,6 @@ object ProtonExtras { } } - private fun sha512(file: File): String { - val digest = MessageDigest.getInstance("SHA-512") - file.inputStream().buffered().use { input -> - val buffer = ByteArray(4 * 1024 * 1024) - while (true) { - val read = input.read(buffer) - if (read < 0) break - digest.update(buffer, 0, read) - } - } - return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } - } - private fun requestLines(context: Context): List = FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index b7a759838..730c43787 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -263,150 +263,17 @@ class SessionService : Service() { killStragglers() SessionFiles.stage(this, root) - // One folder per session, claimed by whoever started first - the activity starts the - // compositor before this service runs - so the compositor's log lands in the same place. - val sessionDir = SessionPaths.beginOrCurrent(this) + val sessionDir = openSessionFolder() val sessionLog = File(sessionDir, "session.log") - SessionState.logFile = sessionLog - SessionState.logDirectory = sessionDir - SessionState.sessionId = sessionDir.name - SessionState.eventsFile = File(sessionDir, "events.jsonl") - SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path)) - // Written first, so a session that dies in its first second still says what it ran on. - DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode) - NetworkReport.write(this, File(sessionDir, "network.txt")) - // Everything the app decides from here on - the driver it chose, the audio line, a rival - // client stopped, the exit status - reaches logcat and nowhere a user can get at. Mirror it. - SessionLogCapture.start(File(sessionDir, "app.log")) val size = SessionState.outputSize val guest = ArrayList() - guest.add("/usr/bin/env") - guest.add("-i") - guest.add("HOME=/root") - guest.add("USER=root") - guest.add("PATH=/usr/local/bin:/usr/bin:/bin") - guest.add("TERM=xterm-256color") - guest.add("LANG=C.UTF-8") - // 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) - guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path) - guest.add("XDG_SESSION_TYPE=wayland") - guest.add("WAYLAND_DISPLAY=wayland-0") - guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1") - // Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink. - guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink") - guest.add("GALLIUM_DRIVER=zink") - guest.add("LIBGL_KOPPER_DRI2=true") - LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) } - // An imported glibc Turnip for this mode, when the user chose one: the session script checks - // the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES, - // so the runtime's own driver above stays untouched and is what a bad import falls back to. - // A program from the rail is one of the desktop's emulators, so it draws with the desktop's - // Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver - // build, and with two drivers every emulator compiled its shaders twice - once per way of - // starting it (RPCS3's 6650 interpreter variants on each first boot). - val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode) - val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode) - LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId) - ?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) } - // Turnip's own debug switches, for the runtime's driver and everything on it. The file in - // Downloads holds the value verbatim ("sysmem", "sysmem,deck_emu"); with nothing there, an - // imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both - // its authors advise for those GPUs and what nothing else in the list needs. - tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") } - // Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is - // the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships - // by default for its Zink path; a switch here because on one Fold the menus run at 14 fps. - if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy") - // The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling - // thread, no GL error checks, and the client run as SteamOS runs it (the script reads - // BL_STEAMDECK; it is the one that builds the command line). - if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") - if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") - if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0")) // The desktop's Steam launchers start the client there (bannerlator-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 - 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")) - SessionPrefs.writeForceFullscreenFlag(this) - } - // Proton's own gate for its xalia helper (its `proton` script reads this, and sets - // XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device - // whose seccomp answers its syscalls normally there is no reason to take it away. - if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0") - // The FEXCore preset for the x86 games the client launches: its FEX_* variables go in - // here, before the script, so every game process inherits them from the client. The - // default preset sets nothing, which is what every session ran on before. - if (steamHere) { - val preset = SessionPrefs.fexPreset(this) - val vars = com.droiddeck.launcher.core.FexPreset.env(preset) - vars.forEach { guest.add(it) } - if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}") - } - // Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env - // holds KEY=VALUE lines that go into the session's environment as written, after ours, so a - // line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's, - // the client's - whatever the experiment needs, without a build per attempt. - extraEnv().forEach { guest.add(it) } - // Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is - // on, even naming every core: it exists to undo the pin Steam applies to its own interface - // renderer, and the scheduler's default is exactly what that pin takes away. A game's is - // sent only when it is a real restriction - a game has no pin of its own to undo. - if (steamHere && SessionPrefs.clientCpusOverride(this)) { - guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) - } - // The game cores also pin the desktop and a program from the rail; without a choice the - // session script picks every core but the slowest cluster for those (program_cores). - CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this)) - .takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") } + addClientEnvironment(guest, runtimeDir, steamHere) - // PulseAudio always: the client is a native Linux program and has no other way to make a - // sound - its menus, its music and its voice chat all go through here. DirectAudio is not - // an alternative to it on this path: it replaces the audio driver INSIDE Wine, so it - // changes what games do and leaves the client alone. The microphone is its own opt-in on - // top, and the helper only opens an input stream when asked - so a user who wants game - // sound but no recording gets exactly that, and Android's recording indicator stays off. - val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this) - val wantsMic = SessionPrefs.micEnabled(this) && - checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - // Both paths sit under the app's files directory, which the session binds at its own path, - // so the same string is valid on both sides and nothing has to be translated. - val audioDir = File(filesDir, "directaudio").apply { mkdirs() } - val relaySocket = File(audioDir, "relay.sock") - val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null - - val audioLog = File(sessionDir, "audio.log") - val pulse = PulseAudioComponent(this, micFifo?.absolutePath) - // With DirectAudio on, the client's own sound goes through the relay too: the daemon - // fills the relay's ring and the relay, outside proot, drives the device. - // The client's own sound: the classic AAudio sink unless the user chose the relay. - val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this) - if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath) - pulse.setLogFile(audioLog) - pulse.attach(this) - guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath) - components.add(pulse) - if (wantsDirectAudio || wantsMic || clientDirectAudio) { - // After the daemon in the list, so it can wait for the pipe the daemon makes. - val relay = DirectAudioRelayComponent(relaySocket, micFifo) - relay.setLogFile(audioLog) - relay.attach(this) - components.add(relay) - } - if (wantsDirectAudio) { - // Read by the Proton wrappers, which point Wine at the driver and name it in the - // prefix. Absent, they take an early return and the game uses Proton's own audio - so - // this variable is the whole of the selection. - guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR) - guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath) - } - Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "") - + (if (wantsMic) " + microphone" else "") + - (if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else "")) + val pulse = startAudio(guest, sessionDir) guest.add("BL_WIDTH=" + size.first) guest.add("BL_HEIGHT=" + size.second) @@ -424,28 +291,7 @@ class SessionService : Service() { val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() } val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() - if (controllersOn) { - FakeInputWriter.prepareRingSlots(fakeInputDir, 4) - guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path) - val rings = FakeInputWriter.getRingEnv(fakeInputDir) - if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings") - // SDL and Steam key their mapping database on bus+vendor+product: only a known - // identity gets the standard layout without the user configuring the pad by hand. - guest.add("FAKE_EVDEV_IDENTITY=xbox360") - guest.add("FAKE_EVDEV_VIBRATION=1") - // Steam Input's virtual-gamepad identity is for games the client starts, which are - // meant to see that pad. Everywhere else (the desktop, a program from the rail) it - // hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so - // every SDL emulator came up with no controller. There it is a plain Xbox 360 pad. - if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1") - guest.add("SDL_JOYSTICK_DISABLE_UDEV=1") - guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1") - guest.add("SDL_JOYSTICK_HIDAPI=0") - if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) { - guest.add("FAKE_EVDEV_LOG=1") - } - SessionState.fakeInputDir = fakeInputDir - } + if (controllersOn) addControllerEnvironment(guest, fakeInputDir) // The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render // node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the // default is pixman (software, shm), where a Vulkan program cannot draw at all; those run @@ -506,63 +352,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 = ArrayList() - if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input") - // The client's battery readout (the Quick Access Menu, the top bar) reads - // /sys/class/power_supply/BAT/..., a laptop's or a Deck's naming; Android's supply is - // called "battery" and its files differ, so the client sees no battery at all. A directory - // of our own, written from Android's battery API every few seconds, is bound over it. - val battery = BatteryComponent(File(filesDir, "session/sys/power_supply")) - battery.attach(this) - components.add(battery) - binds.add(battery.dir.path + ":/sys/class/power_supply") - // Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this - // listener, which drives the phone's vibrator (see RumbleComponent). - if (controllersOn) components.add(RumbleComponent().also { it.attach(this) }) - // Where the device's files appear inside the session. Internal storage is bound at its own - // path already, and every program's file dialog opens at home and lists "Computer" from - // /proc/mounts, where a proot bind never shows - so a user saw only the runtime's own - // tree and could not find the phone at all. The same storage is placed under home as - // well, and the ROMs folder chosen on the main screen beside it; a bind rather than a - // link, so a folder on an SD card works the same. - val home = File(LinuxRuntime.rootDir(this), "root") - File(home, "Storage").mkdirs() - binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage") - // A second Steam library: the storage chosen in the Steam cog, at the path the runtime's - // bannerlator-steam-library registers with the client. Nothing bound = the script removes - // the entry, so the client never offers a place that is not there. - val library = GameStorage.effective(this) - if (library != null) { - val problem = GameStorage.prepare(library.path) - if (problem == null) { - File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs() - binds.add("${library.path}:/mnt/bannerlator-sd") - Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")") - } else { - Log.w(TAG, "game storage: $problem; internal only this session") - } - } else { - Log.i(TAG, "game storage: internal only") - } - // The user's own games folder (the Steam cog's "Added games"), bound at a fixed place so - // the shortcuts the app writes point somewhere whatever storage the folder is on. - for (root in com.droiddeck.launcher.frontend.AddedGames.roots(this)) { - if (root.host.isDirectory && root.host.canRead()) { - File(LinuxRuntime.rootDir(this), root.guest.removePrefix("/")).mkdirs() - binds.add(root.host.path + ":" + root.guest) - Log.i(TAG, "added games: ${root.host} -> ${root.guest}") - } else { - Log.w(TAG, "added games: ${root.host} is not a readable folder this session") - } - } - val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) } - if (roms != null && roms.isDirectory && roms.canRead()) { - File(home, "ROMs").mkdirs() - binds.add(roms.path + ":/root/ROMs") - Log.i(TAG, "roms: $roms -> /root/ROMs") - } else if (roms != null) { - Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session") - } + val binds = sessionBinds(controllersOn, fakeInputDir) val command = LinuxRuntime.command( this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest, @@ -644,6 +434,243 @@ class SessionService : Service() { } } + /** The session's own log folder, opened and filled with what is known before anything starts. */ + private fun openSessionFolder(): File { + // One folder per session, claimed by whoever started first - the activity starts the + // compositor before this service runs - so the compositor's log lands in the same place. + val sessionDir = SessionPaths.beginOrCurrent(this) + val sessionLog = File(sessionDir, "session.log") + SessionState.logFile = sessionLog + SessionState.logDirectory = sessionDir + SessionState.sessionId = sessionDir.name + SessionState.eventsFile = File(sessionDir, "events.jsonl") + SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path)) + // Written first, so a session that dies in its first second still says what it ran on. + DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode) + NetworkReport.write(this, File(sessionDir, "network.txt")) + // Everything the app decides from here on - the driver it chose, the audio line, a rival + // client stopped, the exit status - reaches logcat and nowhere a user can get at. Mirror it. + SessionLogCapture.start(File(sessionDir, "app.log")) + return sessionDir + } + + /** The guest's base environment: paths, the display, the GL/Vulkan stack and the client's switches. */ + private fun addClientEnvironment(guest: MutableList, runtimeDir: File, steamHere: Boolean) { + guest.add("/usr/bin/env") + guest.add("-i") + guest.add("HOME=/root") + guest.add("USER=root") + guest.add("PATH=/usr/local/bin:/usr/bin:/bin") + guest.add("TERM=xterm-256color") + guest.add("LANG=C.UTF-8") + // 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) + guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path) + guest.add("XDG_SESSION_TYPE=wayland") + guest.add("WAYLAND_DISPLAY=wayland-0") + guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1") + // Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink. + guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink") + guest.add("GALLIUM_DRIVER=zink") + guest.add("LIBGL_KOPPER_DRI2=true") + LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) } + // An imported glibc Turnip for this mode, when the user chose one: the session script checks + // the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES, + // so the runtime's own driver above stays untouched and is what a bad import falls back to. + // A program from the rail is one of the desktop's emulators, so it draws with the desktop's + // Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver + // build, and with two drivers every emulator compiled its shaders twice - once per way of + // starting it (RPCS3's 6650 interpreter variants on each first boot). + val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode) + val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode) + LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId) + ?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) } + // Turnip's own debug switches, for the runtime's driver and everything on it. The file in + // Downloads holds the value verbatim ("sysmem", "sysmem,deck_emu"); with nothing there, an + // imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both + // its authors advise for those GPUs and what nothing else in the list needs. + tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") } + // Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is + // the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships + // by default for its Zink path; a switch here because on one Fold the menus run at 14 fps. + if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy") + // The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling + // thread, no GL error checks, and the client run as SteamOS runs it (the script reads + // BL_STEAMDECK; it is the one that builds the command line). + if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") + if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") + if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(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")) + SessionPrefs.writeForceFullscreenFlag(this) + } + // Proton's own gate for its xalia helper (its `proton` script reads this, and sets + // XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device + // whose seccomp answers its syscalls normally there is no reason to take it away. + if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0") + // The FEXCore preset for the x86 games the client launches: its FEX_* variables go in + // here, before the script, so every game process inherits them from the client. The + // default preset sets nothing, which is what every session ran on before. + if (steamHere) { + val preset = SessionPrefs.fexPreset(this) + val vars = com.droiddeck.launcher.core.FexPreset.env(preset) + vars.forEach { guest.add(it) } + if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}") + } + // Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env + // holds KEY=VALUE lines that go into the session's environment as written, after ours, so a + // line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's, + // the client's - whatever the experiment needs, without a build per attempt. + extraEnv().forEach { guest.add(it) } + // Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is + // on, even naming every core: it exists to undo the pin Steam applies to its own interface + // renderer, and the scheduler's default is exactly what that pin takes away. A game's is + // sent only when it is a real restriction - a game has no pin of its own to undo. + if (steamHere && SessionPrefs.clientCpusOverride(this)) { + guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) + } + // The game cores also pin the desktop and a program from the rail; without a choice the + // session script picks every core but the slowest cluster for those (program_cores). + CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this)) + .takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") } + } + + /** PulseAudio, and the DirectAudio relay when a path needs it; returns the daemon for suspend and resume. */ + private fun startAudio(guest: MutableList, sessionDir: File): PulseAudioComponent { + // PulseAudio always: the client is a native Linux program and has no other way to make a + // sound - its menus, its music and its voice chat all go through here. DirectAudio is not + // an alternative to it on this path: it replaces the audio driver INSIDE Wine, so it + // changes what games do and leaves the client alone. The microphone is its own opt-in on + // top, and the helper only opens an input stream when asked - so a user who wants game + // sound but no recording gets exactly that, and Android's recording indicator stays off. + val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this) + val wantsMic = SessionPrefs.micEnabled(this) && + checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + // Both paths sit under the app's files directory, which the session binds at its own path, + // so the same string is valid on both sides and nothing has to be translated. + val audioDir = File(filesDir, "directaudio").apply { mkdirs() } + val relaySocket = File(audioDir, "relay.sock") + val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null + + val audioLog = File(sessionDir, "audio.log") + val pulse = PulseAudioComponent(this, micFifo?.absolutePath) + // With DirectAudio on, the client's own sound goes through the relay too: the daemon + // fills the relay's ring and the relay, outside proot, drives the device. + // The client's own sound: the classic AAudio sink unless the user chose the relay. + val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this) + if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath) + pulse.setLogFile(audioLog) + pulse.attach(this) + guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath) + components.add(pulse) + if (wantsDirectAudio || wantsMic || clientDirectAudio) { + // After the daemon in the list, so it can wait for the pipe the daemon makes. + val relay = DirectAudioRelayComponent(relaySocket, micFifo) + relay.setLogFile(audioLog) + relay.attach(this) + components.add(relay) + } + if (wantsDirectAudio) { + // Read by the Proton wrappers, which point Wine at the driver and name it in the + // prefix. Absent, they take an early return and the game uses Proton's own audio - so + // this variable is the whole of the selection. + guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR) + guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath) + } + Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "") + + (if (wantsMic) " + microphone" else "") + + (if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else "")) + return pulse + } + + /** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */ + private fun addControllerEnvironment(guest: MutableList, fakeInputDir: File) { + FakeInputWriter.prepareRingSlots(fakeInputDir, 4) + guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path) + val rings = FakeInputWriter.getRingEnv(fakeInputDir) + if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings") + // SDL and Steam key their mapping database on bus+vendor+product: only a known + // identity gets the standard layout without the user configuring the pad by hand. + guest.add("FAKE_EVDEV_IDENTITY=xbox360") + guest.add("FAKE_EVDEV_VIBRATION=1") + // Steam Input's virtual-gamepad identity is for games the client starts, which are + // meant to see that pad. Everywhere else (the desktop, a program from the rail) it + // hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so + // every SDL emulator came up with no controller. There it is a plain Xbox 360 pad. + if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1") + guest.add("SDL_JOYSTICK_DISABLE_UDEV=1") + guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1") + guest.add("SDL_JOYSTICK_HIDAPI=0") + if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) { + guest.add("FAKE_EVDEV_LOG=1") + } + SessionState.fakeInputDir = fakeInputDir + } + + /** What proot binds into the guest: the pads, the battery, storage, the game library, added games and ROMs. */ + private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList { + val binds = ArrayList() + if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input") + // The client's battery readout (the Quick Access Menu, the top bar) reads + // /sys/class/power_supply/BAT/..., a laptop's or a Deck's naming; Android's supply is + // called "battery" and its files differ, so the client sees no battery at all. A directory + // of our own, written from Android's battery API every few seconds, is bound over it. + val battery = BatteryComponent(File(filesDir, "session/sys/power_supply")) + battery.attach(this) + components.add(battery) + binds.add(battery.dir.path + ":/sys/class/power_supply") + // Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this + // listener, which drives the phone's vibrator (see RumbleComponent). + if (controllersOn) components.add(RumbleComponent().also { it.attach(this) }) + // Where the device's files appear inside the session. Internal storage is bound at its own + // path already, and every program's file dialog opens at home and lists "Computer" from + // /proc/mounts, where a proot bind never shows - so a user saw only the runtime's own + // tree and could not find the phone at all. The same storage is placed under home as + // well, and the ROMs folder chosen on the main screen beside it; a bind rather than a + // link, so a folder on an SD card works the same. + val home = File(LinuxRuntime.rootDir(this), "root") + File(home, "Storage").mkdirs() + binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage") + // A second Steam library: the storage chosen in the Steam cog, at the path the runtime's + // bannerlator-steam-library registers with the client. Nothing bound = the script removes + // the entry, so the client never offers a place that is not there. + val library = GameStorage.effective(this) + if (library != null) { + val problem = GameStorage.prepare(library.path) + if (problem == null) { + File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs() + binds.add("${library.path}:/mnt/bannerlator-sd") + Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")") + } else { + Log.w(TAG, "game storage: $problem; internal only this session") + } + } else { + Log.i(TAG, "game storage: internal only") + } + // The user's own games folder (the Steam cog's "Added games"), bound at a fixed place so + // the shortcuts the app writes point somewhere whatever storage the folder is on. + for (root in com.droiddeck.launcher.frontend.AddedGames.roots(this)) { + if (root.host.isDirectory && root.host.canRead()) { + File(LinuxRuntime.rootDir(this), root.guest.removePrefix("/")).mkdirs() + binds.add(root.host.path + ":" + root.guest) + Log.i(TAG, "added games: ${root.host} -> ${root.guest}") + } else { + Log.w(TAG, "added games: ${root.host} is not a readable folder this session") + } + } + val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) } + if (roms != null && roms.isDirectory && roms.canRead()) { + File(home, "ROMs").mkdirs() + binds.add(roms.path + ":/root/ROMs") + Log.i(TAG, "roms: $roms -> /root/ROMs") + } else if (roms != null) { + Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session") + } + return binds + } + private fun teardown(prootPid: Int, expectedStartTime: Long? = null) { fun stillSameProcess(): Boolean = expectedStartTime == null || readStat(prootPid)?.second == expectedStartTime if (!stillSameProcess()) return diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt new file mode 100644 index 000000000..a49df3698 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -0,0 +1,186 @@ +package com.droiddeck.launcher.ui + +import android.graphics.Bitmap +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.runtime.key +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +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 androidx.compose.ui.zIndex +import coil.compose.AsyncImage +import java.io.File + +// Cover art: posters, the art grid and its tiles. + +internal class Tile( + val title: String, val sub: String?, val art: File?, val key: String, + val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null, + val showFooter: Boolean = true, + val onClick: () -> Unit, +) + +@Composable +internal fun Poster(art: File?, name: String, modifier: Modifier) { + val colors = MaterialTheme.colorScheme + val t = remember { Animatable(0f) } + LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) } + Box( + modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f) + .graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 16.dp.toPx(); rotationZ = (1f - t.value) * 2f; scaleX = 0.94f + 0.06f * t.value; scaleY = scaleX; shadowElevation = 22.dp.toPx(); shape = Shape12; clip = false } + .clip(Shape12).background(artBrush(hueOf(name))), + ) { + if (art != null) CoverImage(art, Modifier.fillMaxSize()) + else Text(name, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun ArtGrid(tiles: List, wide: Boolean = false) { + val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) } + // Big enough to recognise a game by its art and read its name under it; icon tiles are squares. + val minSize = if (square) 76.dp else if (wide) 120.dp else 96.dp + val gap = 12.dp + // Laid out whole, not lazily: the pad's focus search only finds tiles that exist, and a lazy + // grid composes only the rows on screen, so a press towards the next row bounced back among + // the visible tiles. A few hundred tiles lay out fine; the page's scroll follows the focused one. + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val avail = maxWidth - 8.dp + val cols = ((avail + gap) / (minSize + gap)).toInt().coerceAtLeast(1) + val tileWidth = (avail - gap * (cols - 1)) / cols + Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 14.dp, start = 4.dp, end = 4.dp)) { + for (row in tiles.chunked(cols)) { + Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth().padding(bottom = gap)) { + for (t in row) key(t.key) { + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier) + Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) } + } + } + } + } + } +} + +@Composable +private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableInteractionSource, hot: Boolean, track: Modifier) { + val colors = MaterialTheme.colorScheme + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.97f else if (hot) 1.04f else 1f, Motion.sp(0.55f, Spring.StiffnessMedium), label = "tileScale") + val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift") + val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev") + val pal = LocalPalette.current + val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing") + Column( + modifier = track + .graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) } + .clip(Shape12) + .background(colors.surface) + .border(2.5.dp, ring, Shape12) + .alpha(if (t.dim && !hot) 0.55f else 1f) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick), + ) { + Box(modifier = Modifier.fillMaxWidth().shine(hot)) { + Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap) + } + if (t.showFooter) { + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) { + Text(t.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (t.sub != null) Text(t.sub, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +/** + * Art for a portrait (2:3) tile. Box art fills it; wide art - a PS3 disc's ICON0, a game's header - + * is shown whole over a blurred, darkened copy of itself instead of losing its sides to the crop. + */ +@Composable +private fun CoverImage(art: File, modifier: Modifier) { + var wideArt by remember(art) { mutableStateOf(false) } + Box(modifier) { + if (wideArt) { + AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.2f; scaleY = 1.2f }.blur(14.dp), + ) + Spacer(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f))) + } + AsyncImage( + model = art, contentDescription = null, + contentScale = if (wideArt) ContentScale.Fit else ContentScale.Crop, + onSuccess = { state -> + val size = state.painter.intrinsicSize + if (size.width > size.height * 1.1f) wideArt = true + }, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) { + val colors = MaterialTheme.colorScheme + val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f + Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) { + when { + art != null -> if (wide) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + else CoverImage(art, Modifier.fillMaxSize()) + iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp)) + iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp)) + else -> { + Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.55f)))) + Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(6.dp), maxLines = 3, overflow = TextOverflow.Ellipsis) + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt new file mode 100644 index 000000000..b1f0d8c17 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -0,0 +1,407 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.CompositionLocalProvider +import android.os.Build +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.runtime.key +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DesktopWindows +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +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 coil.compose.AsyncImage +import com.droiddeck.launcher.HomeApp +import com.droiddeck.launcher.frontend.Library +import java.io.File + +// The front end's right-hand pane and the page it shows: backdrop, desktop card and emulators. + +@Composable +internal fun Pane( + s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier, + onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit, + onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit, +) { + BoxWithConstraints(modifier = modifier) { + CompositionLocalProvider(LocalNarrowPane provides (maxWidth < NarrowPaneWidth)) { + val backdropArt: File? = when { + s.pageKey != null && page != null -> null + selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }?.art + selected == "games" -> s.steamGames.maxByOrNull { it.lastPlayed }?.art + selected.startsWith("rom:") -> romFor(s, selected)?.second?.art + else -> null + } + Backdrop(backdropArt) + AnimatedContent( + // Picking another game changes the detail beside the list, not the whole page. + targetState = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected, + transitionSpec = { + (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) + .togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 }) + .apply { targetContentZIndex = 1f } + }, + label = "pane", + ) { key -> + if (page != null && key == s.pageKey) page() + else Content(s, if (key == "games") selected else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) + } + } + } +} + +@Composable +private fun Backdrop(art: File?) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Crossfade(targetState = art, animationSpec = Motion.tw(700), label = "backdrop") { a -> + // A game's own art, blurred, where it has some; otherwise the plain ground with one quiet + // glow of the theme's signal colour - a colour per title hash read as noise. + if (a != null && Build.VERSION.SDK_INT >= 31) { + Box(modifier = Modifier.fillMaxSize().alpha(0.26f).blur(70.dp)) { + AsyncImage(model = a, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.5f; scaleY = 1.5f }) + } + } else { + Box(modifier = Modifier.fillMaxSize().background(Brush.radialGradient(listOf(pal.signal.copy(alpha = 0.07f), Color.Transparent), center = Offset.Zero, radius = 1400f))) + } + } + Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, colors.background.copy(alpha = 0.35f))))) +} + +private fun romFor(s: FrontEndState, selected: String): Pair? { + val parts = selected.split(":") + val e = s.emulators.firstOrNull { it.id == parts.getOrNull(1) } ?: return null + val g = e.games.getOrNull(parts.getOrNull(2)?.toIntOrNull() ?: -1) ?: return null + return e to g +} + +@Composable +private fun Content( + s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier, + onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit, + onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp + val narrow = LocalNarrowPane.current + val padH = if (narrow) 16.dp else 22.dp + val padV = if (narrow) 12.dp else 18.dp + // Setup scrolls inside itself, under its tabs. + if (selected == "setup") { + Column(modifier = modifier.padding(horizontal = padH, vertical = padV)) { + SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb) + } + return + } + // Steam is a full-bleed wall of the library with Play over it. + if (selected == "steam") { + SteamHome(s, a, modifier) + return + } + // The Games tab lays out its own list and detail. + if (selected == "games" || selected.startsWith("app:")) { + GamesPage(s, a, selected, onSelect, modifier) + return + } + // Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid + // instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view. + Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) { + when { + selected == "android-apps" && s.isHomeApp -> { + Rise(0) { PageHeader("Android apps") { Chip("${s.androidApps.size} apps", ok = false) } } + if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") } + else Rise(3, Modifier.fillMaxWidth()) { + ArtGrid(s.androidApps.map { app -> + Tile( + app.label, + null, + null, + "android:${app.packageName}", + onClick = { onAndroidAppClick(app) }, + iconBitmap = app.icon, + ) + }) + } + } + selected == "desktop" -> { + val installed = s.emulators.filter { it.installed } + val available = s.emulators.filter { !it.installed } + Rise(0) { + PageHeader("Desktop") { + if (s.desktopInstalled) Chip("● Desktop installed", ok = true) else Chip("Installs on first open", ok = false) + } + } + Rise(2) { DesktopCard(s, a) } + if (installed.isNotEmpty()) { + Rise(3) { SectionTitle("Emulators", "${installed.size} installed") } + Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) } + } + if (available.isNotEmpty()) { + Rise(5) { SectionTitle("Available to install", available.size.toString()) } + Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) } + } + } + selected.startsWith("emu:") -> { + val e = s.emulators.firstOrNull { "emu:${it.id}" == selected } + if (e == null) Note("Not installed.") else { + val pkgId = Library.packageId(e.id) + val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } } + Rise(0) { + BackLink("Desktop") { onSelect("desktop") } + } + Rise(1) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(top = 12.dp, bottom = 12.dp), + ) { + Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp)) + Column { + Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text( + e.system.replaceFirstChar { it.uppercase() } + if (e.installed) "" else " · not installed", + fontSize = 14.sp, color = colors.onSurfaceVariant, + ) + } + } + } + if (e.installed) { + Rise(3) { + Actions { + PrimaryButton("Open ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onEmulator(e) } + SecondaryButton("ROMs folder", onClick = a.onRoms) + if (pkg != null) SecondaryButton( + if (pkg.kind == "appimage") "Remove" else "Forget", + enabled = s.packageBusyId == null && !s.sessionRunning, + ) { a.onRemovePackage(pkg.id) } + } + } + Rise(4) { SectionTitle("Games", e.games.size.toString()) } + if (e.games.isEmpty()) Rise(5) { + Note( + if (s.romsDir == null) "Choose a ROMs folder." + else if (e.id == "retroarch") "Browse to /root/ROMs in RetroArch." + else "Add ${e.system} games to ROMs/${e.system.substringBefore(' ')}.", + ) + } + 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 }) + } + } else { + if (pkg != null) Rise(2) { + Actions { + PrimaryButton( + if (s.packageBusyId == pkg.id) "Installing…" else "Install ${e.name}", + enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning, + ) { a.onInstallPackage(pkg.id) } + if (s.sessionRunning) ActionChip("Stop session to install", ok = false) + else if (!s.ready) ActionChip("Runtime required", ok = false) + } + } + Rise(3) { + Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { + Note(when { + s.packageCatalogLoading -> "Loading install details…" + pkg == null -> "Install details are unavailable right now. Try again when the package catalog is reachable." + !s.ready -> "Install the Linux runtime from Setup before installing desktop apps." + s.sessionRunning -> "Stop the active session before installing desktop apps." + pkg.notes.isNotBlank() -> pkg.notes + else -> "Install ${e.name} into the Linux desktop runtime." + }) + } + } + if (s.packageBusyId == pkg?.id) Rise(4) { + val stage = s.packageStage + Text( + if (stage != null && s.packagePercent >= 0) "$stage · ${s.packagePercent}%" else stage ?: "Starting…", + fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp), + ) + if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) + else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) + } + if (pkg?.kind == "tar") Rise(5) { Note("Forgetting this package hides it from Desktop; its files remain in the Linux runtime.") } + } + } + } + selected.startsWith("rom:") -> { + val pair = romFor(s, selected) + if (pair == null) Note("That game is gone from the ROMs folder.") else { + val (e, g) = pair + Rise(0) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + BackLink(e.name) { onSelect("emu:${e.id}") } + Eyebrow("Desktop · ${e.system}") + } + } + Rise(1) { Title(g.name) } + Rise(2) { + Row { + Column(modifier = Modifier.weight(1f)) { + Text(g.guestPath, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(colors.surface).padding(horizontal = 10.dp, vertical = 6.dp)) + Spacer(Modifier.height(14.dp)) + Actions { + Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp)) + PrimaryButton("Launch in ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onRom(g) } + ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false) + } + } + if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth)) + } + } + val others = e.games.filter { it !== g } + if (others.isNotEmpty()) { + Rise(3) { SectionTitle("Also in ${e.name}", null) } + 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") } + }, wide = others.none { it.art != null }) + } + } + } + } + else -> Note("Select an item.") + } + } +} + +/** The Linux desktop itself, above the emulators that run in it. */ +@Composable +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. + PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop) + Cog(onClick = a.onDesktopSettings) + } + } + 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("Linux desktop", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + Text("LXQt, Firefox and your emulators", fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (!narrow) actions() + } + if (narrow) actions() + } +} + +/** Emulators as wide tiles - three across, or a list on a narrow page. */ +@Composable +private fun EmulatorGrid(emulators: List, first: Boolean, onSelect: (String) -> 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)) { + emulators.chunked(columns).forEachIndexed { r, row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + row.forEachIndexed { i, e -> + key(e.id) { + EmulatorTile(e, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onSelect("emu:${e.id}") } + } + } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +/** + * One emulator: its icon, system and games. One not yet installed says so in words and keeps full + * contrast - dimming it read as disabled rather than one press from installing. + */ +@Composable +private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "emuScale") + val system = e.system.replaceFirstChar { it.uppercase() } + val detail = if (e.installed && e.id != "retroarch") "$system · ${e.games.size} game${if (e.games.size == 1) "" else "s"}" else system + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.paneItem("tile:emu:${e.id}").then(if (isFirst) Modifier.firstTile() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(Shape14) + .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (!e.installed) Text( + "Install", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, + modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt new file mode 100644 index 000000000..2dcb0946f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -0,0 +1,240 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.key +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +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 coil.compose.AsyncImage +import com.droiddeck.launcher.frontend.Library + +// The Games page: the list, the hero for the selected game and its labels. + +/** + * The Games tab: installed Steam games down the left, most recently played first, and the one + * picked beside them - its banner, Launch and the launch settings. One game skips the list. + */ +@Composable +internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) { + val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } } + val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() ?: return + val host = rememberMenuHost() + val narrow = LocalNarrowPane.current + if (games.size == 1) { + Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { + Rise(0) { + Row(verticalAlignment = Alignment.Bottom) { + GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) { + GameHeroCopy(current, if (narrow) 28.sp else 38.sp) + PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) } + } + if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp)) + } + } + Rise(1) { SectionTitle("Launch settings", null) } + Rise(2) { LaunchSettings(s, a, host) } + } + return + } + val pal = LocalPalette.current + Row(modifier = modifier) { + GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) }, + modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight()) + Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) + Column( + modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()) + .padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp), + ) { + GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) { + GameHeroCopy(current, if (narrow) 24.sp else 32.sp) + PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) } + } + SectionTitle("Launch settings", null) + LaunchSettings(s, a, host) + } + } +} + +/** When it was last played (or where it is, if never) over its name, then room for Launch. */ +@Composable +private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) { + Text( + (lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(), + fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + Text( + g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun GameList( + games: List, current: Library.SteamGame, + onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, modifier: Modifier, +) { + val colors = MaterialTheme.colorScheme + // Laid out whole, as the art grid is: the pad's focus search only finds rows that exist. + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp), + ) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) { + Text("Games", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp)) + } + for (g in games) key(g.appId) { + GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) }) + } + } +} + +/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */ +@Composable +private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val focused by src.collectIsFocusedAsState() + val hovered by src.collectIsHoveredAsState() + LaunchedEffect(focused) { if (focused && !selected) onSelect() } + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}") + .clip(Shape12) + .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) + .border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect) + .controllerConfirm(onClick = onLaunch) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Box(Modifier.width(30.dp).height(45.dp).clip(RoundedCornerShape(5.dp)).background(artBrush(hueOf(g.name)))) { + if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + } + Column(modifier = Modifier.weight(1f)) { + Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + lastPlayedText(g.lastPlayed)?.removePrefix("Last played ")?.replaceFirstChar { it.uppercase() } ?: "Never played", + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** A page's title, with room at its right for a status chip. */ +@Composable +internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Text( + title, fontSize = if (LocalNarrowPane.current) 22.sp else 26.sp, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, maxLines = 1, + ) + trailing() + } +} + +/** Whether Steam can start, said where Play is rather than only in Setup. */ +@Composable +internal fun RuntimeChip(s: FrontEndState) = when { + s.busy -> Chip(if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage, ok = false) + !s.ready -> Chip("Runtime installs on first Play", ok = false) + s.available != null && s.available != s.installed -> Chip("Runtime update available", ok = false) + else -> Chip("● Runtime ready", ok = true) +} + +/** "Last played 3 days ago" from Steam's unix seconds; null for a game never played. */ +private fun lastPlayedText(lastPlayed: Long): String? { + if (lastPlayed <= 0L) return null + val span = android.text.format.DateUtils.getRelativeTimeSpanString( + lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS, + ).toString() + return "Last played " + span.replaceFirstChar { it.lowercase() } +} + +private fun libraryLabel(library: String): String = when (library) { + "internal" -> "Internal storage" + "added" -> "Added game" + else -> library +} + +/** + * A game's wide banner: Steam's hero art where the client cached one, else its capsule blurred to + * fill the width. The copy sits bottom-left over a scrim of the ground colour so it always reads; + * the art takes the banner's size, so a banner given a minimum height grows to fit its copy. + */ +@Composable +private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) { + val colors = MaterialTheme.colorScheme + Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) { + val image = g.hero ?: g.art + if (image != null) AsyncImage( + model = image, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + .then(if (g.hero == null) Modifier.blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f } else Modifier), + ) + Spacer( + Modifier.matchParentSize().background( + Brush.horizontalGradient( + 0f to colors.background.copy(alpha = 0.92f), + 0.55f to colors.background.copy(alpha = 0.6f), + 1f to Color.Transparent, + ), + ), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp), + content = content, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt new file mode 100644 index 000000000..64ca8571f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -0,0 +1,250 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.focus.focusRequester +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.key +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.DesktopWindows +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.VideoLibrary +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.PhantomProcessLimit + +// The front end's side rail: the page list, the resume entry and the controller hints under it. + +/** What the pad's face buttons do here, along the bottom edge as on a console. */ +@Composable +internal fun ControllerHints() { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Column(modifier = Modifier.fillMaxWidth()) { + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.End), + modifier = Modifier.fillMaxWidth().height(34.dp).background(colors.surface).padding(horizontal = 20.dp), + ) { + HintGlyph("A", "Select") + HintGlyph("B", "Back") + } + } +} + +@Composable +private fun HintGlyph(button: String, action: String) { + val colors = MaterialTheme.colorScheme + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) { + Text(button, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background) + } + Text(action, fontSize = 13.sp, color = colors.onSurfaceVariant) + } +} + +internal val AttentionAmber = Color(0xFFFFB547) + +/** + * The launcher's sections, always on screen down the left edge: the app is landscape-only, so the + * width a hamburger menu would save is better spent keeping every section one press away. + */ +@Composable +internal fun SideRail( + s: FrontEndState, selected: String, + onSelect: (String) -> Unit, onFocusSelect: (String) -> Unit, a: FrontEndActions, modifier: Modifier, +) { + val colors = MaterialTheme.colorScheme + val compact = LocalConfiguration.current.screenHeightDp < 420 + // Under 600dp wide (4:3 and square screens) the labels would cost a fifth of the width. + val iconOnly = isNarrowScreen() + val setupNeedsAttention = !s.ready || (s.available != null && s.available != s.installed) || + PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp), + ) { + Image(painterResource(R.drawable.logo), contentDescription = "DroidDeck", modifier = Modifier.size(if (compact) 28.dp else 34.dp)) + Spacer(Modifier.height(if (compact) 6.dp else 12.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } + RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, onFocus = { onFocusSelect("steam") }) { onSelect("steam") } + // Games appears once there is one: an empty list is no place to land. + if (s.steamGames.isNotEmpty()) RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") } + RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } + RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") } + RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") } + } + AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 8.dp)) { + val barWidth = if (iconOnly) 44.dp else 60.dp + if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.width(barWidth)) + else LinearProgressIndicator(modifier = Modifier.width(barWidth)) + Text(if (s.percent >= 0) "${s.percent}%" else if (iconOnly) "…" else "Working", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp)) + } + } + var lastRunning by remember { mutableStateOf("") } + if (s.running != null) lastRunning = s.running + AnimatedVisibility( + s.running != null, + enter = expandVertically(Motion.sp(0.75f)) + fadeIn(Motion.tw(300)), + exit = shrinkVertically(Motion.tw(220)) + fadeOut(Motion.tw(180)), + ) { ResumeRailItem(lastRunning, compact, iconOnly, a.onResume) } + } +} + +@Composable +private fun RailItem( + label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean, + badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val frontFocus = LocalFrontFocus.current + val src = remember { MutableInteractionSource() } + val focused by src.collectIsFocusedAsState() + val hovered by src.collectIsHoveredAsState() + val pressed by src.collectIsPressedAsState() + if (frontFocus != null) LaunchedEffect(focused) { + if (focused) { + frontFocus.focusedRail = key + onFocus() + } + else if (frontFocus.focusedRail == key) frontFocus.focusedRail = null + } + val fg by animateColorAsState( + if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant, + Motion.tw(220), label = "railFg", + ) + val fill by animateColorAsState( + if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, + Motion.tw(220), label = "railFill", + ) + val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing") + val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .size(width = if (iconOnly) 52.dp else 80.dp, height = if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp) + .clip(Shape14) + .background(fill) + .border(2.dp, ring, Shape14) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier), + ) { + 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 (compact) 20.dp else 22.dp)) + Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false) + } + 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) + .size(8.dp).clip(CircleShape).background(AttentionAmber), + ) + } +} + +/** The running session, one press from anywhere in the launcher. */ +@Composable +private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, onResume: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val focused by src.collectIsFocusedAsState() + val hovered by src.collectIsHoveredAsState() + val pulse = rememberInfiniteTransition(label = "pulse") + val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(1600, easing = Motion.Ease), RepeatMode.Restart), label = "ring") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier + .padding(top = 6.dp) + .width(if (iconOnly) 52.dp else 80.dp) + .clip(Shape14) + .background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f)) + .border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume) + .semantics { contentDescription = "Resume $name" } + .padding(vertical = if (iconOnly) 15.dp else if (compact) 6.dp else 9.dp, horizontal = 4.dp), + ) { + Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape)) + Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good)) + } + // Icons only: the live dot alone says something is running; its description says what. + if (!iconOnly) { + Text("Resume", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, softWrap = false) + Text(name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 7ec5bb983..422d1a155 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -8,161 +8,68 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged -import android.graphics.Bitmap -import android.os.Build import android.provider.Settings import android.view.Display import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.key import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ChevronLeft -import androidx.compose.material.icons.filled.MoreHoriz -import androidx.compose.material.icons.filled.PriorityHigh -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.outlined.Apps -import androidx.compose.material.icons.outlined.Extension -import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material.icons.outlined.Speed -import androidx.compose.material.icons.outlined.VideogameAsset -import androidx.compose.material.icons.outlined.DesktopWindows -import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material.icons.outlined.SportsEsports -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material.icons.outlined.VideoLibrary -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.first import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.input.InputMode -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import coil.compose.AsyncImage -import com.droiddeck.launcher.R import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.input.SecondScreenDisplay -import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.session.SessionPrefs -import java.io.File import kotlin.math.roundToInt class FrontEndState( @@ -249,7 +156,6 @@ class FrontEndActions( val controller: ControllerActions? = null, ) - internal object Motion { var scale = 1f val Ease = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f) @@ -260,10 +166,13 @@ internal object Motion { } private val Shape10 = RoundedCornerShape(10.dp) -private val Shape12 = RoundedCornerShape(12.dp) + +internal val Shape12 = RoundedCornerShape(12.dp) internal fun hueOf(name: String) = (name.hashCode().toUInt() % 360u).toFloat() -private fun tint(h: Float, s: Float = 0.7f, v: Float = 0.58f) = Color.hsv(h, s, v) + +internal fun tint(h: Float, s: Float = 0.7f, v: Float = 0.58f) = Color.hsv(h, s, v) + internal fun artBrush(h: Float) = Brush.linearGradient(listOf(tint(h), tint((h + 32f) % 360f, 0.65f, 0.30f), tint((h + 64f) % 360f, 0.6f, 0.14f))) @Composable @@ -285,7 +194,7 @@ private fun Modifier.staggerIn(i: Int): Modifier { } @Composable -private fun Modifier.shine(trigger: Boolean, strength: Float = 0.22f): Modifier { +internal fun Modifier.shine(trigger: Boolean, strength: Float = 0.22f): Modifier { val x = remember { Animatable(-1f) } LaunchedEffect(trigger) { if (trigger) { x.snapTo(-1f); x.animateTo(1f, Motion.tw(800)) } } return drawWithContent { @@ -304,14 +213,13 @@ private fun Modifier.shine(trigger: Boolean, strength: Float = 0.22f): Modifier } } - /** * Controller focus on the front end: each rail item's requester and the page's main button (Play, * Open desktop, Open , Launch...). The pane leaves, to the left, for the selected rail * item - whatever tile or button it leaves from - and remembers that control, so coming back in * lands on it again; a page not yet visited enters on its main button. */ -private class FrontFocus { +internal class FrontFocus { val rail = HashMap() val primary = FocusRequester() var primaryAttached by mutableStateOf(0) @@ -340,14 +248,14 @@ private class FrontFocus { /** Down from this button goes to the first tile of the page's grid, when there is one. */ @Composable -private fun Modifier.downToFirstTile(): Modifier { +internal fun Modifier.downToFirstTile(): Modifier { val ff = LocalFrontFocus.current ?: return this return this.focusProperties { down = if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default } } /** Marks the first tile of the page's grid. */ @Composable -private fun Modifier.firstTile(): Modifier { +internal fun Modifier.firstTile(): Modifier { val ff = LocalFrontFocus.current ?: return this DisposableEffect(Unit) { ff.firstTileAttached++ @@ -358,7 +266,7 @@ private fun Modifier.firstTile(): Modifier { /** Lets the pane come back to this control: it is remembered when focused. */ @Composable -private fun Modifier.paneItem(id: String): Modifier { +internal fun Modifier.paneItem(id: String): Modifier { val ff = LocalFrontFocus.current ?: return this val req = remember(id) { ff.items.getOrPut(id) { FocusRequester() } } DisposableEffect(id) { @@ -368,7 +276,7 @@ private fun Modifier.paneItem(id: String): Modifier { return this.focusRequester(req).onFocusChanged { if (it.isFocused) ff.last = id } } -private val LocalFrontFocus = staticCompositionLocalOf { null } +internal val LocalFrontFocus = staticCompositionLocalOf { null } @Composable fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) { @@ -577,1350 +485,3 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com runCatching { frontFocus.railFor(railSelection).requestFocus() } } } - - -private val Shape14 = RoundedCornerShape(14.dp) - -/** What the pad's face buttons do here, along the bottom edge as on a console. */ -@Composable -private fun ControllerHints() { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - Column(modifier = Modifier.fillMaxWidth()) { - Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.End), - modifier = Modifier.fillMaxWidth().height(34.dp).background(colors.surface).padding(horizontal = 20.dp), - ) { - HintGlyph("A", "Select") - HintGlyph("B", "Back") - } - } -} - -@Composable -private fun HintGlyph(button: String, action: String) { - val colors = MaterialTheme.colorScheme - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) { - Text(button, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background) - } - Text(action, fontSize = 13.sp, color = colors.onSurfaceVariant) - } -} -private val AttentionAmber = Color(0xFFFFB547) - -/** - * The launcher's sections, always on screen down the left edge: the app is landscape-only, so the - * width a hamburger menu would save is better spent keeping every section one press away. - */ -@Composable -private fun SideRail( - s: FrontEndState, selected: String, - onSelect: (String) -> Unit, onFocusSelect: (String) -> Unit, a: FrontEndActions, modifier: Modifier, -) { - val colors = MaterialTheme.colorScheme - val compact = LocalConfiguration.current.screenHeightDp < 420 - // Under 600dp wide (4:3 and square screens) the labels would cost a fifth of the width. - val iconOnly = isNarrowScreen() - val setupNeedsAttention = !s.ready || (s.available != null && s.available != s.installed) || - PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp), - ) { - Image(painterResource(R.drawable.logo), contentDescription = "DroidDeck", modifier = Modifier.size(if (compact) 28.dp else 34.dp)) - Spacer(Modifier.height(if (compact) 6.dp else 12.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), - ) { - if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } - RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, onFocus = { onFocusSelect("steam") }) { onSelect("steam") } - // Games appears once there is one: an empty list is no place to land. - if (s.steamGames.isNotEmpty()) RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") } - RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } - RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") } - RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") } - } - AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 8.dp)) { - val barWidth = if (iconOnly) 44.dp else 60.dp - if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.width(barWidth)) - else LinearProgressIndicator(modifier = Modifier.width(barWidth)) - Text(if (s.percent >= 0) "${s.percent}%" else if (iconOnly) "…" else "Working", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp)) - } - } - var lastRunning by remember { mutableStateOf("") } - if (s.running != null) lastRunning = s.running - AnimatedVisibility( - s.running != null, - enter = expandVertically(Motion.sp(0.75f)) + fadeIn(Motion.tw(300)), - exit = shrinkVertically(Motion.tw(220)) + fadeOut(Motion.tw(180)), - ) { ResumeRailItem(lastRunning, compact, iconOnly, a.onResume) } - } -} - -@Composable -private fun RailItem( - label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean, - badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit, -) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val frontFocus = LocalFrontFocus.current - val src = remember { MutableInteractionSource() } - val focused by src.collectIsFocusedAsState() - val hovered by src.collectIsHoveredAsState() - val pressed by src.collectIsPressedAsState() - if (frontFocus != null) LaunchedEffect(focused) { - if (focused) { - frontFocus.focusedRail = key - onFocus() - } - else if (frontFocus.focusedRail == key) frontFocus.focusedRail = null - } - val fg by animateColorAsState( - if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant, - Motion.tw(220), label = "railFg", - ) - val fill by animateColorAsState( - if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, - Motion.tw(220), label = "railFill", - ) - val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing") - val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale") - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale } - .size(width = if (iconOnly) 52.dp else 80.dp, height = if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp) - .clip(Shape14) - .background(fill) - .border(2.dp, ring, Shape14) - .hoverable(src) - .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) - .then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier), - ) { - 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 (compact) 20.dp else 22.dp)) - Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false) - } - 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) - .size(8.dp).clip(CircleShape).background(AttentionAmber), - ) - } -} - -/** The running session, one press from anywhere in the launcher. */ -@Composable -private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, onResume: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val focused by src.collectIsFocusedAsState() - val hovered by src.collectIsHoveredAsState() - val pulse = rememberInfiniteTransition(label = "pulse") - val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(1600, easing = Motion.Ease), RepeatMode.Restart), label = "ring") - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp), - modifier = Modifier - .padding(top = 6.dp) - .width(if (iconOnly) 52.dp else 80.dp) - .clip(Shape14) - .background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f)) - .border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14) - .hoverable(src) - .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume) - .semantics { contentDescription = "Resume $name" } - .padding(vertical = if (iconOnly) 15.dp else if (compact) 6.dp else 9.dp, horizontal = 4.dp), - ) { - Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) { - Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape)) - Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good)) - } - // Icons only: the live dot alone says something is running; its description says what. - if (!iconOnly) { - Text("Resume", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, softWrap = false) - Text(name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } -} - - -@Composable -private fun Pane( - s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier, - onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit, - onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit, -) { - BoxWithConstraints(modifier = modifier) { - CompositionLocalProvider(LocalNarrowPane provides (maxWidth < NarrowPaneWidth)) { - val backdropArt: File? = when { - s.pageKey != null && page != null -> null - selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }?.art - selected == "games" -> s.steamGames.maxByOrNull { it.lastPlayed }?.art - selected.startsWith("rom:") -> romFor(s, selected)?.second?.art - else -> null - } - Backdrop(backdropArt) - AnimatedContent( - // Picking another game changes the detail beside the list, not the whole page. - targetState = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected, - transitionSpec = { - (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) - .togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 }) - .apply { targetContentZIndex = 1f } - }, - label = "pane", - ) { key -> - if (page != null && key == s.pageKey) page() - else Content(s, if (key == "games") selected else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) - } - } - } -} - -@Composable -private fun Backdrop(art: File?) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - Crossfade(targetState = art, animationSpec = Motion.tw(700), label = "backdrop") { a -> - // A game's own art, blurred, where it has some; otherwise the plain ground with one quiet - // glow of the theme's signal colour - a colour per title hash read as noise. - if (a != null && Build.VERSION.SDK_INT >= 31) { - Box(modifier = Modifier.fillMaxSize().alpha(0.26f).blur(70.dp)) { - AsyncImage(model = a, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.5f; scaleY = 1.5f }) - } - } else { - Box(modifier = Modifier.fillMaxSize().background(Brush.radialGradient(listOf(pal.signal.copy(alpha = 0.07f), Color.Transparent), center = Offset.Zero, radius = 1400f))) - } - } - Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, colors.background.copy(alpha = 0.35f))))) -} - -private fun romFor(s: FrontEndState, selected: String): Pair? { - val parts = selected.split(":") - val e = s.emulators.firstOrNull { it.id == parts.getOrNull(1) } ?: return null - val g = e.games.getOrNull(parts.getOrNull(2)?.toIntOrNull() ?: -1) ?: return null - return e to g -} - -@Composable -private fun Content( - s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier, - onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit, - onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit, -) { - val colors = MaterialTheme.colorScheme - val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp - val narrow = LocalNarrowPane.current - val padH = if (narrow) 16.dp else 22.dp - val padV = if (narrow) 12.dp else 18.dp - // Setup scrolls inside itself, under its tabs. - if (selected == "setup") { - Column(modifier = modifier.padding(horizontal = padH, vertical = padV)) { - SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb) - } - return - } - // Steam is a full-bleed wall of the library with Play over it. - if (selected == "steam") { - SteamHome(s, a, modifier) - return - } - // The Games tab lays out its own list and detail. - if (selected == "games" || selected.startsWith("app:")) { - GamesPage(s, a, selected, onSelect, modifier) - return - } - // Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid - // instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view. - Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) { - when { - selected == "android-apps" && s.isHomeApp -> { - Rise(0) { PageHeader("Android apps") { Chip("${s.androidApps.size} apps", ok = false) } } - if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") } - else Rise(3, Modifier.fillMaxWidth()) { - ArtGrid(s.androidApps.map { app -> - Tile( - app.label, - null, - null, - "android:${app.packageName}", - onClick = { onAndroidAppClick(app) }, - iconBitmap = app.icon, - ) - }) - } - } - selected == "desktop" -> { - val installed = s.emulators.filter { it.installed } - val available = s.emulators.filter { !it.installed } - Rise(0) { - PageHeader("Desktop") { - if (s.desktopInstalled) Chip("● Desktop installed", ok = true) else Chip("Installs on first open", ok = false) - } - } - Rise(2) { DesktopCard(s, a) } - if (installed.isNotEmpty()) { - Rise(3) { SectionTitle("Emulators", "${installed.size} installed") } - Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) } - } - if (available.isNotEmpty()) { - Rise(5) { SectionTitle("Available to install", available.size.toString()) } - Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) } - } - } - selected.startsWith("emu:") -> { - val e = s.emulators.firstOrNull { "emu:${it.id}" == selected } - if (e == null) Note("Not installed.") else { - val pkgId = Library.packageId(e.id) - val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } } - Rise(0) { - BackLink("Desktop") { onSelect("desktop") } - } - Rise(1) { - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp), - ) { - Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp)) - Column { - Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) - Text( - e.system.replaceFirstChar { it.uppercase() } + if (e.installed) "" else " · not installed", - fontSize = 14.sp, color = colors.onSurfaceVariant, - ) - } - } - } - if (e.installed) { - Rise(3) { - Actions { - PrimaryButton("Open ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onEmulator(e) } - SecondaryButton("ROMs folder", onClick = a.onRoms) - if (pkg != null) SecondaryButton( - if (pkg.kind == "appimage") "Remove" else "Forget", - enabled = s.packageBusyId == null && !s.sessionRunning, - ) { a.onRemovePackage(pkg.id) } - } - } - Rise(4) { SectionTitle("Games", e.games.size.toString()) } - if (e.games.isEmpty()) Rise(5) { - Note( - if (s.romsDir == null) "Choose a ROMs folder." - else if (e.id == "retroarch") "Browse to /root/ROMs in RetroArch." - else "Add ${e.system} games to ROMs/${e.system.substringBefore(' ')}.", - ) - } - 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 }) - } - } else { - if (pkg != null) Rise(2) { - Actions { - PrimaryButton( - if (s.packageBusyId == pkg.id) "Installing…" else "Install ${e.name}", - enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning, - ) { a.onInstallPackage(pkg.id) } - if (s.sessionRunning) ActionChip("Stop session to install", ok = false) - else if (!s.ready) ActionChip("Runtime required", ok = false) - } - } - Rise(3) { - Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { - Note(when { - s.packageCatalogLoading -> "Loading install details…" - pkg == null -> "Install details are unavailable right now. Try again when the package catalog is reachable." - !s.ready -> "Install the Linux runtime from Setup before installing desktop apps." - s.sessionRunning -> "Stop the active session before installing desktop apps." - pkg.notes.isNotBlank() -> pkg.notes - else -> "Install ${e.name} into the Linux desktop runtime." - }) - } - } - if (s.packageBusyId == pkg?.id) Rise(4) { - val stage = s.packageStage - Text( - if (stage != null && s.packagePercent >= 0) "$stage · ${s.packagePercent}%" else stage ?: "Starting…", - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp), - ) - if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) - else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) - } - if (pkg?.kind == "tar") Rise(5) { Note("Forgetting this package hides it from Desktop; its files remain in the Linux runtime.") } - } - } - } - selected.startsWith("rom:") -> { - val pair = romFor(s, selected) - if (pair == null) Note("That game is gone from the ROMs folder.") else { - val (e, g) = pair - Rise(0) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - BackLink(e.name) { onSelect("emu:${e.id}") } - Eyebrow("Desktop · ${e.system}") - } - } - Rise(1) { Title(g.name) } - Rise(2) { - Row { - Column(modifier = Modifier.weight(1f)) { - Text(g.guestPath, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(colors.surface).padding(horizontal = 10.dp, vertical = 6.dp)) - Spacer(Modifier.height(14.dp)) - Actions { - Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp)) - PrimaryButton("Launch in ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onRom(g) } - ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false) - } - } - if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth)) - } - } - val others = e.games.filter { it !== g } - if (others.isNotEmpty()) { - Rise(3) { SectionTitle("Also in ${e.name}", null) } - 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") } - }, wide = others.none { it.art != null }) - } - } - } - } - else -> Note("Select an item.") - } - } -} - -/** The Linux desktop itself, above the emulators that run in it. */ -@Composable -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. - PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop) - Cog(onClick = a.onDesktopSettings) - } - } - 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("Linux desktop", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Text("LXQt, Firefox and your emulators", fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - if (!narrow) actions() - } - if (narrow) actions() - } -} - -/** Emulators as wide tiles - three across, or a list on a narrow page. */ -@Composable -private fun EmulatorGrid(emulators: List, first: Boolean, onSelect: (String) -> 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)) { - emulators.chunked(columns).forEachIndexed { r, row -> - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - row.forEachIndexed { i, e -> - key(e.id) { - EmulatorTile(e, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onSelect("emu:${e.id}") } - } - } - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } -} - -/** - * One emulator: its icon, system and games. One not yet installed says so in words and keeps full - * contrast - dimming it read as disabled rather than one press from installing. - */ -@Composable -private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "emuScale") - val system = e.system.replaceFirstChar { it.uppercase() } - val detail = if (e.installed && e.id != "retroarch") "$system · ${e.games.size} game${if (e.games.size == 1) "" else "s"}" else system - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier.paneItem("tile:emu:${e.id}").then(if (isFirst) Modifier.firstTile() else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) { - Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (!e.installed) Text( - "Install", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp), - ) - } -} - -private enum class CheckState { OK, WARN, BUSY } - -/** One requirement in Setup's system check: a status mark, what it is, and at most one action. */ -@Composable -private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val tint = when (state) { - CheckState.OK -> pal.good - CheckState.WARN -> AttentionAmber - CheckState.BUSY -> pal.signal - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth() - .background(if (state == CheckState.WARN) AttentionAmber.copy(alpha = 0.07f) else Color.Transparent) - .heightIn(min = 60.dp) - .padding(horizontal = 14.dp, vertical = 10.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(tint.copy(alpha = 0.16f))) { - Icon( - when (state) { - CheckState.OK -> Icons.Filled.Check - CheckState.WARN -> Icons.Filled.PriorityHigh - CheckState.BUSY -> Icons.Filled.Refresh - }, - contentDescription = when (state) { - CheckState.OK -> "Done" - CheckState.WARN -> "Needs attention" - CheckState.BUSY -> "Working" - }, - tint = tint, modifier = Modifier.size(16.dp), - ) - } - Column(modifier = Modifier.weight(1f)) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - if (detail != null) Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - if (action != null) action() - } - if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) -} - -@Composable -private fun SetupPanel( - s: FrontEndState, - a: FrontEndActions, - onOpenDeveloperOptions: () -> Unit, - onRequestWirelessAdb: (Boolean) -> Unit, -) { - val host = rememberMenuHost() - var processLimitBusy by remember { mutableStateOf(false) } - var processLimitMessage by remember { mutableStateOf(null) } - val setProcessLimit: (Boolean) -> Unit = { enabled -> - processLimitBusy = true - processLimitMessage = null - a.onSetPhantomProcessLimit(enabled) { error -> - processLimitBusy = false - if (error == null) { - a.onRefreshPhantomStatus() - } else { - processLimitMessage = "Check the Wireless debugging IP address & Port, or pair again if Android removed this device." - onRequestWirelessAdb(enabled) - } - } - } - val runtime = when { - s.busy -> "Working…" - !s.ready -> "Install" - s.available != null && s.available != s.installed -> "Update" - else -> "Manage" - } - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val gpuOk = remember { DeviceSupport.adreno() } - val gpuName = remember { DeviceSupport.gpuName() } - val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) - val signedIn = s.offlineAccount != null - var showLimitDetails by rememberSaveable { mutableStateOf(false) } - val checks = 4 - val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it } - // Five tabs instead of one long scroll; LB and RB turn them from anywhere on the page. - val tabs = listOf("Overview", "Controller", "Session", "Launcher", "About") - var tab by rememberSaveable { mutableStateOf(0) } - val tabFocus = remember { List(tabs.size) { FocusRequester() } } - var tabTurned by remember { mutableStateOf(false) } - val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true } - val inputModeManager = LocalInputModeManager.current - // The control a controller was on went with the old tab: it lands on the new tab itself. - LaunchedEffect(tab) { - if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) { - androidx.compose.runtime.withFrameNanos { } - runCatching { tabFocus[tab].requestFocus() } - } - } - Rise(0, Modifier.fillMaxSize()) { - Column( - modifier = Modifier.fillMaxSize().bumpers( - onPrevious = { pick((tab + tabs.size - 1) % tabs.size) }, - onNext = { pick((tab + 1) % tabs.size) }, - ), - ) { - PageHeader("Setup") { - Chip(if (readyCount == checks) "● All set" else "$readyCount of $checks ready", ok = readyCount == checks) - } - TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus) - Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { - when (tab) { - 0 -> { - SectionTitle("System check", null) - // What Steam needs, one row each: green when done, one button when not. The - // process-limit controls only open under their row. - Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { - CheckRow( - if (gpuOk) CheckState.OK else CheckState.WARN, - if (gpuOk) "Device supported" else "GPU not supported", - if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen", - ) - CheckRow( - when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK }, - "Linux runtime", - when { - s.busy -> if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage - !s.ready -> "Not installed · about 3 GB, installed on the first Play" - s.available != null && s.available != s.installed -> "${s.installed ?: "Installed"} · update available" - else -> "${s.installed ?: "Installed"} · up to date" - }, - ) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) } - CheckRow( - if (limitBlocks) CheckState.WARN else CheckState.OK, - if (limitBlocks) "Android may close Steam" else "Android process limit", - if (limitBlocks) "“Restrict child processes” is on - it takes a minute to turn off" else PhantomProcessLimit.title(s.phantomProcessStatus), - ) { - if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails } - else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { - SecondaryButton(if (showLimitDetails) "Hide" else "Details", compact = true) { showLimitDetails = !showLimitDetails } - } - } - AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { - Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) { - Text( - PhantomProcessLimit.instructions(s.phantomProcessStatus), - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) { - SecondaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions) - SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus) - SecondaryButton("Copy ADB command", compact = true, onClick = { a.onCopyPhantomCommand(false) }) - } - Text( - PhantomProcessLimit.ADB_COMMAND, fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), - ) - if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { - Text( - "With wireless debugging paired, DroidDeck can switch the limit itself. Turning it off is recommended for Steam sessions.", - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) { - SecondaryButton("Turn limit off", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.DISABLED) { setProcessLimit(false) } - SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.ENABLED) { setProcessLimit(true) } - } - if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) - processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) } - } - } - } - CheckRow( - if (signedIn) CheckState.OK else CheckState.WARN, - "Steam account", - s.offlineAccount?.let { if (s.offline) "Signed in as $it · offline mode" else "Signed in as $it" } ?: "Press Play and sign in to Steam", - divider = false, - ) - } - SectionTitle("Tools", null) - ToolGrid(s, a) - } - 1 -> { - val controller = s.controller - if (controller != null && a.controller != null) SettingsGroup("Controller") { - ControllerRows(host, s.oscMode, controller, a.controller) - } - if (s.controller == null || a.controller == null) Note("Controller settings are unavailable.") - } - 2 -> { - SettingsGroup("Session") { - ChoiceRow( - host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted), - listOf( - false to SessionPrefs.BACK_MENU_THEN_QAM, - true to SessionPrefs.BACK_QAM_THEN_MENU, - ), s.backActionsInverted, onPick = a.onBackActionsInverted, - ) - SettingsRow("Frame generation", "Select the frame generation mode") { - Box { - ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" } - FrameGenMenu(s, a, host) - } - } - SettingsRow("Session logs", "${if (s.logsEnabled) "Enabled" else "Disabled"} · logs are saved after each session") { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - SecondaryButton(if (s.logsEnabled) "Turn off" else "Turn on") { a.onLogs() } - SecondaryButton("Share latest") { a.onShareLogs() } - } - } - SettingsRow("Offline mode", s.offlineAccount?.let { if (s.offline) "Enabled for $it" else "Signed in as $it" } ?: "Sign in to Steam first") { - SecondaryButton(if (s.offline) "Turn off" else "Turn on", enabled = s.offlineAccount != null) { a.onOffline() } - } - } - } - 3 -> { - SettingsGroup("Launcher") { - SettingsRow("Theme", "Choose the launcher appearance") { - Box { - ValueChip(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 = "Theme") { firstItemFocus -> - Themes.all.forEachIndexed { index, theme -> - MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) { - a.onTheme(theme.id) - host.open = null - } - } - } - } - } - ToggleRow( - host, "home-screen", "Use as a Home screen", - if (s.homeScreenEnabled) "DroidDeck can be the phone's Home app" else "Off: DroidDeck is never offered as a Home app", - s.homeScreenEnabled, - ) { a.onHomeScreen(it) } - ToggleRow( - host, "launcher-fullscreen", "Fullscreen", - if (s.launcherFullscreen) "Hide the Android status and navigation bars" else "Show the Android status and navigation bars", - s.launcherFullscreen, - ) { a.onLauncherFullscreen(it) } - if (s.homeScreenEnabled) { - ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp) - } - } - } - else -> { - SettingsGroup("About") { - ActionRow("Build", s.buildLabel, "Check for newer", a.onCheckLatestBuild) - ActionRow("Credits", "The people and projects DroidDeck builds on", "View", a.onCredits) - } - } - } - } - } - } -} - -/** The launcher's own tools as cards: four across, two by two on a narrow page. */ -@Composable -private fun ToolGrid(s: FrontEndState, a: FrontEndActions) { - val columns = if (LocalNarrowPane.current) 2 else 4 - val tools = listOf( - ToolSpec(Icons.Outlined.Folder, "Files", "Browse and manage files", a.onFiles), - ToolSpec(Icons.Outlined.Extension, "Compatibility tools", "Install ARM64 Proton builds", a.onProtons), - ToolSpec(Icons.Outlined.Speed, "Performance", "CPU core assignment", a.onPerformance), - ToolSpec(Icons.Outlined.VideogameAsset, "ROMs folder", s.romsDir ?: "Choose where emulator games are stored", a.onRoms), - ) - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - for (row in tools.chunked(columns)) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight()) - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } -} - -private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit) - -@Composable -private fun ToolCard(t: ToolSpec, modifier: Modifier) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale") - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier.paneItem("tool:${t.title}") - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick) - .controllerConfirm(onClick = t.onClick) - .padding(horizontal = 14.dp, vertical = 12.dp), - ) { - Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) - Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) - } -} - -private class Tile( - val title: String, val sub: String?, val art: File?, val key: String, - val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null, - val showFooter: Boolean = true, - val onClick: () -> Unit, -) - - -@Composable -internal fun Eyebrow(t: String) { - val pal = LocalPalette.current - val rule = remember { Animatable(0f) } - LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal)) - Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal) - } -} -@Composable internal fun Title(t: String) = Text(t, fontSize = 26.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp)) -@Composable internal fun Lede(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp)) -@Composable -private fun SectionTitle(t: String, detail: String?) { - val colors = MaterialTheme.colorScheme - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) { - Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant) - if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground) - Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line)) - } -} -@Composable private fun Note(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().clip(Shape12).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line2, Shape12).padding(12.dp)) -/** A page's buttons: they wrap onto a second line rather than run off a narrow page. */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun Actions(content: @Composable () -> Unit) = FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), -) { content() } - -/** A status chip in a row of buttons, centred on their height. */ -@Composable -private fun ActionChip(t: String, ok: Boolean) = Box(contentAlignment = Alignment.Center, modifier = Modifier.heightIn(min = 44.dp)) { Chip(t, ok) } - -private val Shape16 = RoundedCornerShape(16.dp) - -/** One level up, as Back and B do: a real button, big enough to hit ([compact]: for the tightest layouts). */ -@Composable -internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val shape = RoundedCornerShape(22.dp) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 36.dp else 44.dp) - .clip(shape) - .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f)) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .controllerConfirm(onClick = onClick) - .padding(start = 8.dp, end = 16.dp), - ) { - Icon(Icons.Filled.ChevronLeft, contentDescription = null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(22.dp)) - Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} - -@Composable -private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) { - AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> - val need = if (s.lsfgReady) null else "Install Lossless Scaling in Steam" - MenuItem("Off", checked = s.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null } - for (m in 2..4) MenuItem("Win-FG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_WINFG && s.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null } - for (m in 2..4) MenuItem("LSFG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_LSFG && s.frameGenMultiplier == m, enabled = s.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null } - } -} - -/** - * What shapes a launch, beside the game rather than three screens away in Setup. They are the - * app-wide settings - each card opens the same page or menu Setup does. - */ -@Composable -private fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) { - // 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 cards = buildList<@Composable (Modifier) -> Unit> { - add { m -> SettingCard("Compatibility", "Proton & components", "card:components", m) { a.onComponents(true) } } - add { m -> - Box(m) { - SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { - host.open = if (host.open == "fg") null else "fg" - } - FrameGenMenu(s, a, host) - } - } - if (controller != null) add { m -> SettingCard("Controls", "Button mapping", "card:controls", m, controller.onMapping) } - } - 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)) { - for (card in row) card(Modifier.weight(1f).fillMaxHeight()) - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } -} - -@Composable -private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale") - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge") - Column( - verticalArrangement = Arrangement.spacedBy(3.dp), - modifier = modifier.paneItem(id) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, edge, Shape14) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .controllerConfirm(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 12.dp), - ) { - Text(label, fontSize = 13.sp, color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} - -/** - * The Games tab: installed Steam games down the left, most recently played first, and the one - * picked beside them - its banner, Launch and the launch settings. One game skips the list. - */ -@Composable -private fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) { - val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } } - val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() ?: return - val host = rememberMenuHost() - val narrow = LocalNarrowPane.current - if (games.size == 1) { - Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - Rise(0) { - Row(verticalAlignment = Alignment.Bottom) { - GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) { - GameHeroCopy(current, if (narrow) 28.sp else 38.sp) - PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) } - } - if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp)) - } - } - Rise(1) { SectionTitle("Launch settings", null) } - Rise(2) { LaunchSettings(s, a, host) } - } - return - } - val pal = LocalPalette.current - Row(modifier = modifier) { - GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) }, - modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight()) - Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) - Column( - modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()) - .padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp), - ) { - GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) { - GameHeroCopy(current, if (narrow) 24.sp else 32.sp) - PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) } - } - SectionTitle("Launch settings", null) - LaunchSettings(s, a, host) - } - } -} - -/** When it was last played (or where it is, if never) over its name, then room for Launch. */ -@Composable -private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) { - Text( - (lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(), - fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal, - maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - Text( - g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(8.dp)) -} - -@Composable -private fun GameList( - games: List, current: Library.SteamGame, - onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, modifier: Modifier, -) { - val colors = MaterialTheme.colorScheme - // Laid out whole, as the art grid is: the pad's focus search only finds rows that exist. - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp), - ) { - Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) { - Text("Games", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) - Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp)) - } - for (g in games) key(g.appId) { - GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) }) - } - } -} - -/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */ -@Composable -private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val focused by src.collectIsFocusedAsState() - val hovered by src.collectIsHoveredAsState() - LaunchedEffect(focused) { if (focused && !selected) onSelect() } - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}") - .clip(Shape12) - .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) - .border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect) - .controllerConfirm(onClick = onLaunch) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) { - Box(Modifier.width(30.dp).height(45.dp).clip(RoundedCornerShape(5.dp)).background(artBrush(hueOf(g.name)))) { - if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - } - Column(modifier = Modifier.weight(1f)) { - Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - lastPlayedText(g.lastPlayed)?.removePrefix("Last played ")?.replaceFirstChar { it.uppercase() } ?: "Never played", - fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - } - } -} - -/** A page's title, with room at its right for a status chip. */ -@Composable -private fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) { - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - ) { - Text( - title, fontSize = if (LocalNarrowPane.current) 22.sp else 26.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, maxLines = 1, - ) - trailing() - } -} - -/** Whether Steam can start, said where Play is rather than only in Setup. */ -@Composable -internal fun RuntimeChip(s: FrontEndState) = when { - s.busy -> Chip(if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage, ok = false) - !s.ready -> Chip("Runtime installs on first Play", ok = false) - s.available != null && s.available != s.installed -> Chip("Runtime update available", ok = false) - else -> Chip("● Runtime ready", ok = true) -} - -/** "Last played 3 days ago" from Steam's unix seconds; null for a game never played. */ -private fun lastPlayedText(lastPlayed: Long): String? { - if (lastPlayed <= 0L) return null - val span = android.text.format.DateUtils.getRelativeTimeSpanString( - lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS, - ).toString() - return "Last played " + span.replaceFirstChar { it.lowercase() } -} - -private fun libraryLabel(library: String): String = when (library) { - "internal" -> "Internal storage" - "added" -> "Added game" - else -> library -} - -/** - * A game's wide banner: Steam's hero art where the client cached one, else its capsule blurred to - * fill the width. The copy sits bottom-left over a scrim of the ground colour so it always reads; - * the art takes the banner's size, so a banner given a minimum height grows to fit its copy. - */ -@Composable -private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) { - val colors = MaterialTheme.colorScheme - Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) { - val image = g.hero ?: g.art - if (image != null) AsyncImage( - model = image, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize() - .then(if (g.hero == null) Modifier.blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f } else Modifier), - ) - Spacer( - Modifier.matchParentSize().background( - Brush.horizontalGradient( - 0f to colors.background.copy(alpha = 0.92f), - 0.55f to colors.background.copy(alpha = 0.6f), - 1f to Color.Transparent, - ), - ), - ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp), - content = content, - ) - } -} - -@Composable -private fun Chip(t: String, ok: Boolean) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - Text( - t, fontSize = 12.sp, color = if (ok) pal.good else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(colors.surfaceVariant).border(1.dp, if (ok) pal.good.copy(alpha = 0.3f) else pal.line, RoundedCornerShape(99.dp)).padding(horizontal = 9.dp, vertical = 4.dp), - ) -} - -@Composable -private fun rememberHot(src: MutableInteractionSource): Boolean = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value - -@Composable -internal fun PrimaryButton( - text: String, - enabled: Boolean = true, - main: Boolean = false, - compact: Boolean = false, - modifier: Modifier = Modifier, - /** The Steam tab's own Play: bigger than any other button on a page. */ - large: Boolean = false, - icon: ImageVector? = null, - onClick: () -> Unit, -) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - // The page's main button is where the pane is entered from the rail. - val frontFocus = if (main) LocalFrontFocus.current else null - if (frontFocus != null) DisposableEffect(Unit) { - frontFocus.primaryAttached++ - onDispose { frontFocus.primaryAttached-- } - } - val track = (if (frontFocus == null) Modifier.paneItem("btn:$text") else Modifier).downToFirstTile() - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) && enabled - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "btnScale") - val lift by animateFloatAsState(if (hot) 14f else 6f, Motion.tw(300), label = "btnLift") - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.then(track) - .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal } - .clip(Shape12) - .background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant))) - .shine(hot, 0.45f) - // The grow and shine alone barely show on the light fill: outline it when a - // controller is on it, as the other controls are. - .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) - .padding( - start = if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp, - end = if (large) 26.dp else if (compact) 10.dp else 18.dp, - top = if (large) 16.dp else if (compact) 7.dp else 11.dp, - bottom = if (large) 16.dp else if (compact) 7.dp else 11.dp, - ), - ) { - val fg = if (enabled) colors.onPrimary else colors.onSurfaceVariant - if (icon != null) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (large) 20.dp else 17.dp)) - Text( - text, fontSize = if (large) 17.sp else if (compact) 12.sp else 15.sp, - fontWeight = if (large) FontWeight.Bold else FontWeight.SemiBold, letterSpacing = 0.5.sp, color = fg, maxLines = 1, - ) - } -} - -@Composable -internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) && enabled - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale") - val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge") - val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill") - Box( - modifier = modifier.paneItem("btn:$text").downToFirstTile().graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12) - .alpha(if (enabled) 1f else 0.5f) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) - .controllerConfirm(enabled = enabled, onClick = onClick) - .padding(horizontal = if (compact) 10.dp else 16.dp, vertical = if (compact) 7.dp else 11.dp), - ) { Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1) } -} - -@Composable -internal fun Cog(size: androidx.compose.ui.unit.Dp = 42.dp, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog") - val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge") - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), - ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } -} - -@Composable -private fun Poster(art: File?, name: String, modifier: Modifier) { - val colors = MaterialTheme.colorScheme - val t = remember { Animatable(0f) } - LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) } - Box( - modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f) - .graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 16.dp.toPx(); rotationZ = (1f - t.value) * 2f; scaleX = 0.94f + 0.06f * t.value; scaleY = scaleX; shadowElevation = 22.dp.toPx(); shape = Shape12; clip = false } - .clip(Shape12).background(artBrush(hueOf(name))), - ) { - if (art != null) CoverImage(art, Modifier.fillMaxSize()) - else Text(name, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis) - } -} - - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ArtGrid(tiles: List, wide: Boolean = false) { - val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) } - // Big enough to recognise a game by its art and read its name under it; icon tiles are squares. - val minSize = if (square) 76.dp else if (wide) 120.dp else 96.dp - val gap = 12.dp - // Laid out whole, not lazily: the pad's focus search only finds tiles that exist, and a lazy - // grid composes only the rows on screen, so a press towards the next row bounced back among - // the visible tiles. A few hundred tiles lay out fine; the page's scroll follows the focused one. - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val avail = maxWidth - 8.dp - val cols = ((avail + gap) / (minSize + gap)).toInt().coerceAtLeast(1) - val tileWidth = (avail - gap * (cols - 1)) / cols - Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 14.dp, start = 4.dp, end = 4.dp)) { - for (row in tiles.chunked(cols)) { - Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth().padding(bottom = gap)) { - for (t in row) key(t.key) { - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier) - Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) } - } - } - } - } - } -} - -@Composable -private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableInteractionSource, hot: Boolean, track: Modifier) { - val colors = MaterialTheme.colorScheme - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else if (hot) 1.04f else 1f, Motion.sp(0.55f, Spring.StiffnessMedium), label = "tileScale") - val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift") - val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev") - val pal = LocalPalette.current - val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing") - Column( - modifier = track - .graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) } - .clip(Shape12) - .background(colors.surface) - .border(2.5.dp, ring, Shape12) - .alpha(if (t.dim && !hot) 0.55f else 1f) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick), - ) { - Box(modifier = Modifier.fillMaxWidth().shine(hot)) { - Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap) - } - if (t.showFooter) { - Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) { - Text(t.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (t.sub != null) Text(t.sub, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - } -} - -/** - * Art for a portrait (2:3) tile. Box art fills it; wide art - a PS3 disc's ICON0, a game's header - - * is shown whole over a blurred, darkened copy of itself instead of losing its sides to the crop. - */ -@Composable -private fun CoverImage(art: File, modifier: Modifier) { - var wideArt by remember(art) { mutableStateOf(false) } - Box(modifier) { - if (wideArt) { - AsyncImage( - model = art, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.2f; scaleY = 1.2f }.blur(14.dp), - ) - Spacer(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f))) - } - AsyncImage( - model = art, contentDescription = null, - contentScale = if (wideArt) ContentScale.Fit else ContentScale.Crop, - onSuccess = { state -> - val size = state.painter.intrinsicSize - if (size.width > size.height * 1.1f) wideArt = true - }, - modifier = Modifier.fillMaxSize(), - ) - } -} - -@Composable -private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) { - val colors = MaterialTheme.colorScheme - val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f - Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) { - when { - art != null -> if (wide) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) - else CoverImage(art, Modifier.fillMaxSize()) - iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp)) - iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp)) - else -> { - Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.55f)))) - Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(6.dp), maxLines = 3, overflow = TextOverflow.Ellipsis) - } - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt new file mode 100644 index 000000000..debe97504 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -0,0 +1,445 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.FocusRequester +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.PriorityHigh +import androidx.compose.material.icons.outlined.Extension +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material.icons.outlined.VideogameAsset +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.core.DeviceSupport +import com.droiddeck.launcher.core.PhantomProcessLimit +import com.droiddeck.launcher.core.PhantomProcessStatus +import com.droiddeck.launcher.session.SessionPrefs + +// The Setup page: runtime and device checks, tools, frame generation and launch settings. + +private enum class CheckState { OK, WARN, BUSY } + +/** One requirement in Setup's system check: a status mark, what it is, and at most one action. */ +@Composable +private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val tint = when (state) { + CheckState.OK -> pal.good + CheckState.WARN -> AttentionAmber + CheckState.BUSY -> pal.signal + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth() + .background(if (state == CheckState.WARN) AttentionAmber.copy(alpha = 0.07f) else Color.Transparent) + .heightIn(min = 60.dp) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(tint.copy(alpha = 0.16f))) { + Icon( + when (state) { + CheckState.OK -> Icons.Filled.Check + CheckState.WARN -> Icons.Filled.PriorityHigh + CheckState.BUSY -> Icons.Filled.Refresh + }, + contentDescription = when (state) { + CheckState.OK -> "Done" + CheckState.WARN -> "Needs attention" + CheckState.BUSY -> "Working" + }, + tint = tint, modifier = Modifier.size(16.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + if (detail != null) Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (action != null) action() + } + if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) +} + +@Composable +internal fun SetupPanel( + s: FrontEndState, + a: FrontEndActions, + onOpenDeveloperOptions: () -> Unit, + onRequestWirelessAdb: (Boolean) -> Unit, +) { + val host = rememberMenuHost() + var processLimitBusy by remember { mutableStateOf(false) } + var processLimitMessage by remember { mutableStateOf(null) } + val setProcessLimit: (Boolean) -> Unit = { enabled -> + processLimitBusy = true + processLimitMessage = null + a.onSetPhantomProcessLimit(enabled) { error -> + processLimitBusy = false + if (error == null) { + a.onRefreshPhantomStatus() + } else { + processLimitMessage = "Check the Wireless debugging IP address & Port, or pair again if Android removed this device." + onRequestWirelessAdb(enabled) + } + } + } + val runtime = when { + s.busy -> "Working…" + !s.ready -> "Install" + s.available != null && s.available != s.installed -> "Update" + else -> "Manage" + } + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val gpuOk = remember { DeviceSupport.adreno() } + val gpuName = remember { DeviceSupport.gpuName() } + val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) + val signedIn = s.offlineAccount != null + var showLimitDetails by rememberSaveable { mutableStateOf(false) } + val checks = 4 + val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it } + // Five tabs instead of one long scroll; LB and RB turn them from anywhere on the page. + val tabs = listOf("Overview", "Controller", "Session", "Launcher", "About") + var tab by rememberSaveable { mutableStateOf(0) } + val tabFocus = remember { List(tabs.size) { FocusRequester() } } + var tabTurned by remember { mutableStateOf(false) } + val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true } + val inputModeManager = LocalInputModeManager.current + // The control a controller was on went with the old tab: it lands on the new tab itself. + LaunchedEffect(tab) { + if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) { + androidx.compose.runtime.withFrameNanos { } + runCatching { tabFocus[tab].requestFocus() } + } + } + Rise(0, Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().bumpers( + onPrevious = { pick((tab + tabs.size - 1) % tabs.size) }, + onNext = { pick((tab + 1) % tabs.size) }, + ), + ) { + PageHeader("Setup") { + Chip(if (readyCount == checks) "● All set" else "$readyCount of $checks ready", ok = readyCount == checks) + } + TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus) + Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { + when (tab) { + 0 -> { + SectionTitle("System check", null) + // What Steam needs, one row each: green when done, one button when not. The + // process-limit controls only open under their row. + Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { + CheckRow( + if (gpuOk) CheckState.OK else CheckState.WARN, + if (gpuOk) "Device supported" else "GPU not supported", + if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen", + ) + CheckRow( + when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK }, + "Linux runtime", + when { + s.busy -> if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage + !s.ready -> "Not installed · about 3 GB, installed on the first Play" + s.available != null && s.available != s.installed -> "${s.installed ?: "Installed"} · update available" + else -> "${s.installed ?: "Installed"} · up to date" + }, + ) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) } + CheckRow( + if (limitBlocks) CheckState.WARN else CheckState.OK, + if (limitBlocks) "Android may close Steam" else "Android process limit", + if (limitBlocks) "“Restrict child processes” is on - it takes a minute to turn off" else PhantomProcessLimit.title(s.phantomProcessStatus), + ) { + if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails } + else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { + SecondaryButton(if (showLimitDetails) "Hide" else "Details", compact = true) { showLimitDetails = !showLimitDetails } + } + } + AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) { + Text( + PhantomProcessLimit.instructions(s.phantomProcessStatus), + fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) { + SecondaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions) + SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus) + SecondaryButton("Copy ADB command", compact = true, onClick = { a.onCopyPhantomCommand(false) }) + } + Text( + PhantomProcessLimit.ADB_COMMAND, fontSize = 12.sp, fontFamily = FontFamily.Monospace, + color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), + ) + if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { + Text( + "With wireless debugging paired, DroidDeck can switch the limit itself. Turning it off is recommended for Steam sessions.", + fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) { + SecondaryButton("Turn limit off", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.DISABLED) { setProcessLimit(false) } + SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.ENABLED) { setProcessLimit(true) } + } + if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) + processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) } + } + } + } + CheckRow( + if (signedIn) CheckState.OK else CheckState.WARN, + "Steam account", + s.offlineAccount?.let { if (s.offline) "Signed in as $it · offline mode" else "Signed in as $it" } ?: "Press Play and sign in to Steam", + divider = false, + ) + } + SectionTitle("Tools", null) + ToolGrid(s, a) + } + 1 -> { + val controller = s.controller + if (controller != null && a.controller != null) SettingsGroup("Controller") { + ControllerRows(host, s.oscMode, controller, a.controller) + } + if (s.controller == null || a.controller == null) Note("Controller settings are unavailable.") + } + 2 -> { + SettingsGroup("Session") { + ChoiceRow( + host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted), + listOf( + false to SessionPrefs.BACK_MENU_THEN_QAM, + true to SessionPrefs.BACK_QAM_THEN_MENU, + ), s.backActionsInverted, onPick = a.onBackActionsInverted, + ) + SettingsRow("Frame generation", "Select the frame generation mode") { + Box { + ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" } + FrameGenMenu(s, a, host) + } + } + SettingsRow("Session logs", "${if (s.logsEnabled) "Enabled" else "Disabled"} · logs are saved after each session") { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SecondaryButton(if (s.logsEnabled) "Turn off" else "Turn on") { a.onLogs() } + SecondaryButton("Share latest") { a.onShareLogs() } + } + } + SettingsRow("Offline mode", s.offlineAccount?.let { if (s.offline) "Enabled for $it" else "Signed in as $it" } ?: "Sign in to Steam first") { + SecondaryButton(if (s.offline) "Turn off" else "Turn on", enabled = s.offlineAccount != null) { a.onOffline() } + } + } + } + 3 -> { + SettingsGroup("Launcher") { + SettingsRow("Theme", "Choose the launcher appearance") { + Box { + ValueChip(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 = "Theme") { firstItemFocus -> + Themes.all.forEachIndexed { index, theme -> + MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) { + a.onTheme(theme.id) + host.open = null + } + } + } + } + } + ToggleRow( + host, "home-screen", "Use as a Home screen", + if (s.homeScreenEnabled) "DroidDeck can be the phone's Home app" else "Off: DroidDeck is never offered as a Home app", + s.homeScreenEnabled, + ) { a.onHomeScreen(it) } + ToggleRow( + host, "launcher-fullscreen", "Fullscreen", + if (s.launcherFullscreen) "Hide the Android status and navigation bars" else "Show the Android status and navigation bars", + s.launcherFullscreen, + ) { a.onLauncherFullscreen(it) } + if (s.homeScreenEnabled) { + ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp) + } + } + } + else -> { + SettingsGroup("About") { + ActionRow("Build", s.buildLabel, "Check for newer", a.onCheckLatestBuild) + ActionRow("Credits", "The people and projects DroidDeck builds on", "View", a.onCredits) + } + } + } + } + } + } +} + +/** The launcher's own tools as cards: four across, two by two on a narrow page. */ +@Composable +private fun ToolGrid(s: FrontEndState, a: FrontEndActions) { + val columns = if (LocalNarrowPane.current) 2 else 4 + val tools = listOf( + ToolSpec(Icons.Outlined.Folder, "Files", "Browse and manage files", a.onFiles), + ToolSpec(Icons.Outlined.Extension, "Compatibility tools", "Install ARM64 Proton builds", a.onProtons), + ToolSpec(Icons.Outlined.Speed, "Performance", "CPU core assignment", a.onPerformance), + ToolSpec(Icons.Outlined.VideogameAsset, "ROMs folder", s.romsDir ?: "Choose where emulator games are stored", a.onRoms), + ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + for (row in tools.chunked(columns)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight()) + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit) + +@Composable +private fun ToolCard(t: ToolSpec, modifier: Modifier) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale") + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.paneItem("tool:${t.title}") + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(Shape14) + .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick) + .controllerConfirm(onClick = t.onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) + Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) { + AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> + val need = if (s.lsfgReady) null else "Install Lossless Scaling in Steam" + MenuItem("Off", checked = s.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null } + for (m in 2..4) MenuItem("Win-FG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_WINFG && s.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null } + for (m in 2..4) MenuItem("LSFG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_LSFG && s.frameGenMultiplier == m, enabled = s.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null } + } +} + +/** + * What shapes a launch, beside the game rather than three screens away in Setup. They are the + * app-wide settings - each card opens the same page or menu Setup does. + */ +@Composable +internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) { + // 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 cards = buildList<@Composable (Modifier) -> Unit> { + add { m -> SettingCard("Compatibility", "Proton & components", "card:components", m) { a.onComponents(true) } } + add { m -> + Box(m) { + SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { + host.open = if (host.open == "fg") null else "fg" + } + FrameGenMenu(s, a, host) + } + } + if (controller != null) add { m -> SettingCard("Controls", "Button mapping", "card:controls", m, controller.onMapping) } + } + 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)) { + for (card in row) card(Modifier.weight(1f).fillMaxHeight()) + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +@Composable +private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale") + val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge") + Column( + verticalArrangement = Arrangement.spacedBy(3.dp), + modifier = modifier.paneItem(id) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(Shape14) + .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .border(if (hot) 2.dp else 1.dp, edge, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Text(label, fontSize = 13.sp, color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt new file mode 100644 index 000000000..d365ca0aa --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -0,0 +1,226 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import android.provider.Settings +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +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 + +// Small building blocks shared by the front end's pages: text styles, chips and buttons. + +internal val Shape14 = RoundedCornerShape(14.dp) + +@Composable +internal fun Eyebrow(t: String) { + val pal = LocalPalette.current + val rule = remember { Animatable(0f) } + LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal)) + Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal) + } +} + +@Composable internal fun Title(t: String) = Text(t, fontSize = 26.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp)) + +@Composable internal fun Lede(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp)) + +@Composable +internal fun SectionTitle(t: String, detail: String?) { + val colors = MaterialTheme.colorScheme + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) { + Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant) + if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground) + Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line)) + } +} + +@Composable internal fun Note(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().clip(Shape12).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line2, Shape12).padding(12.dp)) + +/** A page's buttons: they wrap onto a second line rather than run off a narrow page. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun Actions(content: @Composable () -> Unit) = FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), +) { content() } + +/** A status chip in a row of buttons, centred on their height. */ +@Composable +internal fun ActionChip(t: String, ok: Boolean) = Box(contentAlignment = Alignment.Center, modifier = Modifier.heightIn(min = 44.dp)) { Chip(t, ok) } + +internal val Shape16 = RoundedCornerShape(16.dp) + +/** One level up, as Back and B do: a real button, big enough to hit ([compact]: for the tightest layouts). */ +@Composable +internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(22.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 36.dp else 44.dp) + .clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f)) + .border(2.dp, if (hot) pal.signal else Color.Transparent, shape) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(start = 8.dp, end = 16.dp), + ) { + Icon(Icons.Filled.ChevronLeft, contentDescription = null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(22.dp)) + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +internal fun Chip(t: String, ok: Boolean) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Text( + t, fontSize = 12.sp, color = if (ok) pal.good else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(colors.surfaceVariant).border(1.dp, if (ok) pal.good.copy(alpha = 0.3f) else pal.line, RoundedCornerShape(99.dp)).padding(horizontal = 9.dp, vertical = 4.dp), + ) +} + +@Composable +internal fun rememberHot(src: MutableInteractionSource): Boolean = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + +@Composable +internal fun PrimaryButton( + text: String, + enabled: Boolean = true, + main: Boolean = false, + compact: Boolean = false, + modifier: Modifier = Modifier, + /** The Steam tab's own Play: bigger than any other button on a page. */ + large: Boolean = false, + icon: ImageVector? = null, + onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + // The page's main button is where the pane is entered from the rail. + val frontFocus = if (main) LocalFrontFocus.current else null + if (frontFocus != null) DisposableEffect(Unit) { + frontFocus.primaryAttached++ + onDispose { frontFocus.primaryAttached-- } + } + val track = (if (frontFocus == null) Modifier.paneItem("btn:$text") else Modifier).downToFirstTile() + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) && enabled + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "btnScale") + val lift by animateFloatAsState(if (hot) 14f else 6f, Motion.tw(300), label = "btnLift") + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.then(track) + .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal } + .clip(Shape12) + .background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant))) + .shine(hot, 0.45f) + // The grow and shine alone barely show on the light fill: outline it when a + // controller is on it, as the other controls are. + .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .padding( + start = if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp, + end = if (large) 26.dp else if (compact) 10.dp else 18.dp, + top = if (large) 16.dp else if (compact) 7.dp else 11.dp, + bottom = if (large) 16.dp else if (compact) 7.dp else 11.dp, + ), + ) { + val fg = if (enabled) colors.onPrimary else colors.onSurfaceVariant + if (icon != null) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (large) 20.dp else 17.dp)) + Text( + text, fontSize = if (large) 17.sp else if (compact) 12.sp else 15.sp, + fontWeight = if (large) FontWeight.Bold else FontWeight.SemiBold, letterSpacing = 0.5.sp, color = fg, maxLines = 1, + ) + } +} + +@Composable +internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) && enabled + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale") + val pal = LocalPalette.current + val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge") + val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill") + Box( + modifier = modifier.paneItem("btn:$text").downToFirstTile().graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12) + .alpha(if (enabled) 1f else 0.5f) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .padding(horizontal = if (compact) 10.dp else 16.dp, vertical = if (compact) 7.dp else 11.dp), + ) { Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1) } +} + +@Composable +internal fun Cog(size: androidx.compose.ui.unit.Dp = 42.dp, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog") + val pal = LocalPalette.current + val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), + ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt new file mode 100644 index 000000000..acbd90281 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -0,0 +1,268 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.runtime.produceState +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import com.droiddeck.launcher.frontend.Library +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.material.icons.Icons +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material.icons.filled.Pause +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Icon +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.border +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow + +// The session's loading screen, its paused screen and the HUD text style. + +@Composable +fun HudText(text: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) { + Text( + text, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = Color.White, + modifier = Modifier + .padding(12.dp) + .background(Color(0x8C000000)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +/** One line of the loading screen's checklist; [detail] is what it says while it is the one running. */ +private class LoadStage(val label: String, val detail: String) + +private fun loadStages(steam: Boolean): List = if (steam) listOf( + LoadStage("Linux runtime", "Getting the Linux runtime ready"), + LoadStage("Session", "Starting the display and audio"), + LoadStage("Steam client", "Checking the Steam client and its compatibility tools"), + LoadStage("Opening Steam", "Waiting for Steam's first frame"), +) else listOf( + LoadStage("Linux runtime", "Getting the Linux runtime ready"), + LoadStage("Desktop", "Getting the desktop ready"), + LoadStage("Opening", "Waiting for the first frame"), +) + +/** + * The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines + * are the session script's "== STEP" milestones and the installer's own progress; they arrive in + * order, and the screen never steps back. + */ +private fun stageOf(step: String, steam: Boolean): Int { + val t = step.lowercase() + return when { + "linux runtime" in t -> 0 + "starting the session" in t -> 1 + !steam && "desktop" in t -> 1 + steam && "starting the steam client" in t -> 3 + steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2 + else -> -1 + } +} + +/** Download and install lines are worth reading as they are; the script's own notes are not. */ +private fun readableStep(step: String): Boolean { + val t = step.lowercase() + return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing") +} + +@Composable +fun LoadingOverlay( + step: String, percent: Int, elapsed: String, hint: String, ended: Boolean, + title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null, +) { + val colors = MaterialTheme.colorScheme + val stages = remember(steam) { loadStages(steam) } + var reached by remember { mutableStateOf(0) } + LaunchedEffect(step, steam) { + val at = stageOf(step, steam) + if (at > reached) reached = at + } + val active = reached.coerceAtMost(stages.lastIndex) + val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail + val context = LocalContext.current + // The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in. + val games by produceState(emptyList()) { + value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) } + } + Box( + modifier = Modifier + .fillMaxSize() + .background(colors.background) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, + ) { + CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f)) + Box( + Modifier.fillMaxSize().drawBehind { + drawRect( + Brush.radialGradient( + 0f to colors.background.copy(alpha = 0.94f), + 0.6f to colors.background.copy(alpha = 0.72f), + 1f to colors.background.copy(alpha = 0.55f), + center = Offset(size.width / 2f, size.height * 0.46f), radius = maxOf(size.width, size.height) * 0.55f, + ), + ) + }, + ) + Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.7f to Color.Transparent, 1f to colors.background.copy(alpha = 0.95f)))) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp), + ) { + LogoThrobber(Modifier.width(if (ended) 88.dp else 110.dp), running = !ended) + Spacer(Modifier.height(30.dp)) + Text( + if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, + color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + Text( + if (ended) step else detail, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onSurfaceVariant, textAlign = TextAlign.Center, + maxLines = if (ended) 12 else 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp), + ) + if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp)) + } + if (!ended && hint.isNotEmpty()) Text( + hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center, + maxLines = 3, overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp), + ) + if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp)) + } +} + +/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */ +@Composable +private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) { + val pal = LocalPalette.current + val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f) + val run = rememberInfiniteTransition(label = "segment") + .animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run") + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" }, + ) { + repeat(count) { i -> + Box( + Modifier.width(44.dp).height(3.dp).clip(RoundedCornerShape(2.dp)) + .background(if (i < active) pal.signal else track) + .drawBehind { + if (i != active) return@drawBehind + if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height)) + else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height)) + }, + ) + } + } +} + +/** Cancel as the pad's B, in the corner; a tap works too. */ +@Composable +private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val src = remember { MutableInteractionSource() } + val hot = src.collectIsHoveredAsState().value || src.collectIsFocusedAsState().value + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(22.dp)) + .background(if (hot) colors.onBackground.copy(alpha = 0.08f) else Color.Transparent) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onCancel) + .padding(horizontal = 12.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) { + Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background) + } + Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant) + } +} + +/** A session paused in the background, ready to pick up where it left off. */ +@Composable +fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val interactionSource = remember { MutableInteractionSource() } + val resumeFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + androidx.compose.runtime.withFrameNanos { } + runCatching { resumeFocus.requestFocus() } + } + Box( + modifier = Modifier + .fillMaxSize() + .background(pal.background.copy(alpha = 0.94f)) + .clickable(interactionSource = interactionSource, indication = null) {}, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.widthIn(max = 440.dp).padding(32.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) { + Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp)) + } + Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center) + PrimaryButton( + "Resume", + modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume), + onClick = onResume, + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 7082fc5b4..82872433f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -1,31 +1,15 @@ package com.droiddeck.launcher.ui -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.runtime.produceState import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role -import com.droiddeck.launcher.frontend.Library -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.heightIn -import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.MoreHoriz import androidx.compose.material.icons.outlined.PowerSettingsNew import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Settings @@ -54,10 +38,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.ScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.AlertDialog @@ -70,12 +52,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.droiddeck.launcher.R import com.droiddeck.launcher.HomeApp import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -92,7 +70,6 @@ import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -121,8 +98,10 @@ import com.droiddeck.launcher.input.SecondScreenMode import kotlinx.coroutines.flow.collect private val drawerPageTitles = listOf("Display", "Controls", "Components", "Settings") + /** One icon per drawer page, in page order (QAM-style tabs). */ private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings) + private val drawerPageEntries = listOf("hud", "touch", "cmp-proton", "suspend") private class DrawerFocus { @@ -145,205 +124,6 @@ private class DrawerFocus { fun forget(page: Int) { last[page] = null } } -@Composable -fun HudText(text: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) { - Text( - text, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = Color.White, - modifier = Modifier - .padding(12.dp) - .background(Color(0x8C000000)) - .padding(horizontal = 8.dp, vertical = 3.dp), - ) - } -} - -/** One line of the loading screen's checklist; [detail] is what it says while it is the one running. */ -private class LoadStage(val label: String, val detail: String) - -private fun loadStages(steam: Boolean): List = if (steam) listOf( - LoadStage("Linux runtime", "Getting the Linux runtime ready"), - LoadStage("Session", "Starting the display and audio"), - LoadStage("Steam client", "Checking the Steam client and its compatibility tools"), - LoadStage("Opening Steam", "Waiting for Steam's first frame"), -) else listOf( - LoadStage("Linux runtime", "Getting the Linux runtime ready"), - LoadStage("Desktop", "Getting the desktop ready"), - LoadStage("Opening", "Waiting for the first frame"), -) - -/** - * The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines - * are the session script's "== STEP" milestones and the installer's own progress; they arrive in - * order, and the screen never steps back. - */ -private fun stageOf(step: String, steam: Boolean): Int { - val t = step.lowercase() - return when { - "linux runtime" in t -> 0 - "starting the session" in t -> 1 - !steam && "desktop" in t -> 1 - steam && "starting the steam client" in t -> 3 - steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2 - else -> -1 - } -} - -/** Download and install lines are worth reading as they are; the script's own notes are not. */ -private fun readableStep(step: String): Boolean { - val t = step.lowercase() - return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing") -} - -@Composable -fun LoadingOverlay( - step: String, percent: Int, elapsed: String, hint: String, ended: Boolean, - title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null, -) { - val colors = MaterialTheme.colorScheme - val stages = remember(steam) { loadStages(steam) } - var reached by remember { mutableStateOf(0) } - LaunchedEffect(step, steam) { - val at = stageOf(step, steam) - if (at > reached) reached = at - } - val active = reached.coerceAtMost(stages.lastIndex) - val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail - val context = LocalContext.current - // The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in. - val games by produceState(emptyList()) { - value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) } - } - Box( - modifier = Modifier - .fillMaxSize() - .background(colors.background) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, - ) { - CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f)) - Box( - Modifier.fillMaxSize().drawBehind { - drawRect( - Brush.radialGradient( - 0f to colors.background.copy(alpha = 0.94f), - 0.6f to colors.background.copy(alpha = 0.72f), - 1f to colors.background.copy(alpha = 0.55f), - center = Offset(size.width / 2f, size.height * 0.46f), radius = maxOf(size.width, size.height) * 0.55f, - ), - ) - }, - ) - Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.7f to Color.Transparent, 1f to colors.background.copy(alpha = 0.95f)))) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp), - ) { - LogoThrobber(Modifier.width(if (ended) 88.dp else 110.dp), running = !ended) - Spacer(Modifier.height(30.dp)) - Text( - if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, - color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - Text( - if (ended) step else detail, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onSurfaceVariant, textAlign = TextAlign.Center, - maxLines = if (ended) 12 else 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp), - ) - if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp)) - } - if (!ended && hint.isNotEmpty()) Text( - hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center, - maxLines = 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp), - ) - if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp)) - } -} - -/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */ -@Composable -private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) { - val pal = LocalPalette.current - val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f) - val run = rememberInfiniteTransition(label = "segment") - .animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run") - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" }, - ) { - repeat(count) { i -> - Box( - Modifier.width(44.dp).height(3.dp).clip(RoundedCornerShape(2.dp)) - .background(if (i < active) pal.signal else track) - .drawBehind { - if (i != active) return@drawBehind - if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height)) - else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height)) - }, - ) - } - } -} - -/** Cancel as the pad's B, in the corner; a tap works too. */ -@Composable -private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) { - val colors = MaterialTheme.colorScheme - val src = remember { MutableInteractionSource() } - val hot = src.collectIsHoveredAsState().value || src.collectIsFocusedAsState().value - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(22.dp)) - .background(if (hot) colors.onBackground.copy(alpha = 0.08f) else Color.Transparent) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onCancel) - .padding(horizontal = 12.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) { - Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background) - } - Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant) - } -} - -/** A session paused in the background, ready to pick up where it left off. */ -@Composable -fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val interactionSource = remember { MutableInteractionSource() } - val resumeFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { - androidx.compose.runtime.withFrameNanos { } - runCatching { resumeFocus.requestFocus() } - } - Box( - modifier = Modifier - .fillMaxSize() - .background(pal.background.copy(alpha = 0.94f)) - .clickable(interactionSource = interactionSource, indication = null) {}, - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.widthIn(max = 440.dp).padding(32.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) { - Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp)) - } - Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center) - PrimaryButton( - "Resume", - modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume), - onClick = onResume, - ) - } - } -} - /** Everything the drawer shows and does. */ class DrawerActions( val steam: Boolean, @@ -859,6 +639,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo /** Display, Controls, Components, Settings. */ const val DRAWER_PAGES = 4 + private const val DRAWER_PAGE_COMPONENTS = 2 /** @@ -987,6 +768,9 @@ private fun DrawerStackedChoice( /** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */ private const val DRAWER_TAB_SPACING_DP = 64f + private const val DRAWER_TAB_LEAN_DP = 12f + private const val DRAWER_TAB_SIDE_SCALE = 0.5f + private const val DRAWER_TAB_SIDE_ALPHA = 0.7f diff --git a/app/src/test/java/com/droiddeck/launcher/core/ArchivePathsTest.kt b/app/src/test/java/com/droiddeck/launcher/core/ArchivePathsTest.kt new file mode 100644 index 000000000..0f8ac08b4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/ArchivePathsTest.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.core + +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class ArchivePathsTest { + private lateinit var root: File + private lateinit var dest: File + + @Before fun setUp() { + root = Files.createTempDirectory("archive-paths").toFile() + dest = File(root, "dest").apply { mkdirs() } + } + + @After fun tearDown() { + root.deleteRecursively() + } + + @Test fun plainEntryLandsInside() { + assertEquals(File(dest, "usr/bin/sh").canonicalPath, ArchivePaths.inside(dest, "usr/bin/sh")!!.canonicalPath) + } + + @Test fun dotEntryIsTheDestination() { + assertEquals(dest.canonicalPath, ArchivePaths.inside(dest, "./")!!.canonicalPath) + } + + @Test fun parentTraversalIsRefused() { + assertNull(ArchivePaths.inside(dest, "../escape")) + assertNull(ArchivePaths.inside(dest, "usr/../../escape")) + } + + @Test fun siblingWithSharedPrefixIsRefused() { + File(root, "dest-other").mkdirs() + assertNull(ArchivePaths.inside(dest, "../dest-other/x")) + } + + @Test fun symlinkOutOfTheTreeIsRefused() { + Files.createSymbolicLink(File(dest, "link").toPath(), root.toPath()) + assertNull(ArchivePaths.inside(dest, "link/escape")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/HashesTest.kt b/app/src/test/java/com/droiddeck/launcher/core/HashesTest.kt new file mode 100644 index 000000000..dda2151fe --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/HashesTest.kt @@ -0,0 +1,41 @@ +package com.droiddeck.launcher.core + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HashesTest { + private fun file(text: String): File = + Files.createTempFile("hashes", ".bin").toFile().apply { writeText(text); deleteOnExit() } + + @Test fun sha256OfKnownInput() { + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Hashes.sha256(file("abc"))) + } + + @Test fun sha512OfKnownInput() { + assertEquals( + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a" + + "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f", + Hashes.sha512(file("abc")), + ) + } + + @Test fun githubDigestIsRecognised() { + val hex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + assertTrue(Hashes.isGithubSha256("sha256:$hex")) + assertEquals(hex, Hashes.githubSha256("sha256:$hex")) + assertEquals(hex.uppercase(), Hashes.githubSha256("SHA256:${hex.uppercase()}")) + } + + @Test fun anythingElseIsNotADigest() { + assertFalse(Hashes.isGithubSha256(null)) + assertFalse(Hashes.isGithubSha256("")) + assertFalse(Hashes.isGithubSha256("sha256:abc")) + assertFalse(Hashes.isGithubSha256("sha512:" + "0".repeat(64))) + assertNull(Hashes.githubSha256("md5:0123")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt new file mode 100644 index 000000000..88f2a8703 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt @@ -0,0 +1,58 @@ +package com.droiddeck.launcher.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogRedactorTest { + @Test fun labelledJwtIsRedacted() { + val out = LogRedactor.redact("Using JWT 25484942796017334") + assertFalse(out.contains("25484942796017334")) + assertTrue(out.contains("")) + } + + @Test fun secretPairKeepsItsKey() { + val out = LogRedactor.redact("GET /x?sessionid=abcdef123456 200") + assertFalse(out.contains("abcdef123456")) + assertTrue(out.contains("sessionid=")) + } + + @Test fun plainKeyValueSurvives() { + assertEquals("language key=english", LogRedactor.redact("language key=english")) + } + + @Test fun steamIdIsMaskedNotDeleted() { + assertEquals("user 76561********1234", LogRedactor.redact("user 76561198012341234")) + } + + @Test fun webApiKeyIsRedactedButDepotChunkIsKept() { + val key = "0123456789abcdef0123456789abcdef" + val chunk = "0123456789abcdef0123456789abcdef01234567" + assertFalse(LogRedactor.redact("key $key").contains(key)) + assertTrue(LogRedactor.redact("chunk $chunk").contains(chunk)) + } + + @Test fun emailIsRedacted() { + assertEquals("login ok", LogRedactor.redact("login someone@example.com ok")) + } + + @Test fun clockTimeIsNotAnAddress() { + val line = "[2026-09-27 10:03:05] Startup" + assertEquals(line, LogRedactor.redact(line)) + } + + @Test fun redactingTwiceChangesNothing() { + val lines = listOf( + "Using JWT 25484942796017334", + "token=abcdefghijklmnop sessionid=qrstuvwxyz123", + "user 76561198012341234 [U:1:52075506]", + "external address 2607:f8b0:4005:80a::200e", + "OnLoginStateChange someaccount 2 1 0 0", + ) + for (line in lines) { + val once = LogRedactor.redact(line) + assertEquals(once, LogRedactor.redact(once)) + } + } +}