From 685fd89b1c388ee0e1fe2d27fadd5400f2b1fd4b Mon Sep 17 00:00:00 2001 From: 404-GCross Date: Fri, 5 Jun 2026 18:17:26 +0800 Subject: [PATCH 001/211] add NoRoot kernel for XiaomiPad8Pro & Xiaomi 15 (#182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 微调 README_CN.md 与社区支持设备文档措辞 * 修复 GPU-Acceleration_CN 缺少 Rootfs 仓库引用链接 * 翻译 community-supported-devices_CN 新增的 WildKernels OnePlus 设备表格(96行) * 翻译 community-supported-devices_CN 中 none→无、Working→正常运行 * 翻译社区支持设备状态选项:正常运行/部分可用/不可用 * 修复社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 --- Documentation/community-supported-devices.md | 4 ++-- Documentation/zh-CN/community-supported-devices.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 93ebc4b4..e3c72523 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -67,8 +67,8 @@ This document is a community-maintained compatibility list for Android devices k | **红魔 11 Pro (Chinese variant)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **HONOR Magic Pad 3 Pro 13.3 (Chinese variant)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **Xiaomi Pad 7 (Chinese variant)** | 2410CRP4CC | HyperOS 2.0 - Android 15 | `6.1.118` | Sukisu Ultra | [Source](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS.git) | [Download](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS/releases/tag/release) | Both | Turnip & Zink | Working | [HuangJunLin8](https://github.com/HuangJunLin8) | Supports NTsync | -| **Xiaomi Pad 8 Pro (Chinese variant)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github%40404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
Supports NTsync | -| **Xiaomi 15 (Chinese variant)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github%40404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
Supports NTsync | +| **Xiaomi Pad 8 Pro (Chinese variant)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu & NoRoot | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
[NoRoot](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
Supports NTsync
ReSukiSU version 34924 | +| **Xiaomi 15 (Chinese variant)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu & NoRoot | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
[NoRoot](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
Supports NTsync
ReSukiSU version 34924 | | **Xiaomi 14 (Chinese variant)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | Working | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | Applicable to the international version | | **红魔 9 Pro (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | | **红魔 9 SPro+ (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | diff --git a/Documentation/zh-CN/community-supported-devices.md b/Documentation/zh-CN/community-supported-devices.md index 4aee25dd..1e2d623b 100644 --- a/Documentation/zh-CN/community-supported-devices.md +++ b/Documentation/zh-CN/community-supported-devices.md @@ -67,8 +67,8 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | **红魔 11 Pro(国行)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **HONOR Magic Pad 3 Pro 13.3(国行)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **Xiaomi Pad7(国行)** | 2410CRP4CC | HyperOS 2.0 - Android 15 | `6.1.118` | Sukisu Ultra | [Source](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS.git) | [Download](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS/releases/tag/release) | Both | Turnip & Zink | 正常运行 | [HuangJunLin8](https://github.com/HuangJunLin8) | 支持 NTsync | -| **Xiaomi Pad 8 Pro(国行)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github%40404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
支持 NTsync | -| **Xiaomi 15(国行)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github%40404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
支持 NTsync | +| **Xiaomi Pad 8 Pro(国行)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu
&
无Root | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
[无Root](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
支持 NTsync
ReSukiSU版本为34924 | +| **Xiaomi 15(国行)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu
&
无Root | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
[无Root](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
支持 NTsync
ReSukiSU版本为34924 | | **Xiaomi 14(国行)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | 正常运行 | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | 适用于国际版 | | **红魔 9 Pro(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | | **红魔 9 SPro+(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | From b697ef13df1ddade416051889d59309d90e76e06 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Fri, 5 Jun 2026 14:38:28 +0530 Subject: [PATCH 002/211] selinux: unify daemon domains, simplify dyntransition API - monitor, virgl, pulse: explicitly enter droidspacesd via new ds_selinux_enter_domain() -- permissive, zero enforced denials - x11: stays on untrusted_app_27 (app_process requires it); dyntransition() loses dead applied_ctx output param - untrusted_app_27 first in domain trial order to match real Termux - sepolicy: scope fd/fifo_file rules to untrusted_app_27 (not untrusted_app); add audioserver/mediaserver fd use for PulseAudio binder path --- .../src/main/assets/boot-module/sepolicy.rule | 5 ++-- src/android.c | 18 ++++++--------- src/droidspace.h | 6 ++--- src/monitor.c | 5 ++++ src/pulseaudio-android.c | 7 +++++- src/utils.c | 23 +++++++++++++------ src/virgl-android.c | 3 +++ src/x11.c | 8 +++---- 8 files changed, 46 insertions(+), 29 deletions(-) diff --git a/Android/app/src/main/assets/boot-module/sepolicy.rule b/Android/app/src/main/assets/boot-module/sepolicy.rule index a335b653..e80d8656 100644 --- a/Android/app/src/main/assets/boot-module/sepolicy.rule +++ b/Android/app/src/main/assets/boot-module/sepolicy.rule @@ -63,10 +63,11 @@ allow system_server droidspacesd fd use allow system_server droidspacesd fifo_file write # for Termux:X11 and DRI3 denials in daemon mode -allow untrusted_app droidspacesd fd use -allow untrusted_app droidspacesd fifo_file write allow untrusted_app_27 droidspacesd fd use +allow untrusted_app_27 droidspacesd fifo_file write # for pulseAudio allow audioserver droidspacesd binder { call transfer } +allow audioserver droidspacesd fd use allow mediaserver droidspacesd binder { call transfer } +allow mediaserver droidspacesd fd use diff --git a/src/android.c b/src/android.c index 3e3cf7e8..fecd0b05 100644 --- a/src/android.c +++ b/src/android.c @@ -156,15 +156,15 @@ int android_setup_storage(const char *rootfs_path) { * Shared by x11.c and pulseaudio-android.c. Android-specific. * ---------------------------------------------------------------------------*/ -/* SELinux domains to try, newest first */ +/* SELinux domains to try -- untrusted_app_27 first (matches real Termux) */ static const char *const untrusted_domains[] = { - "u:r:untrusted_app", "u:r:untrusted_app_32", "u:r:untrusted_app_30", - "u:r:untrusted_app_29", "u:r:untrusted_app_27", "u:r:untrusted_app_25", + "u:r:untrusted_app_27", "u:r:untrusted_app_30", "u:r:untrusted_app_29", + "u:r:untrusted_app_25", "u:r:untrusted_app_32", "u:r:untrusted_app", }; /* * Extract MLS categories from a full SELinux context string. - * e.g. "u:object_r:app_data_file:s0:c78,c257,c512,c768" + * e.g. "u:object_r:app_data_file:s0:c80,c257,c512,c768" * ^ returned pointer */ const char *ds_extract_mls(const char *ctx) { @@ -177,12 +177,11 @@ const char *ds_extract_mls(const char *ctx) { /* * Transition the calling process into an untrusted_app SELinux domain - * with the given MLS categories. Tries newest API-level domain first. + * with the given MLS categories. Tries untrusted_app_27 first to match + * the real Termux process context. * Also clears /proc/self/attr/exec to prevent label leaking. - * Fills applied_ctx (if non-NULL) with the context string that succeeded. */ -void ds_selinux_dyntransition(const char *mls, char *applied_ctx, - size_t ctx_size) { +void ds_selinux_dyntransition(const char *mls) { char target[256] = ""; int fd = open("/proc/self/attr/current", O_WRONLY | O_CLOEXEC); if (fd >= 0) { @@ -202,9 +201,6 @@ void ds_selinux_dyntransition(const char *mls, char *applied_ctx, } close(fd); } - - if (applied_ctx && ctx_size > 0) - snprintf(applied_ctx, ctx_size, "%s", target); } /* diff --git a/src/droidspace.h b/src/droidspace.h index 8345307b..ed6a56cf 100644 --- a/src/droidspace.h +++ b/src/droidspace.h @@ -456,7 +456,6 @@ void firmware_path_remove(const char *fw_path); int run_command(char *const argv[]); int run_command_quiet(char *const argv[]); int run_command_log(char *const argv[]); -int get_selinux_context(const char *path, char *buf, size_t size); int set_selinux_context(const char *path, const char *context); int ds_send_fd(int sock, int fd); int ds_recv_fd(int sock); @@ -538,9 +537,10 @@ int android_seccomp_setup(int is_systemd, int block_nested_ns, int ds_seccomp_apply_minimal(int privileged_mask); /* SELinux + Termux privilege helpers */ +int get_selinux_context(const char *path, char *buf, size_t size); const char *ds_extract_mls(const char *ctx); -void ds_selinux_dyntransition(const char *mls, char *applied_ctx, - size_t ctx_size); +void ds_selinux_dyntransition(const char *mls); +void ds_selinux_enter_domain(void); int ds_drop_privileges(int uid); int ds_resolve_termux_uid(void); diff --git a/src/monitor.c b/src/monitor.c index f84f7abe..a596e818 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -41,6 +41,11 @@ void ds_monitor_run(struct ds_config *cfg, int sync_pipe_write) { /* Make monitor unkillable */ ds_oom_protect(); + /* Enter droidspacesd domain. Best-effort: if policy not yet loaded + * (user hasn't rebooted after module install), we stay in the inherited + * domain -- still functional since droidspacesd is typepermissive. */ + ds_selinux_enter_domain(); + prctl(PR_SET_NAME, "[ds-monitor]", 0, 0, 0); /* Unshare namespaces - Monitor enters new UTS, IPC, and optionally Cgroup diff --git a/src/pulseaudio-android.c b/src/pulseaudio-android.c index 53dfe33d..96761442 100644 --- a/src/pulseaudio-android.c +++ b/src/pulseaudio-android.c @@ -75,6 +75,10 @@ static void pulse_child_wrapper(int ready_fd, void *user_data) { /* Ensure the tmp directory exists (root creates it before priv drop) */ mkdir_p(TX11_PREFIX "/tmp", 0755); + /* Stay in droidspacesd (permissive) -- no domain transition needed. + * untrusted_app_27 blocks execv of Termux binaries under enforcing. */ + ds_selinux_enter_domain(); + /* Drop root -> Termux UID. */ if (ds_drop_privileges(args->uid) < 0) { perror("[PulseAudio] privilege drop failed"); @@ -131,7 +135,8 @@ static void run_pactl_set_default(int uid) { close(devnull); } - /* Run as the same Termux UID with the same supplementary groups */ + /* Stay in droidspacesd (permissive) */ + ds_selinux_enter_domain(); if (ds_drop_privileges(uid) < 0) { _exit(1); } diff --git a/src/utils.c b/src/utils.c index 1151d9c2..30d219c0 100644 --- a/src/utils.c +++ b/src/utils.c @@ -1465,23 +1465,32 @@ int get_selinux_context(const char *path, char *buf, size_t size) { if (!path || !buf || size == 0) return -1; - /* Use lgetxattr to read the security.selinux attribute */ ssize_t len = lgetxattr(path, "security.selinux", buf, size - 1); - if (len < 0) { #ifdef SYS_lgetxattr + if (len < 0) len = syscall(SYS_lgetxattr, path, "security.selinux", buf, size - 1); #endif - } - - /* FIX: Check bounds before writing null terminator */ - if (len < 0 || len >= (ssize_t)(size - 1)) { + if (len < 0 || len >= (ssize_t)(size - 1)) return -1; - } buf[len] = '\0'; return 0; } +/* Transition self into u:r:droidspacesd:s0. + * Best-effort: if the write fails (policy not loaded, permissive host), + * the caller inherits whatever domain the root process already holds. + * No MLS suffix -- droidspacesd is declared permissive in sepolicy.rule. */ +void ds_selinux_enter_domain(void) { + const char *ctx = "u:r:droidspacesd:s0"; + int fd = open("/proc/self/attr/current", O_WRONLY | O_CLOEXEC); + if (fd < 0) + return; + if (write(fd, ctx, strlen(ctx) + 1) < 0) { /* ignore -- best effort */ + } + close(fd); +} + int ds_get_selinux_status(void) { char buf[16]; if (read_file("/sys/fs/selinux/enforce", buf, sizeof(buf)) < 0) diff --git a/src/virgl-android.c b/src/virgl-android.c index 5f9b7183..e632b455 100644 --- a/src/virgl-android.c +++ b/src/virgl-android.c @@ -42,6 +42,9 @@ static void virgl_child_wrapper(int ready_fd, void *user_data) { /* Make VirGL server unkillable */ ds_oom_protect(); + /* Enter droidspacesd domain -- best-effort, fallback on pre-reboot */ + ds_selinux_enter_domain(); + fprintf(stdout, "[VirGL] uid=%d starting server\n", (int)getuid()); fflush(stdout); diff --git a/src/x11.c b/src/x11.c index 86475f8d..cb2f269f 100644 --- a/src/x11.c +++ b/src/x11.c @@ -114,12 +114,10 @@ static void xserver_child_wrapper(int ready_fd, void *user_data) { _exit(1); } - /* SELinux dyntransition into untrusted_app */ - char target[256] = ""; - ds_selinux_dyntransition(mls, target, sizeof(target)); + /* SELinux dyntransition into untrusted_app_27 (Termux domain) */ + ds_selinux_dyntransition(mls); - fprintf(stdout, "[X11] ctx=%s uid=%d display=%s\n", target, (int)getuid(), - args->display); + fprintf(stdout, "[X11] uid=%d display=%s\n", (int)getuid(), args->display); fflush(stdout); char nice[256]; From 20ed69c3779405681ea86bed7c6597de17fcacfd Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Fri, 5 Jun 2026 20:04:14 +0530 Subject: [PATCH 003/211] pulseaudio: fix mainloop deadlock on Android Patch default.pa at runtime: load aaudio before always-sink, disable module-sles-sink and module-console-kit. Add --use-pid-file=false and --disallow-exit to prevent futex deadlock on Android userdata flock. Signed-off-by: ravindu644 --- src/pulseaudio-android.c | 125 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 3 deletions(-) diff --git a/src/pulseaudio-android.c b/src/pulseaudio-android.c index 96761442..8259e2b1 100644 --- a/src/pulseaudio-android.c +++ b/src/pulseaudio-android.c @@ -21,6 +21,121 @@ /* ---- helpers ---------------------------------------------------------- */ +#define PA_DEFAULT_PA TX11_PREFIX "/etc/pulse/default.pa" +#define PA_AAUDIO_LINE "load-module module-aaudio-sink" +#define PA_ALWAYS_LINE "load-module module-always-sink" +#define PA_SLES_LINE "load-module module-sles-sink" +#define PA_CK_LINE "load-module module-console-kit" + +/* + * Patch Termux's default.pa so module-aaudio-sink loads before + * module-always-sink. Without this ordering, module-always-sink spawns a + * null-sink fallback (seeing no real sink yet), then tries to async-unload it + * via D-Bus once AAudio appears -- but there is no system bus on Android, so + * the deferred unload wedges PulseAudio's mainloop permanently. + * + * Idempotent: if already patched, returns immediately. + */ +static void patch_default_pa(void) { + FILE *f = fopen(PA_DEFAULT_PA, "r"); + if (!f) { + ds_warn("[PulseAudio] cannot open %s for patching: %s", PA_DEFAULT_PA, + strerror(errno)); + return; + } + + /* Slurp the whole file */ + fseek(f, 0, SEEK_END); + long sz = ftell(f); + rewind(f); + char *buf = malloc((size_t)sz + 1); + if (!buf) { + fclose(f); + return; + } + size_t rd = fread(buf, 1, (size_t)sz, f); + fclose(f); + buf[rd] = '\0'; + + /* Check if already patched: aaudio line must appear before always-sink + * and console-kit must be commented out. */ + char *pos_aaudio = strstr(buf, PA_AAUDIO_LINE); + char *pos_always = strstr(buf, PA_ALWAYS_LINE); + int ck_active = 0; + char *p = buf; + while ((p = strstr(p, PA_CK_LINE)) != NULL) { + /* walk back to start of line to check for leading '#' */ + char *sol = p; + while (sol > buf && *(sol - 1) != '\n') + sol--; + if (*sol != '#') { + ck_active = 1; + break; + } + p++; + } + + if (pos_aaudio && pos_always && pos_aaudio < pos_always && !ck_active) { + ds_log("[PulseAudio] default.pa already patched, skipping"); + free(buf); + return; + } + + ds_log("[PulseAudio] patching %s ...", PA_DEFAULT_PA); + + /* Write to a tmp file then rename atomically */ + char tmp[PATH_MAX]; + snprintf(tmp, sizeof(tmp), "%s.ds_tmp", PA_DEFAULT_PA); + FILE *out = fopen(tmp, "w"); + if (!out) { + ds_warn("[PulseAudio] cannot write tmp patch file: %s", strerror(errno)); + free(buf); + return; + } + + char *line = buf; + int injected = 0; + while (*line) { + char *nl = strchr(line, '\n'); + size_t len = nl ? (size_t)(nl - line + 1) : strlen(line); + + /* Comment out module-sles-sink (broken on most Android devices) */ + if (strncmp(line, PA_SLES_LINE, strlen(PA_SLES_LINE)) == 0) { + fputc('#', out); + ds_log("[PulseAudio] commented out " PA_SLES_LINE); + } + + /* Comment out module-console-kit: no D-Bus system bus on Android, + * loading it blocks PA's mainloop on a futex waiting for a D-Bus reply + * that never comes. */ + if (strncmp(line, PA_CK_LINE, strlen(PA_CK_LINE)) == 0) { + fputc('#', out); + ds_log("[PulseAudio] commented out " PA_CK_LINE); + } + + /* Inject aaudio load just before always-sink */ + if (!injected && + strncmp(line, PA_ALWAYS_LINE, strlen(PA_ALWAYS_LINE)) == 0) { + fprintf(out, PA_AAUDIO_LINE "\n"); + injected = 1; + ds_log("[PulseAudio] injected " PA_AAUDIO_LINE " before " PA_ALWAYS_LINE); + } + + fwrite(line, 1, len, out); + line += len; + } + fclose(out); + free(buf); + + if (rename(tmp, PA_DEFAULT_PA) != 0) { + ds_warn("[PulseAudio] failed to rename patched file: %s", strerror(errno)); + unlink(tmp); + return; + } + + ds_log("[PulseAudio] default.pa patched successfully"); +} + /* * Resolve the Termux UID from packages.list. * Returns the UID on success, -1 on failure. @@ -92,19 +207,20 @@ static void pulse_child_wrapper(int ready_fd, void *user_data) { fflush(stdout); /* Launch PulseAudio in non-daemon foreground mode. - * - module-native-protocol-unix: UNIX socket with anonymous auth - * - module-aaudio-sink: Android AAudio low-latency audio output (default) + * - module-native-protocol-unix: UNIX socket at our custom path * - --exit-idle-time=-1: never exit on idle (we manage lifecycle ourselves) * - --daemonize=no: stay in foreground so our log relay captures all output + * module-aaudio-sink is loaded by default.pa (patched at startup). */ char *argv[] = { TX11_PULSE_BIN, "--load=module-native-protocol-unix socket=" TX11_PULSE_SOCKET " auth-anonymous=1", - "--load=module-aaudio-sink", "--exit-idle-time=-1", "--daemonize=no", "--log-target=stderr", + "--use-pid-file=false", + "--disallow-exit", NULL, }; @@ -186,6 +302,9 @@ int ds_pulse_daemon_start(struct ds_config *cfg) { /* Clean up stale socket from a previous crashed run */ unlink(TX11_PULSE_SOCKET); + /* Ensure default.pa loads aaudio before always-sink to avoid null-sink wedge + */ + patch_default_pa(); ds_log("[PulseAudio] launching daemon (uid=%d)", uid); pid_t child = spawn_pulse(uid); if (child <= 0) From 4f1ce671a66545d5af4f69675ca4b083d98f6e0d Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Fri, 5 Jun 2026 20:12:20 +0530 Subject: [PATCH 004/211] scripts: add setup-termux.sh script to install Termux-X11, VirGL dependencies and configure PulseAudio for Droidspaces Signed-off-by: ravindu644 --- scripts/setup-termux.sh | 83 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100755 scripts/setup-termux.sh diff --git a/scripts/setup-termux.sh b/scripts/setup-termux.sh new file mode 100755 index 00000000..eada9a84 --- /dev/null +++ b/scripts/setup-termux.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# Copyright (C) 2026 ravindu644 +# SPDX-License-Identifier: GPL-3.0-or-later + +set -e + +# Reject if not running inside Termux +if [ -z "$TERMUX_APP__PACKAGE_NAME" ] || [ -z "$PREFIX" ]; then + echo "Error: not running inside Termux." >&2 + exit 1 +fi + +DEFAULT_PA="$PREFIX/etc/pulse/default.pa" +AAUDIO_LINE="load-module module-aaudio-sink" +ALWAYS_LINE="load-module module-always-sink" +SLES_LINE="load-module module-sles-sink" +CK_LINE="load-module module-console-kit" + +BOLD="\033[1m" +GREEN="\033[1;32m" +CYAN="\033[0;36m" +RESET="\033[0m" + +log() { + echo -e "${GREEN}===> ${BOLD}$*${RESET}\n" +} + +detail() { + echo -e " ${CYAN}->${RESET} $*" +} + +log "Setting up Droidspaces dependencies..." + +log "Updating repos and upgrading Termux..." +DEBIAN_FRONTEND=noninteractive pkg update -y -o Dpkg::Options::="--force-confold" +DEBIAN_FRONTEND=noninteractive pkg upgrade -y -o Dpkg::Options::="--force-confold" + +log "Installing x11-repo..." +DEBIAN_FRONTEND=noninteractive pkg install -y -o Dpkg::Options::="--force-confold" x11-repo + +log "Installing Termux:X11, VirGL and PulseAudio..." +DEBIAN_FRONTEND=noninteractive pkg install -y -o Dpkg::Options::="--force-confold" pulseaudio termux-x11 virglrenderer-android + +log "Patching $DEFAULT_PA..." + +if [ ! -f "$DEFAULT_PA" ]; then + echo "Error: $DEFAULT_PA not found." >&2 + exit 1 +fi + +# Comment out module-sles-sink (missing Android HAL deps on most devices) +if grep -q "^${SLES_LINE}" "$DEFAULT_PA"; then + sed -i "s|^${SLES_LINE}|#${SLES_LINE}|" "$DEFAULT_PA" + detail "Commented out $SLES_LINE" +fi + +# Comment out module-console-kit (no D-Bus system bus on Android, causes futex deadlock) +if grep -q "^${CK_LINE}" "$DEFAULT_PA"; then + sed -i "s|^${CK_LINE}|#${CK_LINE}|" "$DEFAULT_PA" + detail "Commented out $CK_LINE" +fi + +# Check if already patched (aaudio appears before always-sink) +AAUDIO_LINE_NUM=$(grep -n "^${AAUDIO_LINE}$" "$DEFAULT_PA" | head -1 | cut -d: -f1) +ALWAYS_LINE_NUM=$(grep -n "^${ALWAYS_LINE}$" "$DEFAULT_PA" | head -1 | cut -d: -f1) + +if [ -n "$AAUDIO_LINE_NUM" ] && [ -n "$ALWAYS_LINE_NUM" ] && [ "$AAUDIO_LINE_NUM" -lt "$ALWAYS_LINE_NUM" ]; then + detail "default.pa already patched, skipping." +else + sed -i "s|^${ALWAYS_LINE}|${AAUDIO_LINE}\n${ALWAYS_LINE}|" "$DEFAULT_PA" + detail "Injected $AAUDIO_LINE before $ALWAYS_LINE" + + # Remove duplicate aaudio line at bottom if present + AAUDIO_COUNT=$(grep -c "^${AAUDIO_LINE}$" "$DEFAULT_PA" || true) + if [ "$AAUDIO_COUNT" -gt 1 ]; then + awk "BEGIN{found=0} /^${AAUDIO_LINE}$/{if(found){next}; found=1} {print}" "$DEFAULT_PA" > "$DEFAULT_PA.tmp" + mv "$DEFAULT_PA.tmp" "$DEFAULT_PA" + detail "Removed duplicate $AAUDIO_LINE" + fi +fi + +log "All done. Droidspaces audio/display dependencies are ready." From 5c3ff5f582d73412c9648798e831e45096de0196 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Fri, 5 Jun 2026 20:40:48 +0530 Subject: [PATCH 005/211] scripts: setup-termux.sh: nuked termux-specific checks Signed-off-by: ravindu644 --- scripts/setup-termux.sh | 6 ------ 1 file changed, 6 deletions(-) diff --git a/scripts/setup-termux.sh b/scripts/setup-termux.sh index eada9a84..0110d24a 100755 --- a/scripts/setup-termux.sh +++ b/scripts/setup-termux.sh @@ -5,12 +5,6 @@ set -e -# Reject if not running inside Termux -if [ -z "$TERMUX_APP__PACKAGE_NAME" ] || [ -z "$PREFIX" ]; then - echo "Error: not running inside Termux." >&2 - exit 1 -fi - DEFAULT_PA="$PREFIX/etc/pulse/default.pa" AAUDIO_LINE="load-module module-aaudio-sink" ALWAYS_LINE="load-module module-always-sink" From 5d0aebbe4a155b351687d8d94c50d4d0381e20f3 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 00:35:39 +0530 Subject: [PATCH 006/211] terminal: disable OPOST/ONLCR on host PTY for transparent TUI passthrough OPOST on the outer line discipline transforms \n->\r\n, corrupting escape sequences from tmux/vim/claude-code before they reach the user. Signed-off-by: ravindu644 --- src/mount.c | 4 ---- src/terminal.c | 10 +++++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/mount.c b/src/mount.c index 4c1d200c..0aa3347a 100644 --- a/src/mount.c +++ b/src/mount.c @@ -1165,7 +1165,3 @@ int ds_fix_host_ptys(void) { ds_log("Host devpts mounted successfully (Recovery fix)."); return 0; } - -/* --------------------------------------------------------------------------- - * Container introspection helpers (used by info/show) - * ---------------------------------------------------------------------------*/ diff --git a/src/terminal.c b/src/terminal.c index 84ac19d6..3779ba51 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -125,11 +125,11 @@ int ds_setup_tios(int fd, struct termios *old) { #ifdef IEXTEN new_tios.c_lflag &= (tcflag_t)~IEXTEN; #endif - /* Keep host's ONLCR enabled to avoid staircase output if the container side - * stops sending \r (e.g. during shutdown or sudo execution). Duplicate \r - * are harmless. */ - // new_tios.c_oflag &= (tcflag_t)~ONLCR; - new_tios.c_oflag |= OPOST; + /* Disable output processing: OPOST with ONLCR active on the host PTY causes + * the line discipline to transform \n -> \r\n, corrupting TUI escape + * sequences from tmux, vim, etc. The container shell sets its own ONLCR on + * the inner slave, so \r\n translation happens exactly once, there. */ + new_tios.c_oflag &= (tcflag_t) ~(OPOST | ONLCR); new_tios.c_cc[VMIN] = 1; new_tios.c_cc[VTIME] = 0; From 9901f052c87d6d54e4f791652893fab752f5e742 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 00:43:10 +0530 Subject: [PATCH 007/211] terminal: make TIOCSPTLCK best-effort for kernel 4.9 compat vendor 4.9 devpts patches return EINVAL/EIO on newinstance mounts, causing silent PTY alloc failure; kernel auto-unlocks when needed Signed-off-by: ravindu644 --- src/terminal.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/terminal.c b/src/terminal.c index 3779ba51..d68b150f 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -20,10 +20,10 @@ int ds_openpty(int *master, int *slave, char *name) { if (m < 0) return -1; - /* unlock slave */ + /* best-effort: vendor 4.9 kernels may return EINVAL/EIO on newinstance + * devpts mounts; kernel auto-unlocks if needed */ int unlock = 0; - if (ioctl(m, TIOCSPTLCK, &unlock) < 0) - goto err; + (void)ioctl(m, TIOCSPTLCK, &unlock); /* try kernel 4.13+ path-free method first */ int s = ioctl(m, TIOCGPTPEER, O_RDWR | O_NOCTTY | O_CLOEXEC); From d5ba1630b8766101d74fa4cde009850bacbd44a2 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 00:53:11 +0530 Subject: [PATCH 008/211] daemon: signalfd for SIGCHLD/SIGWINCH, fix poll probe, shrink cmdline buf - signalfd(SIGCHLD) replaces 100ms poll-waitpid busy loop in handle_session - signalfd(SIGWINCH) replaces signal handler + 200ms epoll timeout in client - drop redundant poll() probe before recv_frame_hdr in client frame loop - shrink cmdline log buffer from 16KB to 512B stack allocation Signed-off-by: ravindu644 --- src/daemon.c | 130 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/src/daemon.c b/src/daemon.c index 5ad9352e..fa9c2463 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -28,9 +28,9 @@ #include #include -#include #include #include +#include #include #include #include @@ -42,7 +42,6 @@ #define DS_MAX_ARGC 64 #define DS_MAX_ARG 8192 #define DS_IOBUF 8192 -#define DS_POLL_MS 100 #define MSG_OUT ((uint8_t)0x01) #define MSG_ERR ((uint8_t)0x02) @@ -126,8 +125,6 @@ static void daemon_log_tee(const char *prefix, const char *fmt, ...) { daemon_log_tee("-", fmt, ##__VA_ARGS__); \ } while (0) -static volatile sig_atomic_t g_client_sigwinch = 0; - /* * g_self_path is populated once during daemon startup (after daemonize()). * Subsequent fork()+reexec() children inherit a copy of this path. @@ -413,6 +410,13 @@ static void handle_session(int conn, ds_req_t *r) { return; } + /* signalfd for zero-latency SIGCHLD: avoids 100ms poll-waitpid loop */ + sigset_t ss; + sigemptyset(&ss); + sigaddset(&ss, SIGCHLD); + sigprocmask(SIG_BLOCK, &ss, NULL); + int sfd = signalfd(-1, &ss, SFD_NONBLOCK | SFD_CLOEXEC); + struct epoll_event ev, events[8]; int active_reads = 0; @@ -441,6 +445,12 @@ static void handle_session(int conn, ds_req_t *r) { active_reads = 2; } + if (sfd >= 0) { + ev.events = EPOLLIN; + ev.data.fd = sfd; + epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev); + } + /* watch the connection for dead clients or pty input */ ev.events = EPOLLHUP | EPOLLERR; if (is_pty) @@ -452,14 +462,26 @@ static void handle_session(int conn, ds_req_t *r) { int child_done = 0; /* 0=running, 1=killed, 2=normal-exit-seen */ for (;;) { - int nfds = epoll_wait(epfd, events, 8, DS_POLL_MS); + int nfds = epoll_wait(epfd, events, 8, -1); if (nfds < 0 && errno != EINTR) break; for (int i = 0; i < nfds; i++) { int fd = events[i].data.fd; - if (fd == conn) { + if (sfd >= 0 && fd == sfd) { + /* drain all pending SIGCHLD notifications */ + struct signalfd_siginfo si; + while (read(sfd, &si, sizeof(si)) == (ssize_t)sizeof(si)) { + if (!child_done) { + int st; + if (waitpid(child, &st, WNOHANG) == child) { + exit_code = WIFEXITED(st) ? WEXITSTATUS(st) : 1; + child_done = is_pty ? 2 : 1; + } + } + } + } else if (fd == conn) { if (events[i].events & (EPOLLHUP | EPOLLERR)) { /* client died unexpectedly, kill the child */ kill(child, is_pty ? SIGHUP : SIGTERM); @@ -482,7 +504,7 @@ static void handle_session(int conn, ds_req_t *r) { if (read_exact(conn, wd, 4) == 0) { struct winsize nws = {ntohs(wd[0]), ntohs(wd[1]), 0, 0}; ioctl(master, TIOCSWINSZ, &nws); - kill(-child, SIGWINCH); /* signal the whole process group */ + kill(child, SIGWINCH); } } else { /* drain unknown frames so we don't stall the pipe */ @@ -510,7 +532,6 @@ static void handle_session(int conn, ds_req_t *r) { goto session_end; } } else if (n == 0) { - /* Truly EOF */ drained = 1; break; } else { @@ -518,10 +539,8 @@ static void handle_session(int conn, ds_req_t *r) { continue; if (errno == EAGAIN) break; - if (errno == EWOULDBLOCK) - break; drained = 1; - break; /* treat other errors as closure */ + break; } } @@ -541,15 +560,6 @@ static void handle_session(int conn, ds_req_t *r) { } } - if (!child_done) { - int st; - if (waitpid(child, &st, WNOHANG) == child) { - exit_code = WIFEXITED(st) ? WEXITSTATUS(st) : 1; - /* for pipes we wait for eof. for ptys, the child exiting is the end. */ - child_done = is_pty ? 2 : 1; - } - } - /* wait for eofs on pipes even if the child exited, because the monitor * might still be holding it */ if (child_done == 1) { @@ -572,6 +582,11 @@ static void handle_session(int conn, ds_req_t *r) { } session_end: + if (sfd >= 0) { + epoll_ctl(epfd, EPOLL_CTL_DEL, sfd, NULL); + close(sfd); + } + sigprocmask(SIG_UNBLOCK, &ss, NULL); close(epfd); if (master >= 0) close(master); @@ -616,7 +631,7 @@ static void handle_conn(int conn) { /* log the request */ { - char cmdline[DS_MAX_ARG * 2] = {0}; + char cmdline[512]; size_t off = 0; for (int i = 0; i < req.argc && off < sizeof(cmdline) - 1; i++) { int n = snprintf(cmdline + off, sizeof(cmdline) - off, "%s%s", @@ -1000,11 +1015,6 @@ int ds_daemon_probe(void) { /* connect to the daemon and relay our command */ -static void client_sigwinch_handler(int sig) { - (void)sig; - g_client_sigwinch = 1; -} - int ds_client_run(int argc, char **argv) { if (argc < 1) return -2; @@ -1107,6 +1117,7 @@ int ds_client_run(int argc, char **argv) { /* run the relay loop */ struct termios orig; int raw_tty_active = 0; + int winch_sfd = -1; if (interactive && has_tty && tcgetattr(STDIN_FILENO, &orig) == 0) { raw_tty_active = 1; @@ -1114,11 +1125,12 @@ int ds_client_run(int argc, char **argv) { cfmakeraw(&raw); tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); - struct sigaction sa; - memset(&sa, 0, sizeof(sa)); - sa.sa_handler = client_sigwinch_handler; - sa.sa_flags = SA_RESTART; - sigaction(SIGWINCH, &sa, NULL); + /* signalfd for SIGWINCH: no handler, no 200ms timeout needed */ + sigset_t ws; + sigemptyset(&ws); + sigaddset(&ws, SIGWINCH); + sigprocmask(SIG_BLOCK, &ws, NULL); + winch_sfd = signalfd(-1, &ws, SFD_NONBLOCK | SFD_CLOEXEC); } int epfd = epoll_create1(EPOLL_CLOEXEC); @@ -1129,6 +1141,11 @@ int ds_client_run(int argc, char **argv) { ev.data.fd = STDIN_FILENO; epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); } + if (winch_sfd >= 0) { + ev.events = EPOLLIN; + ev.data.fd = winch_sfd; + epoll_ctl(epfd, EPOLL_CTL_ADD, winch_sfd, &ev); + } ev.events = EPOLLIN | EPOLLHUP | EPOLLERR; ev.data.fd = sock; epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev); @@ -1137,15 +1154,7 @@ int ds_client_run(int argc, char **argv) { char buf[DS_IOBUF]; for (;;) { - if (raw_tty_active && g_client_sigwinch) { - g_client_sigwinch = 0; - struct winsize nws = {24, 80, 0, 0}; - ioctl(STDIN_FILENO, TIOCGWINSZ, &nws); - uint16_t wd2[2] = {htons(nws.ws_row), htons(nws.ws_col)}; - send_frame(sock, MSG_WINCH, wd2, 4); - } - - int nfds = epoll_wait(epfd, events, 4, raw_tty_active ? 200 : -1); + int nfds = epoll_wait(epfd, events, 4, -1); if (nfds < 0 && errno != EINTR) break; @@ -1153,7 +1162,15 @@ int ds_client_run(int argc, char **argv) { for (int i = 0; i < nfds && !done; i++) { int fd = events[i].data.fd; - if (fd == STDIN_FILENO) { + if (winch_sfd >= 0 && fd == winch_sfd) { + struct signalfd_siginfo si; + while (read(winch_sfd, &si, sizeof(si)) == (ssize_t)sizeof(si)) { + struct winsize nws = {24, 80, 0, 0}; + ioctl(STDIN_FILENO, TIOCGWINSZ, &nws); + uint16_t wd2[2] = {htons(nws.ws_row), htons(nws.ws_col)}; + send_frame(sock, MSG_WINCH, wd2, 4); + } + } else if (fd == STDIN_FILENO) { ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); if (n > 0) { if (send_frame(sock, MSG_OUT, buf, (uint32_t)n) < 0) @@ -1162,21 +1179,11 @@ int ds_client_run(int argc, char **argv) { done = 1; } } else if (fd == sock) { - if (events[i].events & (EPOLLIN | EPOLLHUP)) { - /* Read as many frames as possible before checking HUP */ + if (events[i].events & EPOLLIN) { + /* drain all available frames without a redundant poll() probe */ for (;;) { uint8_t type; uint32_t mlen; - - /* Probe the socket for pending data */ - int pending = 0; - struct pollfd pfd = {sock, POLLIN, 0}; - if (poll(&pfd, 1, 0) > 0 && (pfd.revents & POLLIN)) - pending = 1; - - if (!pending) - break; - if (recv_frame_hdr(sock, &type, &mlen) < 0) { done = 1; break; @@ -1204,6 +1211,13 @@ int ds_client_run(int argc, char **argv) { rem -= c; } fflush(dest); + if (done) + break; + + /* check for more data without blocking */ + struct pollfd pfd = {sock, POLLIN, 0}; + if (poll(&pfd, 1, 0) <= 0 || !(pfd.revents & POLLIN)) + break; } } @@ -1217,8 +1231,16 @@ int ds_client_run(int argc, char **argv) { break; } - if (raw_tty_active) + if (raw_tty_active) { tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig); + if (winch_sfd >= 0) { + sigset_t ws; + sigemptyset(&ws); + sigaddset(&ws, SIGWINCH); + sigprocmask(SIG_UNBLOCK, &ws, NULL); + close(winch_sfd); + } + } close(epfd); close(sock); return exit_code; From 8151467f8ccd819c563b71f65216096bf70a41a8 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 01:00:08 +0530 Subject: [PATCH 009/211] pid: replace 4x check_*_needs() with generic ds_feature_needs() all four functions were identical modulo which ds_config bool field they tested. collapse into one offsetof-based scanner; keep thin compat wrappers so existing call sites are unchanged Signed-off-by: ravindu644 --- src/droidspace.h | 11 ++- src/pid.c | 248 +++++++---------------------------------------- 2 files changed, 44 insertions(+), 215 deletions(-) diff --git a/src/droidspace.h b/src/droidspace.h index ed6a56cf..5e02dd6f 100644 --- a/src/droidspace.h +++ b/src/droidspace.h @@ -624,7 +624,6 @@ int ds_setup_x11_socket(struct ds_config *cfg); int ds_virgl_daemon_start(struct ds_config *cfg); void ds_virgl_daemon_stop(struct ds_config *cfg); int ds_setup_virgl_socket(struct ds_config *cfg); -int check_virgl_needs(void); /* --------------------------------------------------------------------------- * pulseaudio-android.c @@ -633,7 +632,6 @@ int check_virgl_needs(void); int ds_pulse_daemon_start(struct ds_config *cfg); void ds_pulse_daemon_stop(struct ds_config *cfg); int ds_setup_pulse_socket(struct ds_config *cfg); -int check_pulse_needs(void); /* --------------------------------------------------------------------------- * network.c @@ -787,6 +785,15 @@ int show_containers(struct ds_config *cfg); int scan_containers(void); int check_selinux_permissive_needs(void); int check_x11_needs(void); +int check_virgl_needs(void); +int check_pulse_needs(void); + +/* + * ds_feature_needs - generic feature-needs scanner. + * Pass offsetof(struct ds_config, ) for the boolean flag to check. + * Returns: 1=running, 0=installed-but-idle, -1=not-installed + */ +int ds_feature_needs(size_t cfg_flag_offset); void write_plain_env_file(const char *src, const char *dst); /* --------------------------------------------------------------------------- diff --git a/src/pid.c b/src/pid.c index a40b6b7c..e7edf6e1 100644 --- a/src/pid.c +++ b/src/pid.c @@ -795,92 +795,22 @@ int scan_containers(void) { return 0; } -/** - * Scans all installed containers and their running status to determine - * if the host SELinux permissive mode is still needed. - * - * Returns: - * -1: No containers with 'selinux-permissive=yes' are installed. - * 0: At least one permissive container is installed, but none are running. - * 1: At least one permissive container is currently running. - */ -int check_selinux_permissive_needs(void) { - char pids_path[PATH_MAX]; - snprintf(pids_path, sizeof(pids_path), "%s", get_pids_dir()); - DIR *pd = opendir(pids_path); - if (!pd) - return -1; - - /* Phase 1: Fast check - are any permissive containers currently running? */ - struct dirent *ent; - while ((ent = readdir(pd)) != NULL) { - if (!is_pid_file(ent->d_name)) - continue; - - char name[256]; - get_container_name_from_pidfile(ent->d_name, name, sizeof(name)); - - struct ds_config tmp_cfg = {0}; - if (ds_config_load_by_name(name, &tmp_cfg) == 0) { - pid_t pid; - /* Check if THIS running container needs permissive mode */ - if (tmp_cfg.selinux_permissive && is_container_running(&tmp_cfg, &pid)) { - free_config_binds(&tmp_cfg); - free_config_env_vars(&tmp_cfg); - free_config_unknown_lines(&tmp_cfg); - closedir(pd); - return 1; /* Found a running permissive container - stay permissive */ - } - free_config_binds(&tmp_cfg); - free_config_env_vars(&tmp_cfg); - free_config_unknown_lines(&tmp_cfg); - } - } - closedir(pd); - - /* Phase 2: None are running. But is at least one permissive container - * installed? (Requirement: do nothing if none installed). */ - char containers_path[PATH_MAX]; - snprintf(containers_path, sizeof(containers_path), "%s/Containers", - get_workspace_dir()); - DIR *cd = opendir(containers_path); - if (!cd) - return -1; - - int permissive_installed = 0; - while ((ent = readdir(cd)) != NULL) { - if (ent->d_name[0] == '.') - continue; - - struct ds_config tmp_cfg = {0}; - if (ds_config_load_by_name(ent->d_name, &tmp_cfg) == 0) { - if (tmp_cfg.selinux_permissive) { - permissive_installed = 1; - free_config_binds(&tmp_cfg); - free_config_env_vars(&tmp_cfg); - free_config_unknown_lines(&tmp_cfg); - break; - } - free_config_binds(&tmp_cfg); - free_config_env_vars(&tmp_cfg); - free_config_unknown_lines(&tmp_cfg); - } - } - closedir(cd); - - return permissive_installed ? 0 : -1; -} - /* - * check_x11_needs - scan running containers to decide if the global - * Termux-X11 server should stay up. + * ds_feature_needs - generic "is this feature still needed?" scanner. + * + * Reads the int field at cfg_flag_offset inside ds_config to decide + * whether the associated global daemon/server should stay up. * * Returns: - * 1: at least one running container has termux_x11 enabled - * 0: termux_x11 containers exist but none are running - * -1: no containers with termux_x11 are installed + * 1: at least one running container has the feature enabled + * 0: feature containers exist but none are currently running + * -1: no containers with the feature are installed + * + * Adding support for a new feature costs zero lines here - just define + * a DS_FEAT_* constant in droidspace.h and call ds_feature_needs(). */ -int check_x11_needs(void) { +int ds_feature_needs(size_t cfg_flag_offset) { + /* Phase 1: any running container with the feature? */ DIR *pd = opendir(get_pids_dir()); if (!pd) return -1; @@ -893,8 +823,9 @@ int check_x11_needs(void) { get_container_name_from_pidfile(ent->d_name, name, sizeof(name)); struct ds_config tmp = {0}; if (ds_config_load_by_name(name, &tmp) == 0) { + int flag = *(int *)((char *)&tmp + cfg_flag_offset); pid_t pid; - int running = tmp.termux_x11 && is_container_running(&tmp, &pid); + int running = flag && is_container_running(&tmp, &pid); free_config_binds(&tmp); free_config_env_vars(&tmp); free_config_unknown_lines(&tmp); @@ -906,7 +837,7 @@ int check_x11_needs(void) { } closedir(pd); - /* Phase 2: any termux_x11 container installed at all? */ + /* Phase 2: feature installed in any container at all? */ char containers_path[PATH_MAX]; snprintf(containers_path, sizeof(containers_path), "%s/Containers", get_workspace_dir()); @@ -914,148 +845,39 @@ int check_x11_needs(void) { if (!cd) return -1; - int x11_installed = 0; + int installed = 0; while ((ent = readdir(cd)) != NULL) { if (ent->d_name[0] == '.') continue; struct ds_config tmp = {0}; - if (ds_config_load_by_name(ent->d_name, &tmp) == 0 && tmp.termux_x11) { - x11_installed = 1; + if (ds_config_load_by_name(ent->d_name, &tmp) == 0) { + int flag = *(int *)((char *)&tmp + cfg_flag_offset); + if (flag) { + installed = 1; + free_config_binds(&tmp); + free_config_env_vars(&tmp); + free_config_unknown_lines(&tmp); + break; + } free_config_binds(&tmp); free_config_env_vars(&tmp); free_config_unknown_lines(&tmp); - break; } - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); } closedir(cd); - return x11_installed ? 0 : -1; + return installed ? 0 : -1; } -/* - * check_virgl_needs - scan running containers to decide if the global - * VirGL server should stay up. - * - * Returns: - * 1: at least one running container has virgl enabled - * 0: virgl containers exist but none are running - * -1: no containers with virgl are installed - */ +/* Compat wrappers - thin shims over ds_feature_needs() */ +int check_selinux_permissive_needs(void) { + return ds_feature_needs(offsetof(struct ds_config, selinux_permissive)); +} +int check_x11_needs(void) { + return ds_feature_needs(offsetof(struct ds_config, termux_x11)); +} int check_virgl_needs(void) { - DIR *pd = opendir(get_pids_dir()); - if (!pd) - return -1; - - struct dirent *ent; - while ((ent = readdir(pd)) != NULL) { - if (!is_pid_file(ent->d_name)) - continue; - char name[256]; - get_container_name_from_pidfile(ent->d_name, name, sizeof(name)); - struct ds_config tmp = {0}; - if (ds_config_load_by_name(name, &tmp) == 0) { - pid_t pid; - int running = tmp.virgl && is_container_running(&tmp, &pid); - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - if (running) { - closedir(pd); - return 1; - } - } - } - closedir(pd); - - /* Phase 2: any virgl container installed at all? */ - char containers_path[PATH_MAX]; - snprintf(containers_path, sizeof(containers_path), "%s/Containers", - get_workspace_dir()); - DIR *cd = opendir(containers_path); - if (!cd) - return -1; - - int virgl_installed = 0; - while ((ent = readdir(cd)) != NULL) { - if (ent->d_name[0] == '.') - continue; - struct ds_config tmp = {0}; - if (ds_config_load_by_name(ent->d_name, &tmp) == 0 && tmp.virgl) { - virgl_installed = 1; - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - break; - } - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - } - closedir(cd); - return virgl_installed ? 0 : -1; + return ds_feature_needs(offsetof(struct ds_config, virgl)); } - -/* - * check_pulse_needs - scan running containers to decide if the global - * PulseAudio daemon should stay up. - * - * Returns: - * 1: at least one running container has pulseaudio enabled - * 0: pulseaudio containers exist but none are running - * -1: no containers with pulseaudio are installed - */ int check_pulse_needs(void) { - DIR *pd = opendir(get_pids_dir()); - if (!pd) - return -1; - - struct dirent *ent; - while ((ent = readdir(pd)) != NULL) { - if (!is_pid_file(ent->d_name)) - continue; - char name[256]; - get_container_name_from_pidfile(ent->d_name, name, sizeof(name)); - struct ds_config tmp = {0}; - if (ds_config_load_by_name(name, &tmp) == 0) { - pid_t pid; - int running = tmp.pulseaudio && is_container_running(&tmp, &pid); - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - if (running) { - closedir(pd); - return 1; - } - } - } - closedir(pd); - - /* Phase 2: any pulseaudio container installed at all? */ - char containers_path[PATH_MAX]; - snprintf(containers_path, sizeof(containers_path), "%s/Containers", - get_workspace_dir()); - DIR *cd = opendir(containers_path); - if (!cd) - return -1; - - int pulse_installed = 0; - while ((ent = readdir(cd)) != NULL) { - if (ent->d_name[0] == '.') - continue; - struct ds_config tmp = {0}; - if (ds_config_load_by_name(ent->d_name, &tmp) == 0 && tmp.pulseaudio) { - pulse_installed = 1; - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - break; - } - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - } - closedir(cd); - return pulse_installed ? 0 : -1; + return ds_feature_needs(offsetof(struct ds_config, pulseaudio)); } From b641c240cb9529f29cb4367f41ffabfbf1bf9af4 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 01:09:42 +0530 Subject: [PATCH 010/211] config: add ds_config_free() to unify free_config_binds/env_vars/unknown_lines Signed-off-by: ravindu644 --- src/config.c | 6 +++++ src/container.c | 12 +++------ src/droidspace.h | 1 + src/main.c | 4 +-- src/monitor.c | 4 +-- src/network.c | 4 +-- src/pid.c | 12 +++------ src/socketd_bridge.c | 57 ++++++++++++++++-------------------------- src/socketd_protocol.h | 16 ++++++------ 9 files changed, 46 insertions(+), 70 deletions(-) diff --git a/src/config.c b/src/config.c index 60af1bf4..7ba72588 100644 --- a/src/config.c +++ b/src/config.c @@ -600,6 +600,12 @@ void free_config_unknown_lines(struct ds_config *cfg) { cfg->unknown_head = cfg->unknown_tail = NULL; } +void ds_config_free(struct ds_config *cfg) { + free_config_binds(cfg); + free_config_env_vars(cfg); + free_config_unknown_lines(cfg); +} + static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "# Droidspaces Container Configuration\n"); fprintf(f, "# Generated automatically - Changes may be overwritten\n\n"); diff --git a/src/container.c b/src/container.c index 2986825b..82ccf043 100644 --- a/src/container.c +++ b/src/container.c @@ -746,9 +746,7 @@ int start_rootfs(struct ds_config *cfg) { * handles this. Let's just return error so parent doesn't report * success. */ - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); goto cleanup; } @@ -766,9 +764,7 @@ int start_rootfs(struct ds_config *cfg) { if (lock_acquired) release_external_lock(cfg->container_name); - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); return 0; @@ -792,9 +788,7 @@ int start_rootfs(struct ds_config *cfg) { if (sync_pipe[1] >= 0) close(sync_pipe[1]); - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); return -1; } diff --git a/src/droidspace.h b/src/droidspace.h index 5e02dd6f..e5ef188a 100644 --- a/src/droidspace.h +++ b/src/droidspace.h @@ -515,6 +515,7 @@ int ds_config_add_bind(struct ds_config *cfg, const char *src, const char *dest, void free_config_binds(struct ds_config *cfg); void free_config_env_vars(struct ds_config *cfg); void free_config_unknown_lines(struct ds_config *cfg); +void ds_config_free(struct ds_config *cfg); int ds_split_flags(const char *str, char ***out_argv, int *out_argc); void ds_free_split_flags(char **argv, int argc); char *ds_config_auto_path(const char *rootfs_path); diff --git a/src/main.c b/src/main.c index 18753dd1..b7f3f8ad 100644 --- a/src/main.c +++ b/src/main.c @@ -1199,9 +1199,7 @@ int main(int argc, char **argv) { ret = 1; cleanup: - free_config_unknown_lines(&cfg); - free_config_env_vars(&cfg); - free_config_binds(&cfg); + ds_config_free(&cfg); free(cfg.tx11_extra_flags); free(cfg.virgl_extra_flags); return ret; diff --git a/src/monitor.c b/src/monitor.c index a596e818..b4ef85f5 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -649,8 +649,6 @@ reboot_loop:; monitor_cleanup_and_exit: /* Free dynamically allocated configuration members before exit */ - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); _exit(WIFEXITED(status) ? WEXITSTATUS(status) : 0); } diff --git a/src/network.c b/src/network.c index 7d4a55d9..1e1418ac 100644 --- a/src/network.c +++ b/src/network.c @@ -314,9 +314,7 @@ int ds_net_check_ip_collision(const char *ip_str, const char *exclude_name) { if (ds_config_load(config_path, &other) == 0) { if (other.static_nat_ip[0] && strcmp(other.static_nat_ip, ip_str) == 0) collision = 1; - free_config_binds(&other); - free_config_env_vars(&other); - free_config_unknown_lines(&other); + ds_config_free(&other); } } diff --git a/src/pid.c b/src/pid.c index e7edf6e1..1460d492 100644 --- a/src/pid.c +++ b/src/pid.c @@ -826,9 +826,7 @@ int ds_feature_needs(size_t cfg_flag_offset) { int flag = *(int *)((char *)&tmp + cfg_flag_offset); pid_t pid; int running = flag && is_container_running(&tmp, &pid); - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); + ds_config_free(&tmp); if (running) { closedir(pd); return 1; @@ -854,14 +852,10 @@ int ds_feature_needs(size_t cfg_flag_offset) { int flag = *(int *)((char *)&tmp + cfg_flag_offset); if (flag) { installed = 1; - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); + ds_config_free(&tmp); break; } - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); + ds_config_free(&tmp); } } closedir(cd); diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 07f8be51..c2e6dfb6 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -250,9 +250,7 @@ static int socketd_pidfile_to_container_name(const char *filename, } static void socketd_free_loaded_config(struct ds_config *cfg) { - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); } static int socketd_is_uuid_prefix_ref(const char *ref) { @@ -355,7 +353,6 @@ static int socketd_load_config_by_ref(const char *ref, struct ds_config *cfg) { static void socketd_pack_port_record(struct ds_socketd_port_record *dst, const struct ds_port_forward *src); - static void socketd_pack_container_record(struct ds_socketd_container_record *record, const struct ds_config *cfg, pid_t pid) { @@ -405,7 +402,6 @@ socketd_pack_container_record(struct ds_socketd_container_record *record, record->started_at_be = (int64_t)socketd_hton64((uint64_t)started_at); } - static int socketd_u16_count(int count, int max_count) { if (count < 0) return 0; @@ -438,8 +434,8 @@ static void socketd_pack_inspect_record( safe_strncpy(record->rootfs_path, cfg->rootfs_path, sizeof(record->rootfs_path)); - const char *image_ref = cfg->rootfs_img_path[0] ? cfg->rootfs_img_path - : cfg->rootfs_path; + const char *image_ref = + cfg->rootfs_img_path[0] ? cfg->rootfs_img_path : cfg->rootfs_path; safe_strncpy(record->image_ref, image_ref, sizeof(record->image_ref)); safe_strncpy(record->hostname, cfg->hostname, sizeof(record->hostname)); @@ -458,7 +454,8 @@ static void socketd_pack_inspect_record( int64_t started_at = pid > 0 ? socketd_container_started_at_epoch(pid) : 0; record->started_at_be = (int64_t)socketd_hton64((uint64_t)started_at); - record->memory_limit_be = (int64_t)socketd_hton64((uint64_t)cfg->memory_limit); + record->memory_limit_be = + (int64_t)socketd_hton64((uint64_t)cfg->memory_limit); record->cpu_quota_be = (int64_t)socketd_hton64((uint64_t)cfg->cpu_quota); record->cpu_period_be = (int64_t)socketd_hton64((uint64_t)cfg->cpu_period); record->pids_limit_be = (int64_t)socketd_hton64((uint64_t)cfg->pids_limit); @@ -477,8 +474,8 @@ static void socketd_pack_inspect_record( record->block_nested_ns = cfg->block_nested_ns ? 1u : 0u; record->is_img_mount = cfg->is_img_mount ? 1u : 0u; - int env_count = socketd_u16_count(cfg->env_var_count, - DS_SOCKETD_INSPECT_ENV_MAX); + int env_count = + socketd_u16_count(cfg->env_var_count, DS_SOCKETD_INSPECT_ENV_MAX); record->env_count_be = htons((uint16_t)env_count); record->env_total_count_be = htons((uint16_t)socketd_u16_count(cfg->env_var_count, UINT16_MAX)); @@ -491,8 +488,8 @@ static void socketd_pack_inspect_record( sizeof(record->env[i].value)); } - int bind_count = socketd_u16_count(cfg->bind_count, - DS_SOCKETD_INSPECT_BINDS_MAX); + int bind_count = + socketd_u16_count(cfg->bind_count, DS_SOCKETD_INSPECT_BINDS_MAX); record->bind_count_be = htons((uint16_t)bind_count); record->bind_total_count_be = htons((uint16_t)socketd_u16_count(cfg->bind_count, UINT16_MAX)); @@ -504,8 +501,8 @@ static void socketd_pack_inspect_record( record->binds[i].read_only = cfg->binds[i].ro ? 1u : 0u; } - int port_count = socketd_u16_count(cfg->port_forward_count, - DS_SOCKETD_RECORD_PORTS_MAX); + int port_count = + socketd_u16_count(cfg->port_forward_count, DS_SOCKETD_RECORD_PORTS_MAX); record->port_count_be = htons((uint16_t)port_count); record->port_total_count_be = htons((uint16_t)socketd_u16_count(cfg->port_forward_count, UINT16_MAX)); @@ -774,13 +771,9 @@ static int socketd_validate_start_config(struct ds_config *cfg) { return 0; } -static enum ds_socketd_status -socketd_read_lifecycle_request(int conn, - uint32_t payload_len, - struct ds_socketd_lifecycle_req *req_out, - char *target_out, - size_t target_size, - int *timeout_seconds_out) { +static enum ds_socketd_status socketd_read_lifecycle_request( + int conn, uint32_t payload_len, struct ds_socketd_lifecycle_req *req_out, + char *target_out, size_t target_size, int *timeout_seconds_out) { if (!req_out || !target_out || target_size == 0 || !timeout_seconds_out) return DS_SOCKETD_STATUS_BAD_REQUEST; @@ -794,8 +787,7 @@ socketd_read_lifecycle_request(int conn, if (!target_out[0]) return DS_SOCKETD_STATUS_BAD_REQUEST; - int32_t timeout = - (int32_t)ntohl((uint32_t)req_out->timeout_seconds_be); + int32_t timeout = (int32_t)ntohl((uint32_t)req_out->timeout_seconds_be); if (timeout < -1) return DS_SOCKETD_STATUS_BAD_REQUEST; @@ -878,16 +870,14 @@ static enum ds_socketd_status socketd_lifecycle_restart(struct ds_config *cfg, return DS_SOCKETD_STATUS_OK; } -static int socketd_handle_lifecycle_request(int conn, - uint32_t payload_len, +static int socketd_handle_lifecycle_request(int conn, uint32_t payload_len, enum ds_socketd_opcode opcode) { struct ds_socketd_lifecycle_req req; char target[DS_SOCKETD_RECORD_NAME_MAX]; int timeout_seconds = -1; - enum ds_socketd_status status = - socketd_read_lifecycle_request(conn, payload_len, &req, target, - sizeof(target), &timeout_seconds); + enum ds_socketd_status status = socketd_read_lifecycle_request( + conn, payload_len, &req, target, sizeof(target), &timeout_seconds); if (status != DS_SOCKETD_STATUS_OK) { socketd_send_response(conn, status, NULL, 0); return 0; @@ -994,9 +984,9 @@ static void socketd_handle_conn(int conn) { uint32_t caps_be = htonl(DS_SOCKETD_CAP_PROTOCOL_V1 | DS_SOCKETD_CAP_PING | DS_SOCKETD_CAP_CAPABILITIES | DS_SOCKETD_CAP_INFO | - DS_SOCKETD_CAP_LIST_CONTAINERS | DS_SOCKETD_CAP_INSPECT_CONTAINER | - DS_SOCKETD_CAP_LIFECYCLE | DS_SOCKETD_CAP_LIST_IMAGES | - DS_SOCKETD_CAP_POLL_EVENTS); + DS_SOCKETD_CAP_LIST_CONTAINERS | + DS_SOCKETD_CAP_INSPECT_CONTAINER | DS_SOCKETD_CAP_LIFECYCLE | + DS_SOCKETD_CAP_LIST_IMAGES | DS_SOCKETD_CAP_POLL_EVENTS); socketd_send_response(conn, DS_SOCKETD_STATUS_OK, &caps_be, (uint32_t)sizeof(caps_be)); @@ -1276,13 +1266,11 @@ static void socketd_handle_conn(int conn) { return; } - case DS_SOCKETD_OP_INSPECT_CONTAINER: { struct ds_socketd_inspect_container_req inspect_req; memset(&inspect_req, 0, sizeof(inspect_req)); - if (socketd_read_payload(conn, &inspect_req, - (uint32_t)sizeof(inspect_req), + if (socketd_read_payload(conn, &inspect_req, (uint32_t)sizeof(inspect_req), payload_len) < 0) { socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); return; @@ -1394,7 +1382,6 @@ static void socketd_handle_conn(int conn) { return; } - case DS_SOCKETD_OP_START_CONTAINER: case DS_SOCKETD_OP_STOP_CONTAINER: case DS_SOCKETD_OP_RESTART_CONTAINER: diff --git a/src/socketd_protocol.h b/src/socketd_protocol.h index 1b42aaa4..0a778a88 100644 --- a/src/socketd_protocol.h +++ b/src/socketd_protocol.h @@ -151,7 +151,7 @@ struct DS_SOCKETD_PACKED ds_socketd_bind_record { struct DS_SOCKETD_PACKED ds_socketd_inspect_container_record_v1 { uint16_t record_version_be; /* currently 1 */ uint16_t _header_pad; - uint32_t record_size_be; /* sizeof(ds_socketd_inspect_container_record_v1) */ + uint32_t record_size_be; /* sizeof(ds_socketd_inspect_container_record_v1) */ char name[DS_SOCKETD_RECORD_NAME_MAX]; char uuid[DS_UUID_LEN + 1]; @@ -162,7 +162,7 @@ struct DS_SOCKETD_PACKED ds_socketd_inspect_container_record_v1 { char custom_init[DS_SOCKETD_RECORD_PATH_MAX]; /* empty = /sbin/init */ char dns_servers[DS_SOCKETD_INSPECT_STRING_MAX]; - int32_t pid_be; /* host-view PID 1; 0 = stopped */ + int32_t pid_be; /* host-view PID 1; 0 = stopped */ int64_t started_at_be; /* CLOCK_REALTIME seconds; 0 if unknown */ int64_t memory_limit_be; /* bytes; 0 = unlimited */ @@ -184,12 +184,12 @@ struct DS_SOCKETD_PACKED ds_socketd_inspect_container_record_v1 { uint8_t block_nested_ns; uint8_t is_img_mount; - uint16_t env_count_be; /* entries serialized in env[] */ - uint16_t env_total_count_be; /* original cfg count before truncation */ - uint16_t bind_count_be; /* entries serialized in binds[] */ - uint16_t bind_total_count_be; /* original cfg count before truncation */ - uint16_t port_count_be; /* entries serialized in ports[] */ - uint16_t port_total_count_be; /* original cfg count before truncation */ + uint16_t env_count_be; /* entries serialized in env[] */ + uint16_t env_total_count_be; /* original cfg count before truncation */ + uint16_t bind_count_be; /* entries serialized in binds[] */ + uint16_t bind_total_count_be; /* original cfg count before truncation */ + uint16_t port_count_be; /* entries serialized in ports[] */ + uint16_t port_total_count_be; /* original cfg count before truncation */ struct ds_socketd_env_record env[DS_SOCKETD_INSPECT_ENV_MAX]; struct ds_socketd_bind_record binds[DS_SOCKETD_INSPECT_BINDS_MAX]; From 09483d36636cfe22c40d1c4e1fac41a9f3bd5d95 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 01:18:54 +0530 Subject: [PATCH 011/211] utils: add ds_global_daemon_stop/ds_resolve_daemon_pid, unify 3 stop fns 3 identical ~25-line stop functions (x11/virgl/pulse) collapsed to one-liner wrappers; kill/wait/unlink logic lives in one place now Signed-off-by: ravindu644 --- src/droidspace.h | 4 ++++ src/pulseaudio-android.c | 28 +++---------------------- src/utils.c | 45 ++++++++++++++++++++++++++++++++++++++++ src/virgl-android.c | 28 +++---------------------- src/x11.c | 28 ++++--------------------- 5 files changed, 59 insertions(+), 74 deletions(-) diff --git a/src/droidspace.h b/src/droidspace.h index e5ef188a..83cf2fb6 100644 --- a/src/droidspace.h +++ b/src/droidspace.h @@ -493,6 +493,10 @@ typedef void (*ds_child_fn)(int ready_fd, void *user_data); pid_t ds_daemon_read_pid(const char *filename); void ds_daemon_write_pid(const char *filename, pid_t pid); void ds_daemon_remove_pid(const char *filename); +pid_t ds_resolve_daemon_pid(pid_t cached, const char *pidfile); +void ds_global_daemon_stop(int (*check_fn)(void), pid_t cached_pid, + pid_t *pid_out, const char *pidfile, + const char *sock_path, const char *tag); void ds_oom_protect(void); void ds_spawn_log_relay(int pipe_read_fd, const char *log_file, const char *tag); diff --git a/src/pulseaudio-android.c b/src/pulseaudio-android.c index 8259e2b1..c920766d 100644 --- a/src/pulseaudio-android.c +++ b/src/pulseaudio-android.c @@ -327,32 +327,10 @@ int ds_pulse_daemon_start(struct ds_config *cfg) { } void ds_pulse_daemon_stop(struct ds_config *cfg) { - if (!cfg || !is_android()) + if (!cfg) return; - - /* Keep the daemon alive if any other running container still needs it */ - if (check_pulse_needs() == 1) { - ds_log("[PulseAudio] keeping global daemon running for other active " - "containers"); - return; - } - - pid_t pid = - cfg->pulse_pid > 0 ? cfg->pulse_pid : ds_daemon_read_pid("pulse.ppid"); - if (pid > 0) { - ds_log("[PulseAudio] terminating daemon (PID %d)...", (int)pid); - kill(pid, SIGTERM); - for (int i = 0; i < 10 && kill(pid, 0) == 0; i++) - usleep(100000); - if (kill(pid, 0) == 0) { - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); - } - cfg->pulse_pid = 0; - } - - ds_daemon_remove_pid("pulse.ppid"); - unlink(TX11_PULSE_SOCKET); + ds_global_daemon_stop(check_pulse_needs, cfg->pulse_pid, &cfg->pulse_pid, + "pulse.ppid", TX11_PULSE_SOCKET, "[PulseAudio]"); } /* ---- socket bridge ---------------------------------------------------- */ diff --git a/src/utils.c b/src/utils.c index 30d219c0..75e73c1b 100644 --- a/src/utils.c +++ b/src/utils.c @@ -2423,6 +2423,51 @@ void ds_daemon_remove_pid(const char *filename) { unlink(path); } +/* Resolve a cached pid or fall back to the pidfile. */ +pid_t ds_resolve_daemon_pid(pid_t cached, const char *pidfile) { + return cached > 0 ? cached : ds_daemon_read_pid(pidfile); +} + +/* + * ds_global_daemon_stop - unified teardown for X11/VirGL/PulseAudio daemons. + * + * check_fn : feature-needs checker (returns 1 if still needed) + * cached_pid : cfg->x11_pid / cfg->virgl_pid / cfg->pulse_pid + * pid_out : pointer to that field (zeroed on stop) + * pidfile : e.g. "x11.xpid" + * sock_path : socket to unlink after stop + * tag : log prefix e.g. "[X11]" + */ +void ds_global_daemon_stop(int (*check_fn)(void), pid_t cached_pid, + pid_t *pid_out, const char *pidfile, + const char *sock_path, const char *tag) { + if (!is_android()) + return; + + if (check_fn() == 1) { + ds_log("%s keeping global server running for other active containers", tag); + return; + } + + pid_t pid = ds_resolve_daemon_pid(cached_pid, pidfile); + if (pid > 0) { + ds_log("%s terminating (PID %d)...", tag, (int)pid); + kill(pid, SIGTERM); + for (int i = 0; i < 10 && kill(pid, 0) == 0; i++) + usleep(100000); + if (kill(pid, 0) == 0) { + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } + if (pid_out) + *pid_out = 0; + } + + ds_daemon_remove_pid(pidfile); + if (sock_path) + unlink(sock_path); +} + /* Set oom_score_adj to -1000 (unkillable). Best-effort, no error return. */ void ds_oom_protect(void) { FILE *f = fopen("/proc/self/oom_score_adj", "w"); diff --git a/src/virgl-android.c b/src/virgl-android.c index e632b455..bba53cf1 100644 --- a/src/virgl-android.c +++ b/src/virgl-android.c @@ -134,32 +134,10 @@ int ds_virgl_daemon_start(struct ds_config *cfg) { } void ds_virgl_daemon_stop(struct ds_config *cfg) { - if (!cfg || !is_android()) + if (!cfg) return; - - /* Keep the server alive if any other running container still needs VirGL */ - if (check_virgl_needs() == 1) { - ds_log("[VirGL] keeping global VirGL server running for other active " - "containers"); - return; - } - - pid_t pid = - cfg->virgl_pid > 0 ? cfg->virgl_pid : ds_daemon_read_pid("virgl.vpid"); - if (pid > 0) { - ds_log("[VirGL] terminating VirGL server (PID %d)...", (int)pid); - kill(pid, SIGTERM); - for (int i = 0; i < 10 && kill(pid, 0) == 0; i++) - usleep(100000); - if (kill(pid, 0) == 0) { - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); - } - cfg->virgl_pid = 0; - } - - ds_daemon_remove_pid("virgl.vpid"); - unlink(TX11_VIRGL_SOCKET); + ds_global_daemon_stop(check_virgl_needs, cfg->virgl_pid, &cfg->virgl_pid, + "virgl.vpid", TX11_VIRGL_SOCKET, "[VirGL]"); } /* ---- socket bridge ---------------------------------------------------- */ diff --git a/src/x11.c b/src/x11.c index cb2f269f..9472d68f 100644 --- a/src/x11.c +++ b/src/x11.c @@ -225,31 +225,11 @@ int ds_x11_daemon_start(struct ds_config *cfg) { } void ds_x11_daemon_stop(struct ds_config *cfg) { - if (!cfg || !is_android()) + if (!cfg) return; - - /* Keep the server alive if any other running container still needs X11 */ - if (check_x11_needs() == 1) { - ds_log( - "[X11] keeping global X11 server running for other active containers"); - return; - } - - pid_t pid = cfg->x11_pid > 0 ? cfg->x11_pid : ds_daemon_read_pid("x11.xpid"); - if (pid > 0) { - ds_log("[X11] terminating Termux-X11 server (PID %d)...", (int)pid); - kill(pid, SIGTERM); - for (int i = 0; i < 10 && kill(pid, 0) == 0; i++) - usleep(100000); - if (kill(pid, 0) == 0) { - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); - } - cfg->x11_pid = 0; - } - - ds_daemon_remove_pid("x11.xpid"); - unlink(TX11_SOCK_DIR "/" TX11_DISPLAY_SOCK); + ds_global_daemon_stop(check_x11_needs, cfg->x11_pid, &cfg->x11_pid, + "x11.xpid", TX11_SOCK_DIR "/" TX11_DISPLAY_SOCK, + "[X11]"); } /* ---- socket bridge ---------------------------------------------------- */ From d0a76116af1aa53365dd3daaf24ab6c455a0b64d Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 01:35:24 +0530 Subject: [PATCH 012/211] src: reorganize into android/, net/, and include/ subdirs Move Android-specific sources (android, x11, virgl, pulseaudio) into src/android/, network sources (network, netlink, iptables, dhcp) into src/net/, and all headers into src/include/. Redundant prefixes/suffixes dropped: ds_ and -android stripped where the subdirectory name already conveys the context. Update Makefile SRCS paths, -I flag (now -Isrc/include), VERSION grep path, and add mkdir -p to the compile rule for object subdirs. Update src/socketd/Makefile -I and fix api_server.cpp's relative --- Android/app/build.gradle.kts | 2 +- Makefile | 21 ++++++++++--------- src/{ => android}/android.c | 0 .../pulseaudio.c} | 0 src/{virgl-android.c => android/virgl.c} | 0 src/{ => android}/x11.c | 0 src/{ => include}/droidspace.h | 0 src/{ => include}/socketd_bridge.h | 0 src/{ => include}/socketd_protocol.h | 0 src/{ => include}/virtualize.h | 0 src/{ds_dhcp.c => net/dhcp.c} | 0 src/{ds_iptables.c => net/iptables.c} | 0 src/{ds_netlink.c => net/netlink.c} | 0 src/{ => net}/network.c | 0 src/socketd/Makefile | 2 +- src/socketd/api_server.cpp | 2 +- 16 files changed, 14 insertions(+), 13 deletions(-) rename src/{ => android}/android.c (100%) rename src/{pulseaudio-android.c => android/pulseaudio.c} (100%) rename src/{virgl-android.c => android/virgl.c} (100%) rename src/{ => android}/x11.c (100%) rename src/{ => include}/droidspace.h (100%) rename src/{ => include}/socketd_bridge.h (100%) rename src/{ => include}/socketd_protocol.h (100%) rename src/{ => include}/virtualize.h (100%) rename src/{ds_dhcp.c => net/dhcp.c} (100%) rename src/{ds_iptables.c => net/iptables.c} (100%) rename src/{ds_netlink.c => net/netlink.c} (100%) rename src/{ => net}/network.c (100%) diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index e343b795..8c8af54c 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -18,7 +18,7 @@ if (keystorePropertiesFile.exists()) { // Helper to extract version from C header fun getDSVersion(): String { - val headerFile = file("../../src/droidspace.h") + val headerFile = file("../../src/include/droidspace.h") if (!headerFile.exists()) return "1.0.0" val pattern = Regex("#define\\s+DS_VERSION\\s+\"([^\"]+)\"") diff --git a/Makefile b/Makefile index 72d151ce..bc26d342 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ OUT_DIR = output CC ?= cc # Get version from header -VERSION := $(shell grep "DS_VERSION" $(SRC_DIR)/droidspace.h | awk '{print $$3}' | tr -d '"') +VERSION := $(shell grep "DS_VERSION" $(SRC_DIR)/include/droidspace.h | awk '{print $$3}' | tr -d '"') # Parallel jobs - use all available CPU cores NPROC := $(shell nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 4) @@ -37,11 +37,11 @@ endif # Source files SRCS = $(SRC_DIR)/main.c \ $(SRC_DIR)/utils.c \ - $(SRC_DIR)/android.c \ + $(SRC_DIR)/android/android.c \ $(SRC_DIR)/seccomp.c \ $(SRC_DIR)/mount.c \ $(SRC_DIR)/cgroup.c \ - $(SRC_DIR)/network.c \ + $(SRC_DIR)/net/network.c \ $(SRC_DIR)/terminal.c \ $(SRC_DIR)/console.c \ $(SRC_DIR)/pid.c \ @@ -52,18 +52,18 @@ SRCS = $(SRC_DIR)/main.c \ $(SRC_DIR)/environment.c \ $(SRC_DIR)/documentation.c \ $(SRC_DIR)/hardware.c \ - $(SRC_DIR)/ds_iptables.c \ - $(SRC_DIR)/ds_netlink.c \ - $(SRC_DIR)/ds_dhcp.c \ + $(SRC_DIR)/net/iptables.c \ + $(SRC_DIR)/net/netlink.c \ + $(SRC_DIR)/net/dhcp.c \ $(SRC_DIR)/daemon.c \ $(SRC_DIR)/check.c \ - $(SRC_DIR)/x11.c \ - $(SRC_DIR)/virgl-android.c \ - $(SRC_DIR)/pulseaudio-android.c \ + $(SRC_DIR)/android/x11.c \ + $(SRC_DIR)/android/virgl.c \ + $(SRC_DIR)/android/pulseaudio.c \ $(SRC_DIR)/virtualize.c # Compiler flags - hardened warning set, all warnings are errors -CFLAGS = -Wall -Wextra -Wpedantic -Werror -O2 -flto=auto -std=gnu99 -I$(SRC_DIR) -no-pie -pthread +CFLAGS = -Wall -Wextra -Wpedantic -Werror -O2 -flto=auto -std=gnu99 -I$(SRC_DIR)/include -no-pie -pthread CFLAGS += -Wformat=2 -Wformat-security -Wformat-overflow=2 -Wformat-truncation=2 CFLAGS += -Wnull-dereference -Wcast-qual -Wlogical-op -Wshadow -Wdouble-promotion -Wundef CFLAGS += -Wduplicated-cond -Wduplicated-branches -Wimplicit-fallthrough=3 @@ -138,6 +138,7 @@ $(OBJ_DIR): # Compile each source file to an object - runs in parallel via -j$(NPROC) $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) $(msg_cc) + $(Q)mkdir -p $(dir $@) $(Q)$(CC) $(CFLAGS) -c $< -o $@ # Link step diff --git a/src/android.c b/src/android/android.c similarity index 100% rename from src/android.c rename to src/android/android.c diff --git a/src/pulseaudio-android.c b/src/android/pulseaudio.c similarity index 100% rename from src/pulseaudio-android.c rename to src/android/pulseaudio.c diff --git a/src/virgl-android.c b/src/android/virgl.c similarity index 100% rename from src/virgl-android.c rename to src/android/virgl.c diff --git a/src/x11.c b/src/android/x11.c similarity index 100% rename from src/x11.c rename to src/android/x11.c diff --git a/src/droidspace.h b/src/include/droidspace.h similarity index 100% rename from src/droidspace.h rename to src/include/droidspace.h diff --git a/src/socketd_bridge.h b/src/include/socketd_bridge.h similarity index 100% rename from src/socketd_bridge.h rename to src/include/socketd_bridge.h diff --git a/src/socketd_protocol.h b/src/include/socketd_protocol.h similarity index 100% rename from src/socketd_protocol.h rename to src/include/socketd_protocol.h diff --git a/src/virtualize.h b/src/include/virtualize.h similarity index 100% rename from src/virtualize.h rename to src/include/virtualize.h diff --git a/src/ds_dhcp.c b/src/net/dhcp.c similarity index 100% rename from src/ds_dhcp.c rename to src/net/dhcp.c diff --git a/src/ds_iptables.c b/src/net/iptables.c similarity index 100% rename from src/ds_iptables.c rename to src/net/iptables.c diff --git a/src/ds_netlink.c b/src/net/netlink.c similarity index 100% rename from src/ds_netlink.c rename to src/net/netlink.c diff --git a/src/network.c b/src/net/network.c similarity index 100% rename from src/network.c rename to src/net/network.c diff --git a/src/socketd/Makefile b/src/socketd/Makefile index d4bb08d8..59992c5e 100644 --- a/src/socketd/Makefile +++ b/src/socketd/Makefile @@ -30,7 +30,7 @@ endif CXXFLAGS ?= -Wall -Wextra -Wpedantic -Werror \ -O2 -std=c++17 \ - -I$(SRC_DIR) -I$(API_DIR) + -I$(SRC_DIR) -I$(API_DIR)/include LDFLAGS ?= LDLIBS ?= diff --git a/src/socketd/api_server.cpp b/src/socketd/api_server.cpp index 52647d95..1302cbc7 100644 --- a/src/socketd/api_server.cpp +++ b/src/socketd/api_server.cpp @@ -4,7 +4,7 @@ #include "container_inspect.h" #include "snapshot_lists.h" #include "event_log.h" -#include "../droidspace.h" +#include "droidspace.h" #include #include From bb7871c9e2f8ec01276f3eb4a73093a5f7efcbea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E3=81=AE=E5=85=89?= <80254980+Akimitsu333@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:56:18 +0800 Subject: [PATCH 013/211] container: support NixOS su path (#185) Support NixOS-specific su path to run su correctly in NixOS containers. --- src/container.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/container.c b/src/container.c index 82ccf043..63505c2d 100644 --- a/src/container.c +++ b/src/container.c @@ -1198,6 +1198,7 @@ int enter_rootfs(struct ds_config *cfg, const char *user) { char *shell_argv[] = {"su", "-l", (char *)(uintptr_t)user, NULL}; execve("/bin/su", shell_argv, environ); execve("/usr/bin/su", shell_argv, environ); + execve("/run/wrappers/bin/su", shell_argv, environ); /* Fallback: su not available - look up the shell from /etc/passwd */ char user_shell[PATH_MAX] = {0}; @@ -1369,6 +1370,7 @@ int run_in_rootfs(struct ds_config *cfg, int argc, char **argv, "-c", cmd_buf, NULL}; execvp("/bin/su", su_argv); execvp("/usr/bin/su", su_argv); + execvp("/run/wrappers/bin/su", su_argv); ds_error("Failed to exec su for user '%s': %s", as_user, strerror(errno)); _exit(EXIT_FAILURE); From b672a2391bfef2d7ff72c14da2a65fe9bc102754 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 21:50:29 +0530 Subject: [PATCH 014/211] updated gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ee048bf1..3e9ec968 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ logs/ *.keystore local.properties +# IDE / tooling +compile_commands.json + # Nix stuff /result *.qcow2 From c49daea7dc2a04251740365802371cc41c99b4e2 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 22:03:10 +0530 Subject: [PATCH 015/211] refactor: remove PulseAudio default.pa patching in favor of clearing stale runtime configuration Signed-off-by: ravindu644 --- src/android/pulseaudio.c | 159 +++++++++------------------------------ 1 file changed, 34 insertions(+), 125 deletions(-) diff --git a/src/android/pulseaudio.c b/src/android/pulseaudio.c index c920766d..7e87edad 100644 --- a/src/android/pulseaudio.c +++ b/src/android/pulseaudio.c @@ -11,131 +11,12 @@ #define _GNU_SOURCE #include "droidspace.h" #include -#include -#include -#include -#include #include #include #include /* ---- helpers ---------------------------------------------------------- */ -#define PA_DEFAULT_PA TX11_PREFIX "/etc/pulse/default.pa" -#define PA_AAUDIO_LINE "load-module module-aaudio-sink" -#define PA_ALWAYS_LINE "load-module module-always-sink" -#define PA_SLES_LINE "load-module module-sles-sink" -#define PA_CK_LINE "load-module module-console-kit" - -/* - * Patch Termux's default.pa so module-aaudio-sink loads before - * module-always-sink. Without this ordering, module-always-sink spawns a - * null-sink fallback (seeing no real sink yet), then tries to async-unload it - * via D-Bus once AAudio appears -- but there is no system bus on Android, so - * the deferred unload wedges PulseAudio's mainloop permanently. - * - * Idempotent: if already patched, returns immediately. - */ -static void patch_default_pa(void) { - FILE *f = fopen(PA_DEFAULT_PA, "r"); - if (!f) { - ds_warn("[PulseAudio] cannot open %s for patching: %s", PA_DEFAULT_PA, - strerror(errno)); - return; - } - - /* Slurp the whole file */ - fseek(f, 0, SEEK_END); - long sz = ftell(f); - rewind(f); - char *buf = malloc((size_t)sz + 1); - if (!buf) { - fclose(f); - return; - } - size_t rd = fread(buf, 1, (size_t)sz, f); - fclose(f); - buf[rd] = '\0'; - - /* Check if already patched: aaudio line must appear before always-sink - * and console-kit must be commented out. */ - char *pos_aaudio = strstr(buf, PA_AAUDIO_LINE); - char *pos_always = strstr(buf, PA_ALWAYS_LINE); - int ck_active = 0; - char *p = buf; - while ((p = strstr(p, PA_CK_LINE)) != NULL) { - /* walk back to start of line to check for leading '#' */ - char *sol = p; - while (sol > buf && *(sol - 1) != '\n') - sol--; - if (*sol != '#') { - ck_active = 1; - break; - } - p++; - } - - if (pos_aaudio && pos_always && pos_aaudio < pos_always && !ck_active) { - ds_log("[PulseAudio] default.pa already patched, skipping"); - free(buf); - return; - } - - ds_log("[PulseAudio] patching %s ...", PA_DEFAULT_PA); - - /* Write to a tmp file then rename atomically */ - char tmp[PATH_MAX]; - snprintf(tmp, sizeof(tmp), "%s.ds_tmp", PA_DEFAULT_PA); - FILE *out = fopen(tmp, "w"); - if (!out) { - ds_warn("[PulseAudio] cannot write tmp patch file: %s", strerror(errno)); - free(buf); - return; - } - - char *line = buf; - int injected = 0; - while (*line) { - char *nl = strchr(line, '\n'); - size_t len = nl ? (size_t)(nl - line + 1) : strlen(line); - - /* Comment out module-sles-sink (broken on most Android devices) */ - if (strncmp(line, PA_SLES_LINE, strlen(PA_SLES_LINE)) == 0) { - fputc('#', out); - ds_log("[PulseAudio] commented out " PA_SLES_LINE); - } - - /* Comment out module-console-kit: no D-Bus system bus on Android, - * loading it blocks PA's mainloop on a futex waiting for a D-Bus reply - * that never comes. */ - if (strncmp(line, PA_CK_LINE, strlen(PA_CK_LINE)) == 0) { - fputc('#', out); - ds_log("[PulseAudio] commented out " PA_CK_LINE); - } - - /* Inject aaudio load just before always-sink */ - if (!injected && - strncmp(line, PA_ALWAYS_LINE, strlen(PA_ALWAYS_LINE)) == 0) { - fprintf(out, PA_AAUDIO_LINE "\n"); - injected = 1; - ds_log("[PulseAudio] injected " PA_AAUDIO_LINE " before " PA_ALWAYS_LINE); - } - - fwrite(line, 1, len, out); - line += len; - } - fclose(out); - free(buf); - - if (rename(tmp, PA_DEFAULT_PA) != 0) { - ds_warn("[PulseAudio] failed to rename patched file: %s", strerror(errno)); - unlink(tmp); - return; - } - - ds_log("[PulseAudio] default.pa patched successfully"); -} - /* * Resolve the Termux UID from packages.list. * Returns the UID on success, -1 on failure. @@ -210,7 +91,6 @@ static void pulse_child_wrapper(int ready_fd, void *user_data) { * - module-native-protocol-unix: UNIX socket at our custom path * - --exit-idle-time=-1: never exit on idle (we manage lifecycle ourselves) * - --daemonize=no: stay in foreground so our log relay captures all output - * module-aaudio-sink is loaded by default.pa (patched at startup). */ char *argv[] = { TX11_PULSE_BIN, @@ -253,9 +133,8 @@ static void run_pactl_set_default(int uid) { /* Stay in droidspacesd (permissive) */ ds_selinux_enter_domain(); - if (ds_drop_privileges(uid) < 0) { + if (ds_drop_privileges(uid) < 0) _exit(1); - } setenv("PULSE_SERVER", "unix:" TX11_PULSE_SOCKET, 1); setenv("HOME", TX11_HOME, 1); @@ -269,6 +148,38 @@ static void run_pactl_set_default(int uid) { waitpid(p, NULL, 0); } +/* ---- stale config nuke ------------------------------------------------ */ + +#define PA_CONFIG_DIR TX11_HOME "/.config/pulse" + +/* + * Remove PulseAudio's stale runtime dir before each start. + * A leftover cookie/pid from a previous crash causes PA to refuse + * to bind its socket and exit silently with status=1. + */ +static void nuke_pulse_config(void) { + if (access(PA_CONFIG_DIR, F_OK) != 0) + return; + + ds_log("[PulseAudio] nuking stale config: %s", PA_CONFIG_DIR); + + pid_t child = fork(); + if (child < 0) + return; + if (child == 0) { + int devnull = open("/dev/null", O_RDWR); + if (devnull >= 0) { + dup2(devnull, STDOUT_FILENO); + dup2(devnull, STDERR_FILENO); + close(devnull); + } + execl("/system/bin/rm", "rm", "-rf", PA_CONFIG_DIR, NULL); + _exit(1); + } + int st; + waitpid(child, &st, 0); +} + /* ---- spawn ------------------------------------------------------------ */ static pid_t spawn_pulse(int uid) { @@ -301,10 +212,8 @@ int ds_pulse_daemon_start(struct ds_config *cfg) { /* Clean up stale socket from a previous crashed run */ unlink(TX11_PULSE_SOCKET); + nuke_pulse_config(); - /* Ensure default.pa loads aaudio before always-sink to avoid null-sink wedge - */ - patch_default_pa(); ds_log("[PulseAudio] launching daemon (uid=%d)", uid); pid_t child = spawn_pulse(uid); if (child <= 0) From e7a8ed518325264a589d3ac1963a68a3afcccd75 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 6 Jun 2026 18:35:19 +0200 Subject: [PATCH 016/211] Translated using Weblate (Chinese (Traditional Han script)) (#188) Currently translated at 100.0% (467 of 467 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (467 of 467 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hant/ Translation: Droidspaces/Strings Co-authored-by: Jung Chi Wang Co-authored-by: MengHanLOVE <2193438288@qq.com> --- Android/app/src/main/res/values-zh-rCN/strings.xml | 10 ++++++++++ Android/app/src/main/res/values-zh-rTW/strings.xml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index a24dd3ab..0608f605 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -457,4 +457,14 @@ URL 不能为空 添加自定义仓库 只读 + VirGL + PulseAudio + 配置 PulseAudio + 通过 PulseAudio 启用音频支持 + Termux:X11 额外参数 + 例如: -dpi 160 -force-sysvshm + 配置 VirGL 3D 加速功能 + 通过 VirGL 启用 3D 硬件加速 + VirGL 额外参数 + 例如: --multi-clients --angle-vulkan diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index d18875e0..a1a3c6ea 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -452,4 +452,14 @@ 網址必須以 https:// 開頭 目前還沒有訂閱任何自訂儲存庫。 唯讀 + VirGL + PulseAudio + 設定 PulseAudio + 透過 PulseAudio 啟用音訊支援 + Termux:X11 額外參數標記 + 例如: -dpi 160 -force-sysvshm + 配置 VirGL 3D 加速 + 透過 VirGL 啟用 3D 硬體加速 + VirGL 額外參數標記 + 例如: --multi-clients --angle-vulkan From 8c891c64b402affee66577aacdce9fb664fcd04d Mon Sep 17 00:00:00 2001 From: chhr4n_ Date: Sat, 6 Jun 2026 22:05:37 +0530 Subject: [PATCH 017/211] Add OnePlus 7 Pro (guacamole) to community-supported devices (#186) --- Documentation/community-supported-devices.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index e3c72523..ae829a2e 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -31,6 +31,7 @@ This document is a community-maintained compatibility list for Android devices k | **Realme 6, 6i, 6s** | RMX2001L1 | RUI 2.0 - Android 11 | - | `4.14.336` | KSU-Next v3.2.0 / ReSukiSU v4.1.0 | [Source](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo) | [Download](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo/releases/tag/v1.1) | Both | Virgl | Working | [@Prime-TITAN-CameraMan](https://github.com/Prime-TITAN-CameraMan) | RUI 2.0 only & No Firewall Support (UFW/Fail2ban) | | **Redmi K20 Pro (China)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | Working | [@Alhkxsj](https://github.com/Alhkxsj) | Kernel source is stock YAAP kernel; Droidspaces configs applied via automated build workflow | | **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | none | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | Working | [@JackA1ltman](https://github.com/JackA1ltman) | Custom ROM only. Supported NTSYNC. | +| **OnePlus 7 Pro** | GM1911 | LineageOS 17.1 - Android 10 | `QQ3A.200805.001` | `4.14.117` | Magisk | [Source](https://github.com/charan-gn/guacamole-droidspaces-kernel) | [Download](https://github.com/charan-gn/guacamole-droidspaces-kernel/releases/tag/v1.0) | Both | Turnip & Virgl | Working | [@charan-gn](https://github.com/charan-gn) | AnyKernel3 flashable on any ROM. | ## GKI Devices (Kernel 5.4 and up) From 7524efaea2754cf902e4318ea64102dcc759127b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 6 Jun 2026 22:37:16 +0530 Subject: [PATCH 018/211] pulseaudio: fix OpenSL ES and suspend-on-idle hang on Samsung Inject libskcodec.so into LD_PRELOAD (lib64 > lib priority) to fix module-sles-sink dlopen failure on One UI 6.1+; install libandroid-stub and disable module-suspend-on-idle in setup-termux.sh to fix PA futex hang. Signed-off-by: ravindu644 --- scripts/setup-termux.sh | 10 ++++++++++ src/android/pulseaudio.c | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/scripts/setup-termux.sh b/scripts/setup-termux.sh index 0110d24a..44d30a55 100755 --- a/scripts/setup-termux.sh +++ b/scripts/setup-termux.sh @@ -10,6 +10,7 @@ AAUDIO_LINE="load-module module-aaudio-sink" ALWAYS_LINE="load-module module-always-sink" SLES_LINE="load-module module-sles-sink" CK_LINE="load-module module-console-kit" +SIDLE_LINE="load-module module-suspend-on-idle" BOLD="\033[1m" GREEN="\033[1;32m" @@ -36,6 +37,9 @@ DEBIAN_FRONTEND=noninteractive pkg install -y -o Dpkg::Options::="--force-confol log "Installing Termux:X11, VirGL and PulseAudio..." DEBIAN_FRONTEND=noninteractive pkg install -y -o Dpkg::Options::="--force-confold" pulseaudio termux-x11 virglrenderer-android +log "Installing libandroid-stub (OpenSL ES HAL fix)..." +DEBIAN_FRONTEND=noninteractive pkg install -y -o Dpkg::Options::="--force-confold" libandroid-stub + log "Patching $DEFAULT_PA..." if [ ! -f "$DEFAULT_PA" ]; then @@ -55,6 +59,12 @@ if grep -q "^${CK_LINE}" "$DEFAULT_PA"; then detail "Commented out $CK_LINE" fi +# Comment out module-suspend-on-idle (causes PA futex deadlock on audio device changes) +if grep -q "^${SIDLE_LINE}" "$DEFAULT_PA"; then + sed -i "s|^${SIDLE_LINE}|#${SIDLE_LINE}|" "$DEFAULT_PA" + detail "Commented out $SIDLE_LINE" +fi + # Check if already patched (aaudio appears before always-sink) AAUDIO_LINE_NUM=$(grep -n "^${AAUDIO_LINE}$" "$DEFAULT_PA" | head -1 | cut -d: -f1) ALWAYS_LINE_NUM=$(grep -n "^${ALWAYS_LINE}$" "$DEFAULT_PA" | head -1 | cut -d: -f1) diff --git a/src/android/pulseaudio.c b/src/android/pulseaudio.c index 7e87edad..934bca24 100644 --- a/src/android/pulseaudio.c +++ b/src/android/pulseaudio.c @@ -37,6 +37,41 @@ static int pa_resolve_termux_uid(void) { return uid; } +/* + * Probe for libskcodec.so (needed by libskandroidcodec.so on Samsung One UI + * 6.1+). lib64 takes priority; fall back to lib; skip if absent. + */ +#define SKCODEC_LIB64 "/system/lib64/libskcodec.so" +#define SKCODEC_LIB "/system/lib/libskcodec.so" + +static const char *find_skcodec(void) { + if (access(SKCODEC_LIB64, F_OK) == 0) + return SKCODEC_LIB64; + if (access(SKCODEC_LIB, F_OK) == 0) + return SKCODEC_LIB; + return NULL; +} + +/* + * Inject libskcodec.so into LD_PRELOAD so the OpenSL ES module can dlopen + * libskandroidcodec.so which has it as a hidden dependency on Samsung firmware. + */ +static void inject_skcodec_preload(void) { + const char *lib = find_skcodec(); + if (!lib) + return; + + const char *existing = getenv("LD_PRELOAD"); + if (existing && existing[0]) { + char buf[PATH_MAX * 2]; + snprintf(buf, sizeof(buf), "%s:%s", lib, existing); + setenv("LD_PRELOAD", buf, 1); + } else { + setenv("LD_PRELOAD", lib, 1); + } + ds_log("[PulseAudio] LD_PRELOAD += %s (Samsung OpenSL ES fix)", lib); +} + /* ---- daemon child ----------------------------------------------------- */ struct pulse_args { @@ -83,6 +118,10 @@ static void pulse_child_wrapper(int ready_fd, void *user_data) { _exit(1); } + /* Inject libskcodec.so if present -- fixes OpenSL ES on Samsung One UI 6.1+ + */ + inject_skcodec_preload(); + fprintf(stdout, "[PulseAudio] uid=%d socket=%s\n", (int)getuid(), TX11_PULSE_SOCKET); fflush(stdout); From 4d25c6cf3e5cfa8cb0329f3abb6f1a2595707a4c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 7 Jun 2026 00:14:38 +0530 Subject: [PATCH 019/211] chore: remove Chinese feature request template and enforce English-only bug reports --- .github/ISSUE_TEMPLATE/bug_report.yml | 6 ++++++ .github/ISSUE_TEMPLATE/feature_request_CN.md | 19 ------------------- 2 files changed, 6 insertions(+), 19 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/feature_request_CN.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7a2a8286..fc62a38a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -4,6 +4,12 @@ title: "[BUG]: " labels: [bug, needs-triage] body: + - type: markdown + attributes: + value: | + ## 🌐 ENGLISH ONLY + **All issues MUST be written in English. Non-English reports will be closed without response.** + - type: markdown attributes: value: | diff --git a/.github/ISSUE_TEMPLATE/feature_request_CN.md b/.github/ISSUE_TEMPLATE/feature_request_CN.md deleted file mode 100644 index 9727060c..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request_CN.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: 功能请求 -about: 为此项目提议新功能 -title: "[功能请求]" -labels: enhancement -assignees: '' ---- - -**你的功能请求是否与某个问题相关?请描述。** -清晰简洁地描述问题是什么。例如:当 [...] 的时候我总是感到困扰。 - -**描述你想要的解决方案** -清晰简洁地描述你希望发生的事情。 - -**描述你考虑过的替代方案** -清晰简洁地描述你考虑过的任何替代解决方案或功能。 - -**补充信息** -在此处添加关于此功能请求的任何其他背景信息或截图。 From c2c7cb9888c885c5faf5d9970b67d5d73870016b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 7 Jun 2026 00:37:46 +0530 Subject: [PATCH 020/211] app: ContainersScreen: collapse drawer before action callbacks Signed-off-by: ravindu644 --- .../java/com/droidspaces/app/ui/screen/ContainersScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index a9814a2f..6be3914c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -663,21 +663,26 @@ fun ContainersScreen( } }, onEdit = { + onExpandedContainerNameChange(null) onNavigateToEditContainer(container.name) }, onEnter = { onNavigateToContainerDetails(container.name) }, onUninstall = { + onExpandedContainerNameChange(null) showUninstallConfirmation = container }, onMigrate = { + onExpandedContainerNameChange(null) pendingSparseOperation = SparseOperation.Migrate(container) }, onResize = { + onExpandedContainerNameChange(null) pendingSparseOperation = SparseOperation.Resize(container) }, onExport = { + onExpandedContainerNameChange(null) // Generate filename: _yyyyMMdd_HHmmss.tar.gz val timestamp = java.text.SimpleDateFormat( "yyyyMMdd_HHmmss", From 016e744f521cc1a5be7ecea67c4b3cb4f4448c42 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 7 Jun 2026 00:54:05 +0530 Subject: [PATCH 021/211] main: unified root gate before config/scan execution replace scattered getuid() checks with a single gate after daemon proxy; rename is_stateless_cmd -> is_no_root_cmd, add 'check' to exempt list Signed-off-by: ravindu644 --- src/main.c | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/main.c b/src/main.c index b7f3f8ad..7f2a03a0 100644 --- a/src/main.c +++ b/src/main.c @@ -461,13 +461,14 @@ int main(int argc, char **argv) { * Commands that do not require root access (docs, help, version) or * must be run locally to avoid recursive loops (mode) are never proxied. */ - int is_stateless_cmd = + int is_no_root_cmd = (discovered_cmd && (strcmp(discovered_cmd, "docs") == 0 || strcmp(discovered_cmd, "help") == 0 || strcmp(discovered_cmd, "version") == 0 || - strcmp(discovered_cmd, "mode") == 0)); + strcmp(discovered_cmd, "mode") == 0 || + strcmp(discovered_cmd, "check") == 0)); - if (!is_daemon_cmd && !is_stateless_cmd && getenv("DS_NO_PROXY") == NULL) { + if (!is_daemon_cmd && !is_no_root_cmd && getenv("DS_NO_PROXY") == NULL) { int proxy_ret = ds_client_run(argc - 1, argv + 1); if (proxy_ret != -2) { ret = proxy_ret; @@ -475,6 +476,14 @@ int main(int argc, char **argv) { } } + /* Unified root gate: block all non-exempt commands before any work begins */ + if (!is_no_root_cmd && getuid() != 0) { + ds_error("Root privileges required for '%s'", + discovered_cmd ? discovered_cmd : "(unknown)"); + ret = 1; + goto cleanup; + } + /* * Unified Configuration Discovery and Loading * 1. Try to load from explicitly provided config file. @@ -1057,12 +1066,6 @@ int main(int argc, char **argv) { goto cleanup; } - /* Root required commands */ - if (getuid() != 0) { - ds_error("Root privileges required for '%s'", cmd); - ret = 1; - goto cleanup; - } ensure_workspace(); if (strcmp(cmd, "show") == 0) { @@ -1184,11 +1187,6 @@ int main(int argc, char **argv) { } if (strcmp(cmd, "daemon") == 0) { - if (getuid() != 0) { - ds_error("Root privileges required for daemon mode"); - ret = 1; - goto cleanup; - } ret = ds_daemon_run(cfg.foreground, argv); goto cleanup; } From a3278f1c9e4dfd0165ca5372e5838bae672d49ad Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 7 Jun 2026 01:12:26 +0530 Subject: [PATCH 022/211] Makefile: fix missing -I include/src path in debug-hardened target Signed-off-by: ravindu644 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index bc26d342..a416996b 100644 --- a/Makefile +++ b/Makefile @@ -214,7 +214,7 @@ debug-hardened: $(OUT_DIR) @echo "[*] Building hardened debug binary..." @mkdir -p $(OUT_DIR)/.obj/debug $(Q)$(CC) $(SRCS) -o $(OUT_DIR)/$(BINARY_NAME)-hardened \ - -I$(SRC_DIR) -g3 -O1 -pthread -lutil \ + -I$(SRC_DIR)/include -I$(SRC_DIR) -g3 -O1 -pthread -lutil \ -fsanitize=address -fsanitize=undefined -fsanitize=leak \ -fstack-protector-strong -D_FORTIFY_SOURCE=2 \ -Wall -Wextra -Wno-unused-parameter From 700f751690731eb50aed5310bc064c84292f8410 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 7 Jun 2026 01:14:46 +0530 Subject: [PATCH 023/211] daemon: fix PTY input deadlock and backpressure on large paste replace blocking write_all(master) with non-blocking write + dynamic wbuf; flush via EPOLLOUT, suspend conn EPOLLIN at 192KB, resume at 64KB Signed-off-by: ravindu644 --- src/daemon.c | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/src/daemon.c b/src/daemon.c index fa9c2463..db9d15b6 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -42,6 +42,10 @@ #define DS_MAX_ARGC 64 #define DS_MAX_ARG 8192 #define DS_IOBUF 8192 +#define PTY_WBUF_MAX (256 * 1024) /* absolute buffer cap for PTY master input \ + */ +#define PTY_WBUF_HIGH (192 * 1024) /* suspend conn EPOLLIN above this */ +#define PTY_WBUF_LOW (64 * 1024) /* resume conn EPOLLIN below this */ #define MSG_OUT ((uint8_t)0x01) #define MSG_ERR ((uint8_t)0x02) @@ -461,6 +465,15 @@ static void handle_session(int conn, ds_req_t *r) { int exit_code = EXIT_PENDING; int child_done = 0; /* 0=running, 1=killed, 2=normal-exit-seen */ + /* Non-blocking write buffer for PTY master input. + * Prevents blocking the event loop when the PTY kernel buffer is full + * (e.g. large paste). Excess bytes are queued here and flushed via + * EPOLLOUT on master. */ + uint8_t *pty_wbuf = NULL; + size_t pty_wbuf_len = 0; + size_t pty_wbuf_cap = 0; + int conn_suspended = 0; /* 1 = conn EPOLLIN removed (wbuf high-water) */ + for (;;) { int nfds = epoll_wait(epfd, events, 8, -1); if (nfds < 0 && errno != EINTR) @@ -497,8 +510,63 @@ static void handle_session(int conn, ds_req_t *r) { goto session_end; } if (type == MSG_OUT && mlen > 0 && mlen <= (uint32_t)sizeof(buf)) { - if (read_exact(conn, buf, mlen) == 0) - write_all(master, buf, mlen); + if (read_exact(conn, buf, mlen) == 0) { + /* Non-blocking write to PTY master; queue overflow into wbuf. */ + size_t written = 0; + if (pty_wbuf_len == 0) { + /* Fast path: no pending data, try direct write first. */ + ssize_t w = write(master, buf, mlen); + if (w > 0) + written = (size_t)w; + } + size_t rem = mlen - written; + if (rem > 0) { + /* Buffer remainder; grow wbuf if needed (cap at PTY_WBUF_MAX). + */ + if (pty_wbuf_len + rem <= PTY_WBUF_MAX) { + if (pty_wbuf_len + rem > pty_wbuf_cap) { + /* Double capacity without overflow: clamp before shifting. + */ + size_t ncap = pty_wbuf_cap ? pty_wbuf_cap : DS_IOBUF; + while (ncap < pty_wbuf_len + rem) { + if (ncap > PTY_WBUF_MAX / 2) { + ncap = PTY_WBUF_MAX; + break; + } + ncap *= 2; + } + if (ncap > PTY_WBUF_MAX) + ncap = PTY_WBUF_MAX; + /* Preserve old pointer on realloc failure to avoid leak. */ + uint8_t *nb = realloc(pty_wbuf, ncap); + if (nb) { + pty_wbuf = nb; + pty_wbuf_cap = ncap; + } + /* If realloc failed, pty_wbuf unchanged; fall through to + * the capacity check below which will drop the frame. */ + } + if (pty_wbuf_len + rem <= pty_wbuf_cap) { + memcpy(pty_wbuf + pty_wbuf_len, buf + written, rem); + pty_wbuf_len += rem; + /* Arm EPOLLOUT on master to flush wbuf. */ + ev.events = EPOLLIN | EPOLLOUT | EPOLLHUP | EPOLLERR; + ev.data.fd = master; + epoll_ctl(epfd, EPOLL_CTL_MOD, master, &ev); + /* High-watermark: suspend conn input to apply backpressure. + * Prevents unbounded growth; conn resumes at low-watermark. + */ + if (!conn_suspended && pty_wbuf_len >= PTY_WBUF_HIGH) { + ev.events = EPOLLHUP | EPOLLERR; /* no EPOLLIN */ + ev.data.fd = conn; + epoll_ctl(epfd, EPOLL_CTL_MOD, conn, &ev); + conn_suspended = 1; + } + } + } + /* wbuf full and conn suspended: no data lost, client stalls. */ + } + } } else if (type == MSG_WINCH && mlen == 4) { uint16_t wd[2]; if (read_exact(conn, wd, 4) == 0) { @@ -518,6 +586,50 @@ static void handle_session(int conn, ds_req_t *r) { } } } + } else if (is_pty && fd == master && (events[i].events & EPOLLOUT)) { + /* Flush pending wbuf to PTY master. */ + while (pty_wbuf_len > 0) { + ssize_t w = write(master, pty_wbuf, pty_wbuf_len); + if (w > 0) { + pty_wbuf_len -= (size_t)w; + if (pty_wbuf_len > 0) + memmove(pty_wbuf, pty_wbuf + w, pty_wbuf_len); + } else if (w < 0 && errno == EAGAIN) { + break; /* PTY buffer still full; wait for next EPOLLOUT */ + } else { + break; + } + } + if (pty_wbuf_len == 0) { + /* All flushed; stop watching for writability. */ + ev.events = EPOLLIN | EPOLLHUP | EPOLLERR; + ev.data.fd = master; + epoll_ctl(epfd, EPOLL_CTL_MOD, master, &ev); + } + /* Low-watermark: resume conn input once pressure drops. */ + if (conn_suspended && pty_wbuf_len < PTY_WBUF_LOW) { + ev.events = EPOLLIN | EPOLLHUP | EPOLLERR; + ev.data.fd = conn; + epoll_ctl(epfd, EPOLL_CTL_MOD, conn, &ev); + conn_suspended = 0; + } + /* Also service any pending output from master in this iteration. */ + if (events[i].events & EPOLLIN) { + for (;;) { + ssize_t n = read(master, buf, sizeof(buf)); + if (n > 0) { + if (send_frame(conn, MSG_OUT, buf, (uint32_t)n) < 0) { + kill(child, SIGHUP); + waitpid(child, NULL, 0); + goto session_end; + } + } else { + if (errno == EINTR) + continue; + break; + } + } + } } else { /* this is a read fd from the child */ if (events[i].events & (EPOLLIN | EPOLLHUP)) { @@ -582,6 +694,7 @@ static void handle_session(int conn, ds_req_t *r) { } session_end: + free(pty_wbuf); if (sfd >= 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, sfd, NULL); close(sfd); From 0aa6dcd8b9bbf0fa0c6d789b89a68eb3ea7e12d2 Mon Sep 17 00:00:00 2001 From: fatalcoder524 <11532648+fatalcoder524@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:09:27 +0530 Subject: [PATCH 024/211] Add More Wild Kernels OnePlus Kernel Variants (#192) --- Documentation/community-supported-devices.md | 237 +++++++++++-------- 1 file changed, 141 insertions(+), 96 deletions(-) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index ae829a2e..ed50535a 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -83,102 +83,147 @@ This document is a community-maintained compatibility list for Android devices k | **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | Working | [@superuseryu](https://github.com/superuseryu) | Other KSU variants may fail due to SELinux context reset. | | **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | Working | [@MySelly](https://github.com/MySelly) | - | | **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | Working | [@miaizhe](https://github.com/miaizhe) | - | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10R | CPH2411 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10R | CPH2411 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android 15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 PJZ | PJZ110 | Android 15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13S | CPH2723 | Android 15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13S | CPH2723 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13T | PKX110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13T | PKX110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15 | CPH2747, CPH2745, PLK110, CPH2749 | Android 16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15R | CPH2769, CPH2767 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15T | PLZ110 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE | PGKM10 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE | PGKM10 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 | PHK110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 | PHK110 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 Pro | PJA110 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 Pro | PJA110 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 | PJE110 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 | PJE110 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android 14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3V | PJF110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3V | PJF110 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 | PKG110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 | PKG110 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Pro | PKR110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Pro | PKR110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Racing | PLF110 | Android 15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Racing | PLF110 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_racehttps://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Ultra | PLC110 | Android 15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Ultra | PLC110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 6 | PLQ110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 6T | PLR110 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE Race | PGZ110 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE Race | PGZ110 | Android 15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 3 | CPH2491, CPH2493 | Android 14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 3 | CPH2491, CPH2493 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 | CPH2663, CPH2661 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 | CPH2663, CPH2661 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 CE | CPH2613 | Android 15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 CE | CPH2613 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 5 | CPH2709, CPH2707 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 5 | CPH2709, CPH2707 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 6 | CPH2793, CPH2795 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_b_16.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE5 | CPH2719, CPH2717 | Android 15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE5 | CPH2719, CPH2717 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord N30 SE | CPH2605 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Open | CPH2551 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Open | CPH2551 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 MT6991 | OPD2508 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 Pro | OPD2413 | Android 15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 Pro | OPD2413 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 SM8650 | OPD2403 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 SM8650 | OPD2403 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 MT6897 | OPD2407 | Android 15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 MT6897 | OPD2407 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 Pro | OPD2513 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 SM8750 | OPD2415 | Android 15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 SM8750 | OPD2415 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 4 | OPD2514 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Go | OPD2304, OPD2305 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_u_14.0.0_onepluspad_go) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Go 2 | OPD2504, OPD2505 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6878/tree/oneplus/mt6878_b_16.0.0_pad_go_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Lite | OPD2480, OPD2481 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Lite | OPD2480, OPD2481 | Android 16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android 14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Turbo 6 | PLU110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Turbo 6V | PLY110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/210ba17c8f19beed0212eb4f209109e35ef35746) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10R | CPH2411 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10R | CPH2411 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/bdebb03938ab5f6719abc50af454d76a1b12df67) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/45235533105840dc875a80e9d5b4cd881e453472) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/7d9f64c901ac11ce80bf78682d33fd336c825fd9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/bee3255be1f407e7cff7b0b8f1c26daaccbbb510) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11R | CPH2487 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11R | CPH2487 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/6821a3751c4fd016a7ee4da5aea635d42a8b3d43) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11R | CPH2487 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 11R | CPH2487 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/c461b745bc361581d0b419effd284367d85f1e57) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/2936373a255da66646a05384e993d55a8dfecc80) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/86a89359565410962004fd838e3d2e3b42a047ca) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/717aea450444dfd00db7d60c99cff77fbcbf014e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/cec4a1056a32dfdd68c23767f6c88bb6c698f1bf) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13 PJZ | PJZ110 | Android15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/d68f486cb00b246abf4b7b66375082dc8bcb884e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/35f7c28cf36e19e3243a9db8496d5e70b94e34ce) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13S | CPH2723 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13S | CPH2723 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13T | PKX110 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/06be241a7b9eefa01db78447cc7877339b3685f3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13T | PKX110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 13T | PKX110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 15 | CPH2747, CPH2745, PLK110, CPH2749 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 15R | CPH2769, CPH2767 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus 15T | PLZ110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE | PGKM10 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE | PGKM10 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/e5f7c2a470fd9d8b43c5082aed8b58a6d5a2bf7c) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE | PGKM10 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2 | PHK110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/ef7ca029d0f6beddf38e60f6b9016935d0f1ac55) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2 | PHK110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2 | PHK110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2 Pro | PJA110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/95b9da8a7cd99053f65e7d4cebec310b5f5d5744) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2 Pro | PJA110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2 Pro | PJA110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2V | PHP110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2V | PHP110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/8a49e1e297768839d78730f15e8f34e0d0b59f2b) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2V | PHP110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 2V | PHP110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3 | PJE110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3a51ce558bfa3a1c7eeaaab69ee54d5e6bd03afb) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3 | PJE110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3 | PJE110 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3dc000f085497a728cdf21b28fab9d3ef632f5fd) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3 | PJE110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3 Pro | PJX110 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3 Pro | PJX110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/1cee682fb60cc9ffd86a2342fa8051bcde77116f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3 Pro | PJX110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3 Pro | PJX110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3V | PJF110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/ac51a5e4c2b0f13f06e9234e415bfa68fb24a8b5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3V | PJF110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3V | PJF110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/65c3035422d899d95bfde4e30dc426712ffe52f2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 3V | PJF110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 | PKG110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/8c797fd3ff3f76248b44a619410ace1277692907) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 | PKG110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 | PKG110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/7499247fd6e0669a062ec44cce948ec1e9d4c75e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 | PKG110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 Pro | PKR110 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 Pro | PKR110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 Pro | PKR110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 Racing | PLF110 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 Racing | PLF110 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 Ultra | PLC110 | Android15 (OOS/COS 15) | 6.6.50 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/06b1dfbb8f777d329a25d6f932f58f0e45d87627) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 Ultra | PLC110 | Android15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 5 Ultra | PLC110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 6 | PLQ110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 6 Ultra | PMB110 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE 6T | PLR110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE Race | PGZ110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE Race | PGZ110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/7a64b08a000cc7040d99543cc7093aaa13e0c786) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE Race | PGZ110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/6409af87cfda3739caff4ee29eb28e77abe8a290) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus ACE Race | PGZ110 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 3 | CPH2491, CPH2493 | Android14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 3 | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/b9b40be7bbe4e2c201ba263ac72f2e1b10171998) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 3 | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 4 | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/a3c5659c859e888102a144be0884fbf3731b170f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 4 | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 4 | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/975bcbc2e288c3b7c2d2799421c7f356f9c78ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 4 | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 4 CE | CPH2613 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/d8187f64c9e60fee0969a678a67ac77114a926d9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 4 CE | CPH2613 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/0d28103245225a507d4f4b3ca657d3e27aae2f20) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 4 CE | CPH2613 | Android15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 4 CE | CPH2613 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 5 | CPH2709, CPH2707 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 5 | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/fbeaa9ff25bef199618b9e47004f2eb8fe135f9f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 5 | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord 6 | CPH2793, CPH2795 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_b_16.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord CE5 | CPH2719, CPH2717 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord CE5 | CPH2719, CPH2717 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Nord N30 SE | CPH2605 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Open | CPH2551 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/2a845b8afd7afc3d72679ddbe5adb07c390966bc) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Open | CPH2551 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Open | CPH2551 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/1e88584a40b3b055e5ff3796f9a184655ef2d0b8) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Open | CPH2551 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 2 MT6991 | OPD2508 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 2 Pro | OPD2413 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/68c46d941cc1818d49f8a8805c79e63fbee01eac) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 2 Pro | OPD2413 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 2 Pro | OPD2413 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 2 SM8650 | OPD2403 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/f5a170ce44c7657894247de3de41d23a89e248f7) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 2 SM8650 | OPD2403 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 2 SM8650 | OPD2403 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 3 MT6897 | OPD2407 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/cbae97472bca105f69ffaef126065f9ab06e1c12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 3 MT6897 | OPD2407 | Android15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 3 MT6897 | OPD2407 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 3 Pro | OPD2513 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 3 SM8750 | OPD2415 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/35366be96d7e73d1d0d7093e1cf2fafd58f9c7d4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 3 SM8750 | OPD2415 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 3 SM8750 | OPD2415 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad 4 | OPD2514 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad Go | OPD2304, OPD2305 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_u_14.0.0_onepluspad_go) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad Go 2 | OPD2504, OPD2505 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6878/tree/oneplus/mt6878_b_16.0.0_pad_go_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad Lite | OPD2480, OPD2481 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad Lite | OPD2480, OPD2481 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad MT6983 | OPD2203 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad MT6983 | OPD2203 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/4790201712d757f4f337cd55e5709255edc8bee5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad MT6983 | OPD2203 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad MT6983 | OPD2203 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad Pro | OPD2404 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad Pro | OPD2404 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/dcb47d3dcba84e13ae678f18b4629b22333e8898) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad Pro | OPD2404 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Pad Pro | OPD2404 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Turbo 6 | PLU110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| OnePlus Turbo 6V | PLY110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | ## Contribution guidelines From b8729bf650b79460810d8de70e1d8551fe797fae Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 7 Jun 2026 09:13:04 +0530 Subject: [PATCH 025/211] docs: bold all OnePlus device names in community-supported-devices table Signed-off-by: ravindu644 --- Documentation/community-supported-devices.md | 282 +++++++++---------- 1 file changed, 141 insertions(+), 141 deletions(-) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index ed50535a..126e9b58 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -83,147 +83,147 @@ This document is a community-maintained compatibility list for Android devices k | **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | Working | [@superuseryu](https://github.com/superuseryu) | Other KSU variants may fail due to SELinux context reset. | | **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | Working | [@MySelly](https://github.com/MySelly) | - | | **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | Working | [@miaizhe](https://github.com/miaizhe) | - | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/210ba17c8f19beed0212eb4f209109e35ef35746) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10R | CPH2411 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10R | CPH2411 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/bdebb03938ab5f6719abc50af454d76a1b12df67) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/45235533105840dc875a80e9d5b4cd881e453472) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/7d9f64c901ac11ce80bf78682d33fd336c825fd9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/bee3255be1f407e7cff7b0b8f1c26daaccbbb510) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/6821a3751c4fd016a7ee4da5aea635d42a8b3d43) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/c461b745bc361581d0b419effd284367d85f1e57) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/2936373a255da66646a05384e993d55a8dfecc80) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/86a89359565410962004fd838e3d2e3b42a047ca) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/717aea450444dfd00db7d60c99cff77fbcbf014e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/cec4a1056a32dfdd68c23767f6c88bb6c698f1bf) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 PJZ | PJZ110 | Android15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/d68f486cb00b246abf4b7b66375082dc8bcb884e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/35f7c28cf36e19e3243a9db8496d5e70b94e34ce) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13S | CPH2723 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13S | CPH2723 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13T | PKX110 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/06be241a7b9eefa01db78447cc7877339b3685f3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13T | PKX110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13T | PKX110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15 | CPH2747, CPH2745, PLK110, CPH2749 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15R | CPH2769, CPH2767 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15T | PLZ110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE | PGKM10 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE | PGKM10 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/e5f7c2a470fd9d8b43c5082aed8b58a6d5a2bf7c) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE | PGKM10 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 | PHK110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/ef7ca029d0f6beddf38e60f6b9016935d0f1ac55) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 | PHK110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 | PHK110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 Pro | PJA110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/95b9da8a7cd99053f65e7d4cebec310b5f5d5744) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 Pro | PJA110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 Pro | PJA110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/8a49e1e297768839d78730f15e8f34e0d0b59f2b) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 | PJE110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3a51ce558bfa3a1c7eeaaab69ee54d5e6bd03afb) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 | PJE110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 | PJE110 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3dc000f085497a728cdf21b28fab9d3ef632f5fd) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 | PJE110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/1cee682fb60cc9ffd86a2342fa8051bcde77116f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3V | PJF110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/ac51a5e4c2b0f13f06e9234e415bfa68fb24a8b5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3V | PJF110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3V | PJF110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/65c3035422d899d95bfde4e30dc426712ffe52f2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3V | PJF110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 | PKG110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/8c797fd3ff3f76248b44a619410ace1277692907) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 | PKG110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 | PKG110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/7499247fd6e0669a062ec44cce948ec1e9d4c75e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 | PKG110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Pro | PKR110 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Pro | PKR110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Pro | PKR110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Racing | PLF110 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Racing | PLF110 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Ultra | PLC110 | Android15 (OOS/COS 15) | 6.6.50 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/06b1dfbb8f777d329a25d6f932f58f0e45d87627) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Ultra | PLC110 | Android15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Ultra | PLC110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 6 | PLQ110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 6 Ultra | PMB110 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 6T | PLR110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE Race | PGZ110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE Race | PGZ110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/7a64b08a000cc7040d99543cc7093aaa13e0c786) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE Race | PGZ110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/6409af87cfda3739caff4ee29eb28e77abe8a290) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE Race | PGZ110 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 3 | CPH2491, CPH2493 | Android14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 3 | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/b9b40be7bbe4e2c201ba263ac72f2e1b10171998) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 3 | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/a3c5659c859e888102a144be0884fbf3731b170f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/975bcbc2e288c3b7c2d2799421c7f356f9c78ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 CE | CPH2613 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/d8187f64c9e60fee0969a678a67ac77114a926d9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 CE | CPH2613 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/0d28103245225a507d4f4b3ca657d3e27aae2f20) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 CE | CPH2613 | Android15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 CE | CPH2613 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 5 | CPH2709, CPH2707 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 5 | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/fbeaa9ff25bef199618b9e47004f2eb8fe135f9f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 5 | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 6 | CPH2793, CPH2795 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_b_16.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE5 | CPH2719, CPH2717 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE5 | CPH2719, CPH2717 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord N30 SE | CPH2605 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Open | CPH2551 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/2a845b8afd7afc3d72679ddbe5adb07c390966bc) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Open | CPH2551 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Open | CPH2551 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/1e88584a40b3b055e5ff3796f9a184655ef2d0b8) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Open | CPH2551 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 MT6991 | OPD2508 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 Pro | OPD2413 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/68c46d941cc1818d49f8a8805c79e63fbee01eac) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 Pro | OPD2413 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 Pro | OPD2413 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 SM8650 | OPD2403 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/f5a170ce44c7657894247de3de41d23a89e248f7) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 SM8650 | OPD2403 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 SM8650 | OPD2403 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 MT6897 | OPD2407 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/cbae97472bca105f69ffaef126065f9ab06e1c12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 MT6897 | OPD2407 | Android15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 MT6897 | OPD2407 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 Pro | OPD2513 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 SM8750 | OPD2415 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/35366be96d7e73d1d0d7093e1cf2fafd58f9c7d4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 SM8750 | OPD2415 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 SM8750 | OPD2415 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 4 | OPD2514 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Go | OPD2304, OPD2305 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_u_14.0.0_onepluspad_go) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Go 2 | OPD2504, OPD2505 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6878/tree/oneplus/mt6878_b_16.0.0_pad_go_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Lite | OPD2480, OPD2481 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Lite | OPD2480, OPD2481 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/4790201712d757f4f337cd55e5709255edc8bee5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/dcb47d3dcba84e13ae678f18b4629b22333e8898) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Turbo 6 | PLU110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Turbo 6V | PLY110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/210ba17c8f19beed0212eb4f209109e35ef35746) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10R** | CPH2411 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10R** | CPH2411 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/bdebb03938ab5f6719abc50af454d76a1b12df67) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/45235533105840dc875a80e9d5b4cd881e453472) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/7d9f64c901ac11ce80bf78682d33fd336c825fd9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/bee3255be1f407e7cff7b0b8f1c26daaccbbb510) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/6821a3751c4fd016a7ee4da5aea635d42a8b3d43) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/c461b745bc361581d0b419effd284367d85f1e57) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/2936373a255da66646a05384e993d55a8dfecc80) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/86a89359565410962004fd838e3d2e3b42a047ca) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/717aea450444dfd00db7d60c99cff77fbcbf014e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 CPH** | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/cec4a1056a32dfdd68c23767f6c88bb6c698f1bf) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 CPH** | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 PJZ** | PJZ110 | Android15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/d68f486cb00b246abf4b7b66375082dc8bcb884e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/35f7c28cf36e19e3243a9db8496d5e70b94e34ce) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13S** | CPH2723 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13S** | CPH2723 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/06be241a7b9eefa01db78447cc7877339b3685f3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 15** | CPH2747, CPH2745, PLK110, CPH2749 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 15R** | CPH2769, CPH2767 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 15T** | PLZ110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/e5f7c2a470fd9d8b43c5082aed8b58a6d5a2bf7c) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/ef7ca029d0f6beddf38e60f6b9016935d0f1ac55) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2 Pro** | PJA110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/95b9da8a7cd99053f65e7d4cebec310b5f5d5744) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2 Pro** | PJA110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2 Pro** | PJA110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/8a49e1e297768839d78730f15e8f34e0d0b59f2b) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3a51ce558bfa3a1c7eeaaab69ee54d5e6bd03afb) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3dc000f085497a728cdf21b28fab9d3ef632f5fd) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3 Pro** | PJX110 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3 Pro** | PJX110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/1cee682fb60cc9ffd86a2342fa8051bcde77116f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3 Pro** | PJX110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3 Pro** | PJX110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/ac51a5e4c2b0f13f06e9234e415bfa68fb24a8b5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/65c3035422d899d95bfde4e30dc426712ffe52f2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/8c797fd3ff3f76248b44a619410ace1277692907) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/7499247fd6e0669a062ec44cce948ec1e9d4c75e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Racing** | PLF110 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Racing** | PLF110 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android15 (OOS/COS 15) | 6.6.50 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/06b1dfbb8f777d329a25d6f932f58f0e45d87627) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6** | PLQ110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6 Ultra** | PMB110 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6T** | PLR110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/7a64b08a000cc7040d99543cc7093aaa13e0c786) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/6409af87cfda3739caff4ee29eb28e77abe8a290) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/b9b40be7bbe4e2c201ba263ac72f2e1b10171998) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/a3c5659c859e888102a144be0884fbf3731b170f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/975bcbc2e288c3b7c2d2799421c7f356f9c78ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/d8187f64c9e60fee0969a678a67ac77114a926d9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/0d28103245225a507d4f4b3ca657d3e27aae2f20) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 5** | CPH2709, CPH2707 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 5** | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/fbeaa9ff25bef199618b9e47004f2eb8fe135f9f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 5** | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 6** | CPH2793, CPH2795 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE4 Lite** | CPH2619, CPH2621 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE4 Lite** | CPH2619, CPH2621 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_b_16.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE5** | CPH2719, CPH2717 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE5** | CPH2719, CPH2717 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord N30 SE** | CPH2605 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/2a845b8afd7afc3d72679ddbe5adb07c390966bc) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/1e88584a40b3b055e5ff3796f9a184655ef2d0b8) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 MT6991** | OPD2508 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/68c46d941cc1818d49f8a8805c79e63fbee01eac) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 SM8650** | OPD2403 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/f5a170ce44c7657894247de3de41d23a89e248f7) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 SM8650** | OPD2403 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 SM8650** | OPD2403 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 MT6897** | OPD2407 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/cbae97472bca105f69ffaef126065f9ab06e1c12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 MT6897** | OPD2407 | Android15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 MT6897** | OPD2407 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 Pro** | OPD2513 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 SM8750** | OPD2415 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/35366be96d7e73d1d0d7093e1cf2fafd58f9c7d4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 SM8750** | OPD2415 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 SM8750** | OPD2415 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 4** | OPD2514 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Go** | OPD2304, OPD2305 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_u_14.0.0_onepluspad_go) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Go 2** | OPD2504, OPD2505 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6878/tree/oneplus/mt6878_b_16.0.0_pad_go_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Lite** | OPD2480, OPD2481 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Lite** | OPD2480, OPD2481 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/4790201712d757f4f337cd55e5709255edc8bee5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/dcb47d3dcba84e13ae678f18b4629b22333e8898) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Turbo 6** | PLU110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Turbo 6V** | PLY110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | ## Contribution guidelines From d9af43e886c5b53969058384205fc0ad50ac3dfe Mon Sep 17 00:00:00 2001 From: Yoshinobu Date <82716291+yoshi3jp@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:49:39 +0900 Subject: [PATCH 026/211] build: prepare in-house BusyBox for WebUI daemon (#191) * build: add patched BusyBox as submodule * build: enable socketd backend bridge by default --- .gitmodules | 3 +++ Makefile | 10 +++++----- external/busybox | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 .gitmodules create mode 160000 external/busybox diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..80b65828 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/busybox"] + path = external/busybox + url = https://github.com/Droidspaces/busybox-droidspaces.git diff --git a/Makefile b/Makefile index a416996b..8ea48fab 100644 --- a/Makefile +++ b/Makefile @@ -18,9 +18,9 @@ NPROC := $(shell nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || ech # Verbose control - V=1 shows full commands, V=0 (default) shows kernel-style short logs V ?= 0 -# Optional private control bridge for the external C++ droidspaces-socketd. -# Keep this off by default so the stock static Droidspaces binary stays unchanged. -ENABLE_SOCKETD_BACKEND ?= 0 +# Optional private control bridge used by API-capable WebUI daemons. +# Enabled by default for WebUI-ready builds; set to 0 for minimal CLI-only builds. +ENABLE_SOCKETD_BACKEND ?= 1 ifeq ($(V),1) Q = @@ -122,8 +122,8 @@ help: @echo "" @echo "Options:" @echo " V=1 - Show full compiler commands" - @echo " ENABLE_SOCKETD_BACKEND=1" - @echo " - Compile the private droidspaces-socketd backend bridge" + @echo " ENABLE_SOCKETD_BACKEND=0" + @echo " - Disable the private API backend bridge for minimal builds" @echo "" @echo "Other:" @echo " make clean - Remove build artifacts" diff --git a/external/busybox b/external/busybox new file mode 160000 index 00000000..1745348a --- /dev/null +++ b/external/busybox @@ -0,0 +1 @@ +Subproject commit 1745348ad12c601c60de32a7c11c2f7a13508803 From b6a3e4114c92b75b677737f9cd508971a7350270 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 7 Jun 2026 20:36:26 +0530 Subject: [PATCH 027/211] requirements: add Termux requirements section with curl codebox Signed-off-by: ravindu644 --- .../com/droidspaces/app/ui/screen/RequirementsScreen.kt | 9 ++++++++- Android/app/src/main/res/values/strings.xml | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt index d469abca..8853a96e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt @@ -95,6 +95,14 @@ fun RequirementsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Termux Requirements Section + ExpandableKernelRequirementsSection( + title = context.getString(R.string.termux_requirements), + code = "# Run this command inside Termux to install dependencies needed for Termux:X11, VirGL and PulseAudio\n\ncurl -fsSL https://github.com/ravindu644/Droidspaces-OSS/raw/refs/heads/dev/scripts/setup-termux.sh | bash", + guideUrl = null, + snackbarHostState = snackbarHostState + ) + // non-GKI Kernel Requirements Section ExpandableKernelRequirementsSection( title = context.getString(R.string.kernel_requirements_nongki), @@ -532,4 +540,3 @@ private fun CheckRequirementsButton( } } } - diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 68160097..04efce50 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -70,6 +70,7 @@ Reinstall binaries and boot module Requirements Kernel Requirements + Termux Requirements non-GKI Kernel Requirements (<=4.19) GKI Kernel Requirements (>=5.4) Copy From 5c1355d3f0e6af8a3027971b506925baebbe20c3 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 7 Jun 2026 22:57:24 +0530 Subject: [PATCH 028/211] post_extract_fixes: always generate a machine ID regardless of already applied post-extract fixes Signed-off-by: ravindu644 --- .../app/src/main/assets/post_extract_fixes.sh | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Android/app/src/main/assets/post_extract_fixes.sh b/Android/app/src/main/assets/post_extract_fixes.sh index 77731e25..be73e6e1 100755 --- a/Android/app/src/main/assets/post_extract_fixes.sh +++ b/Android/app/src/main/assets/post_extract_fixes.sh @@ -46,13 +46,17 @@ if $TEST ! -d "$ROOTFS_PATH"; then exit 1 fi -log "Starting post-extraction fixes for: $ROOTFS_PATH" +# Helper to execute a command inside the chroot environment +run_in_chroot() { + local command="$*" + local common_exports="export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/libexec:/opt/bin'; export TMPDIR='/tmp';" -# Check if fixes were already applied -if $TEST -f "$ROOTFS_PATH/etc/droidspaces"; then - log "Post-extraction fixes already applied, skipping..." - exit 0 -fi + # We use busybox chroot to run commands inside the rootfs + # Note: This assumes /bin/sh exists in the rootfs + $CHROOT "$ROOTFS_PATH" /bin/sh -c "$common_exports $command" +} + +log "Starting post-extraction fixes for: $ROOTFS_PATH" # Detect NixOS if $TEST -d "$ROOTFS_PATH/nix"; then @@ -62,15 +66,17 @@ if $TEST -d "$ROOTFS_PATH/nix"; then exit 0 fi -# Helper to execute a command inside the chroot environment -run_in_chroot() { - local command="$*" - local common_exports="export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/libexec:/opt/bin'; export TMPDIR='/tmp';" +# Pre-commit machine-id to prevent first-boot deadlock in Fedora 44 +log "Generating machine-id..." +$CAT /proc/sys/kernel/random/uuid | $BB tr -d '-' | $BB tr -d '\n' > "$ROOTFS_PATH/etc/machine-id" +$ECHO "" >> "$ROOTFS_PATH/etc/machine-id" +$CHMOD 444 "$ROOTFS_PATH/etc/machine-id" - # We use busybox chroot to run commands inside the rootfs - # Note: This assumes /bin/sh exists in the rootfs - $CHROOT "$ROOTFS_PATH" /bin/sh -c "$common_exports $command" -} +# Check if fixes were already applied +if $TEST -f "$ROOTFS_PATH/etc/droidspaces"; then + log "Post-extraction fixes already applied, skipping..." + exit 0 +fi # --- 1. General Fixes (Init-independent) --- @@ -105,12 +111,6 @@ if $TEST -f "$ROOTFS_PATH/usr/bin/systemctl" || $TEST -f "$ROOTFS_PATH/bin/syste log "Systemd detected (at $GUEST_SYSTEMD_PATH), applying fixes..." - # 00. Pre-commit machine-id to prevent first-boot deadlock in Fedora 44 - log "Generating machine-id..." - $CAT /proc/sys/kernel/random/uuid | $BB tr -d '-' | $BB tr -d '\n' > "$ROOTFS_PATH/etc/machine-id" - $ECHO "" >> "$ROOTFS_PATH/etc/machine-id" - $CHMOD 444 "$ROOTFS_PATH/etc/machine-id" - # 01. Mask problematic services for Android kernels log "Masking problematic systemd services..." # Mask systemd-networkd-wait-online.service From de132d30ef20cca7e519351ec5e4895ae91b0ce4 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Mon, 8 Jun 2026 09:51:29 +0530 Subject: [PATCH 029/211] fix: wakelock now requests battery optimization exemption Acquire PARTIAL_WAKE_LOCK then fire ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS (API 23+); fallback to ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS for OEMs that block the direct dialog (MIUI etc.). Add manifest permission. Signed-off-by: ravindu644 --- Android/app/src/main/AndroidManifest.xml | 1 + .../app/service/TerminalSessionService.kt | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index bb4a8abe..8f254a33 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + = Build.VERSION_CODES.M) { + val pm2 = getSystemService(POWER_SERVICE) as PowerManager + if (!pm2.isIgnoringBatteryOptimizations(packageName)) { + try { + // Direct per-app exemption dialog (requires the manifest permission). + startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } catch (e: Exception) { + // Fallback: open the general battery optimization settings screen. + // Covers OEMs that block the direct intent (some Xiaomi/MIUI builds). + try { + startActivity( + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } catch (e2: Exception) { + e2.printStackTrace() + } + } + } + } } } else { wakeLock?.release() From 4b521a244f9116f61e15940ce9552c86ad9dcbe0 Mon Sep 17 00:00:00 2001 From: Yoshinobu Date <82716291+yoshi3jp@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:59:12 +0900 Subject: [PATCH 030/211] Add connection timeout settings for socketd (#193) Add receive/send timeouts to accepted socketd backend bridge connections so a partially written request cannot block the privileged bridge indefinitely. Also replace the bridge-specific same-EUID authorization rule with the same peer authorization model used by the main daemon: allow root, or allow peers that are members of the droidspaces group. This keeps the backend bridge aligned with the existing Droidspaces local privilege boundary instead of granting access to every process running under the same UID. --- src/socketd_bridge.c | 48 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index c2e6dfb6..12cdb262 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -9,6 +9,9 @@ #include "droidspace.h" #include "socketd_protocol.h" +#include +#include + /* * The public Docker/Podman-compatible socket belongs to the external C++ * droidspaces-socketd daemon. This bridge is deliberately narrower: it is a @@ -16,6 +19,24 @@ * Droidspaces-native operations needed by that compatibility daemon. */ +#define DS_SOCKETD_GROUP "droidspaces" +#define DS_SOCKETD_CONN_TIMEOUT_SEC 10 + +static void socketd_set_conn_timeouts(int fd) { + struct timeval tv; + memset(&tv, 0, sizeof(tv)); + tv.tv_sec = DS_SOCKETD_CONN_TIMEOUT_SEC; + + if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) { + ds_warn("socketd backend: failed to set receive timeout: %s", + strerror(errno)); + } + + if (setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) < 0) { + ds_warn("socketd backend: failed to set send timeout: %s", strerror(errno)); + } +} + static int socketd_read_exact(int fd, void *buf, size_t len) { uint8_t *p = (uint8_t *)buf; while (len > 0) { @@ -76,11 +97,29 @@ static int socketd_peer_authorized(int fd) { return 0; /* - * The first socketd implementation is expected to run as root beside the - * Droidspaces daemon. Same-EUID access keeps local developer/test launches - * usable on desktop Linux without opening the bridge to arbitrary users. + * The abstract backend socket has no filesystem permissions, so enforce the + * same local authorization model used by the main Droidspaces daemon: root or + * members of the dedicated 'droidspaces' group may connect. */ - return cred.uid == 0 || cred.uid == geteuid(); + if (cred.uid == 0) + return 1; + + struct group *gr = getgrnam(DS_SOCKETD_GROUP); + struct passwd *pw = getpwuid(cred.uid); + if (!gr || !pw) + return 0; + + int ngroups = 64; + gid_t groups[64]; + if (getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups) < 0) + return 0; + + for (int i = 0; i < ngroups; i++) { + if (groups[i] == gr->gr_gid) + return 1; + } + + return 0; #else (void)fd; return 0; @@ -1427,6 +1466,7 @@ static int socketd_bridge_loop(void) { } fcntl(conn, F_SETFD, FD_CLOEXEC); + socketd_set_conn_timeouts(conn); socketd_handle_conn(conn); close(conn); } From be467a5652c94edfeeb9aeb1c1756f0b948337e0 Mon Sep 17 00:00:00 2001 From: Yoshinobu Date Date: Tue, 9 Jun 2026 03:06:31 +0900 Subject: [PATCH 031/211] Move socketd and WebUI out-of-tree (#194) * Move separable components under external * Evacuate externalized components * socketd: Reference external components as submodules --- .gitmodules | 8 + external/socketd | 1 + external/www | 1 + src/socketd/Makefile | 88 -- src/socketd/api_server.cpp | 1715 -------------------------- src/socketd/api_server.h | 39 - src/socketd/backend_client.cpp | 766 ------------ src/socketd/backend_client.h | 151 --- src/socketd/container_inspect.cpp | 410 ------ src/socketd/container_inspect.h | 17 - src/socketd/container_list.cpp | 237 ---- src/socketd/container_list.h | 29 - src/socketd/event_log.cpp | 315 ----- src/socketd/event_log.h | 39 - src/socketd/main.cpp | 183 --- src/socketd/snapshot_lists.cpp | 207 ---- src/socketd/snapshot_lists.h | 25 - www/html/assets/droidspaces-mark.svg | 5 - www/html/index.html | 176 --- www/html/webui.css | 229 ---- www/html/webui.js | 420 ------- 21 files changed, 10 insertions(+), 5051 deletions(-) create mode 160000 external/socketd create mode 160000 external/www delete mode 100644 src/socketd/Makefile delete mode 100644 src/socketd/api_server.cpp delete mode 100644 src/socketd/api_server.h delete mode 100644 src/socketd/backend_client.cpp delete mode 100644 src/socketd/backend_client.h delete mode 100644 src/socketd/container_inspect.cpp delete mode 100644 src/socketd/container_inspect.h delete mode 100644 src/socketd/container_list.cpp delete mode 100644 src/socketd/container_list.h delete mode 100644 src/socketd/event_log.cpp delete mode 100644 src/socketd/event_log.h delete mode 100644 src/socketd/main.cpp delete mode 100644 src/socketd/snapshot_lists.cpp delete mode 100644 src/socketd/snapshot_lists.h delete mode 100644 www/html/assets/droidspaces-mark.svg delete mode 100644 www/html/index.html delete mode 100644 www/html/webui.css delete mode 100644 www/html/webui.js diff --git a/.gitmodules b/.gitmodules index 80b65828..112d4013 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,11 @@ [submodule "external/busybox"] path = external/busybox url = https://github.com/Droidspaces/busybox-droidspaces.git +[submodule "external/www"] + path = external/www + url = https://github.com/Droidspaces/webui.git + branch = main +[submodule "external/socketd"] + path = external/socketd + url = https://github.com/Droidspaces/socketd.git + branch = main diff --git a/external/socketd b/external/socketd new file mode 160000 index 00000000..e1f1d94e --- /dev/null +++ b/external/socketd @@ -0,0 +1 @@ +Subproject commit e1f1d94ef702ed35cbf0a1959cc8169d8ce7cb7c diff --git a/external/www b/external/www new file mode 160000 index 00000000..549d2fcf --- /dev/null +++ b/external/www @@ -0,0 +1 @@ +Subproject commit 549d2fcf52accd7e571bae7e60ad52df50f83b8d diff --git a/src/socketd/Makefile b/src/socketd/Makefile deleted file mode 100644 index 59992c5e..00000000 --- a/src/socketd/Makefile +++ /dev/null @@ -1,88 +0,0 @@ -# Droidspaces v6 - droidspaces-socketd build system -# SPDX-License-Identifier: GPL-3.0-or-later - -ROOT_DIR := ../.. -SRC_DIR := . -API_DIR := .. -OUT_DIR := $(ROOT_DIR)/output -OBJ_DIR := $(OUT_DIR)/.obj/socketd - -BINARY_NAME := droidspaces-socketd -TARGET := $(OUT_DIR)/$(BINARY_NAME) - -CXX ?= g++ - -# Verbose control - V=1 shows full commands, V=0 keeps output tidy -V ?= 0 - -# Static link control - off by default -STATIC ?= 0 - -ifeq ($(V),1) - Q = - msg_cxx = - msg_ld = -else - Q = @ - msg_cxx = @printf " CXX %s\n" $< - msg_ld = @printf " CXXLD %s\n" $@ -endif - -CXXFLAGS ?= -Wall -Wextra -Wpedantic -Werror \ - -O2 -std=c++17 \ - -I$(SRC_DIR) -I$(API_DIR)/include - -LDFLAGS ?= -LDLIBS ?= - -ifeq ($(STATIC),1) - LDFLAGS += -static -endif - -SRCS := \ - main.cpp \ - backend_client.cpp \ - container_list.cpp \ - container_inspect.cpp \ - snapshot_lists.cpp \ - event_log.cpp \ - api_server.cpp - -OBJS := $(SRCS:%.cpp=$(OBJ_DIR)/%.o) -DEPS := $(OBJS:.o=.d) - -.PHONY: all clean help - -all: $(TARGET) - -help: - @echo "droidspaces-socketd build system" - @echo "" - @echo "Targets:" - @echo " make - Build $(BINARY_NAME)" - @echo " make clean - Remove socketd build artifacts" - @echo "" - @echo "Options:" - @echo " V=1 - Show full compiler commands" - @echo " CXX=... - Override the C++ compiler" - @echo " STATIC=1 - Link droidspaces-socketd statically" - -$(OUT_DIR): - $(Q)mkdir -p $(OUT_DIR) - -$(OBJ_DIR): - $(Q)mkdir -p $(OBJ_DIR) - -$(TARGET): $(OBJS) | $(OUT_DIR) - $(msg_ld) - $(Q)$(CXX) $(LDFLAGS) -o $@ $(OBJS) $(LDLIBS) - -$(OBJ_DIR)/%.o: %.cpp | $(OBJ_DIR) - $(msg_cxx) - $(Q)$(CXX) $(CXXFLAGS) -MMD -MP -c $< -o $@ - -clean: - $(Q)rm -rf $(OBJ_DIR) - $(Q)rm -f $(TARGET) - --include $(DEPS) diff --git a/src/socketd/api_server.cpp b/src/socketd/api_server.cpp deleted file mode 100644 index 1302cbc7..00000000 --- a/src/socketd/api_server.cpp +++ /dev/null @@ -1,1715 +0,0 @@ -#include "api_server.h" -#include "backend_client.h" -#include "container_list.h" -#include "container_inspect.h" -#include "snapshot_lists.h" -#include "event_log.h" -#include "droidspace.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -constexpr std::size_t kMaxRequestHeaderBytes = 16 * 1024; -constexpr const char* kSocketApiVersion = "1.41"; -constexpr const char* kSocketMinApiVersion = "1.40"; -constexpr const char* kSocketOsType = "linux"; - -constexpr std::uint64_t kMaxStaticAssetBytes = 32ULL * 1024ULL * 1024ULL; -constexpr const char* kDefaultWebIndex = "index.html"; - -std::string socketd_arch_name() { -#if defined(__x86_64__) - return "amd64"; -#elif defined(__i386__) - return "386"; -#elif defined(__aarch64__) - return "arm64"; -#elif defined(__arm__) - return "arm"; -#elif defined(__riscv) && (__riscv_xlen == 64) - return "riscv64"; -#else - return "unknown"; -#endif -} - -std::string socketd_kernel_version() { - struct utsname uts {}; - if (::uname(&uts) != 0) { - return {}; - } - - return uts.release; -} - -std::uint64_t socketd_mem_total_bytes() { - std::ifstream meminfo("/proc/meminfo"); - if (!meminfo.is_open()) { - return 0; - } - - std::string key; - std::uint64_t value_kib = 0; - std::string unit; - - while (meminfo >> key >> value_kib >> unit) { - if (key == "MemTotal:") { - /* - * /proc/meminfo reports MemTotal in KiB. - */ - return value_kib * 1024ULL; - } - } - - return 0; -} - -unsigned int socketd_ncpu() { - const long value = ::sysconf(_SC_NPROCESSORS_ONLN); - if (value <= 0) { - return 0; - } - - if (static_cast(value) > - std::numeric_limits::max()) { - return std::numeric_limits::max(); - } - - return static_cast(value); -} - -std::string socketd_hostname() { - char hostname[256] {}; - if (::gethostname(hostname, sizeof(hostname) - 1) != 0) { - return "droidspaces"; - } - - hostname[sizeof(hostname) - 1] = '\0'; - - if (hostname[0] == '\0') { - return "droidspaces"; - } - - return hostname; -} - -std::string socketd_system_time_utc() { - std::time_t now = std::time(nullptr); - if (now == static_cast(-1)) { - return {}; - } - - std::tm tm {}; -#if defined(_POSIX_THREAD_SAFE_FUNCTIONS) || defined(__ANDROID__) || defined(__linux__) - if (::gmtime_r(&now, &tm) == nullptr) { - return {}; - } -#else - const std::tm* tmp = std::gmtime(&now); - if (tmp == nullptr) { - return {}; - } - tm = *tmp; -#endif - - char buffer[64] {}; - if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm) == 0) { - return {}; - } - - return buffer; -} - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -void debug_log_request_headers(const std::string& request) { - const std::size_t header_end = request.find("\r\n\r\n"); - - const std::size_t visible_len = - header_end == std::string::npos - ? request.size() - : header_end + 4; - - std::cerr << "socketd: received HTTP request headers\n"; - std::cerr << "----- BEGIN REQUEST -----\n"; - std::cerr.write(request.data(), static_cast(visible_len)); - - /* - * HTTP headers already end with CRLF CRLF, but ensure the terminal output - * does not visually run into the separator if a malformed request arrives. - */ - if (visible_len == 0 || - request[visible_len - 1] != '\n') { - std::cerr << '\n'; - } - - std::cerr << "----- END REQUEST -----\n"; -} - -bool send_all(int fd, const void* data, std::size_t len, std::string& error) { - const auto* p = static_cast(data); - - while (len > 0) { - const ssize_t written = ::send(fd, p, len, 0); - if (written < 0) { - if (errno == EINTR) { - continue; - } - - error = "send() failed: "; - error += std::strerror(errno); - return false; - } - - if (written == 0) { - error = "send() returned 0 unexpectedly"; - return false; - } - - p += static_cast(written); - len -= static_cast(written); - } - - return true; -} - -bool send_http_response(int fd, - int status_code, - const char* reason_phrase, - const char* content_type, - const std::string& body, - bool suppress_body, - std::string& error) { - std::string header; - header.reserve(256); - - header += "HTTP/1.1 "; - header += std::to_string(status_code); - header += ' '; - header += reason_phrase; - header += "\r\n"; - - header += "Content-Type: "; - header += content_type; - header += "\r\n"; - - header += "Content-Length: "; - header += std::to_string(body.size()); - header += "\r\n"; - - header += "Server: Droidspaces/6 (Container, like Docker)\r\n"; - - header += "Api-Version: "; - header += kSocketApiVersion; - header += "\r\n"; - - header += "Ostype: "; - header += kSocketOsType; - header += "\r\n"; - - header += "Connection: close\r\n"; - header += "\r\n"; - - if (!send_all(fd, header.data(), header.size(), error)) { - return false; - } - - if (!suppress_body && !body.empty()) { - if (!send_all(fd, body.data(), body.size(), error)) { - return false; - } - } - - return true; -} - - -bool send_empty_http_response(int fd, - int status_code, - const char* reason_phrase, - std::string& error) { - std::string header; - header.reserve(192); - - header += "HTTP/1.1 "; - header += std::to_string(status_code); - header += ' '; - header += reason_phrase; - header += "\r\n"; - header += "Content-Length: 0\r\n"; - header += "Server: Droidspaces/6 (Container, like Docker)\r\n"; - header += "Api-Version: "; - header += kSocketApiVersion; - header += "\r\n"; - header += "Ostype: "; - header += kSocketOsType; - header += "\r\n"; - header += "Connection: close\r\n"; - header += "\r\n"; - - return send_all(fd, header.data(), header.size(), error); -} - -bool send_bad_request(int fd, bool suppress_body, std::string& error) { - const std::string body = "{\"message\":\"bad request\"}\n"; - return send_http_response(fd, - 400, - "Bad Request", - "application/json", - body, - suppress_body, - error); -} - -bool send_header_too_large(int fd, bool suppress_body, std::string& error) { - const std::string body = "{\"message\":\"request headers too large\"}\n"; - return send_http_response(fd, - 431, - "Request Header Fields Too Large", - "application/json", - body, - suppress_body, - error); -} - -bool send_not_found(int fd, bool suppress_body, std::string& error) { - const std::string body = "{\"message\":\"not found\"}\n"; - return send_http_response(fd, - 404, - "Not Found", - "application/json", - body, - suppress_body, - error); -} - - -bool send_internal_server_error(int fd, - const std::string& message, - bool suppress_body, - std::string& error) { - std::string body = "{\"message\":\""; - body += json_escape(message.empty() ? "internal server error" : message); - body += "\"}\n"; - - return send_http_response(fd, - 500, - "Internal Server Error", - "application/json", - body, - suppress_body, - error); -} - -bool send_no_content(int fd, std::string& error) { - return send_empty_http_response(fd, 204, "No Content", error); -} - -bool send_not_modified(int fd, std::string& error) { - return send_empty_http_response(fd, 304, "Not Modified", error); -} - -bool send_ping_ok(int fd, bool suppress_body, std::string& error) { - const std::string body = "OK"; - return send_http_response(fd, - 200, - "OK", - "text/plain; charset=utf-8", - body, - suppress_body, - error); -} - -int hex_digit_value(char c) { - if (c >= '0' && c <= '9') { - return c - '0'; - } - - if (c >= 'a' && c <= 'f') { - return c - 'a' + 10; - } - - if (c >= 'A' && c <= 'F') { - return c - 'A' + 10; - } - - return -1; -} - -bool percent_decode_url_path(const std::string& input, - std::string& output) { - output.clear(); - output.reserve(input.size()); - - for (std::size_t i = 0; i < input.size(); ++i) { - const char c = input[i]; - - if (c != '%') { - if (c == '\0') { - return false; - } - - output += c; - continue; - } - - if (i + 2 >= input.size()) { - return false; - } - - const int hi = hex_digit_value(input[i + 1]); - const int lo = hex_digit_value(input[i + 2]); - if (hi < 0 || lo < 0) { - return false; - } - - const char decoded = static_cast((hi << 4) | lo); - if (decoded == '\0') { - return false; - } - - output += decoded; - i += 2; - } - - return true; -} - -std::string dirname_of(const std::string& path) { - const std::size_t slash = path.rfind('/'); - if (slash == std::string::npos) { - return "."; - } - - if (slash == 0) { - return "/"; - } - - return path.substr(0, slash); -} - -std::string socketd_binary_dir() { - char buffer[PATH_MAX] {}; - - const ssize_t len = ::readlink("/proc/self/exe", buffer, sizeof(buffer) - 1); - if (len <= 0) { - return "."; - } - - buffer[len] = '\0'; - return dirname_of(buffer); -} - -const std::string& web_root_dir() { - static const std::string root = socketd_binary_dir() + "/../www/html"; - return root; -} - -std::string lowercase_ascii(std::string value) { - for (char& c : value) { - if (c >= 'A' && c <= 'Z') { - c = static_cast(c - 'A' + 'a'); - } - } - - return value; -} - -std::string content_type_for_path(const std::string& path) { - const std::size_t slash = path.rfind('/'); - const std::size_t dot = path.rfind('.'); - if (dot == std::string::npos || - (slash != std::string::npos && dot < slash)) { - return "application/octet-stream"; - } - - const std::string ext = lowercase_ascii(path.substr(dot + 1)); - - if (ext == "html" || ext == "htm") { - return "text/html; charset=utf-8"; - } - - if (ext == "css") { - return "text/css; charset=utf-8"; - } - - if (ext == "js" || ext == "mjs") { - return "application/javascript; charset=utf-8"; - } - - if (ext == "json" || ext == "map") { - return "application/json; charset=utf-8"; - } - - if (ext == "svg") { - return "image/svg+xml"; - } - - if (ext == "png") { - return "image/png"; - } - - if (ext == "jpg" || ext == "jpeg") { - return "image/jpeg"; - } - - if (ext == "gif") { - return "image/gif"; - } - - if (ext == "webp") { - return "image/webp"; - } - - if (ext == "ico") { - return "image/x-icon"; - } - - if (ext == "txt") { - return "text/plain; charset=utf-8"; - } - - if (ext == "wasm") { - return "application/wasm"; - } - - return "application/octet-stream"; -} - -bool build_static_asset_path(const std::string& target, - std::string& file_path, - std::string& error) { - file_path.clear(); - - const std::size_t query_pos = target.find('?'); - const std::string raw_path = - query_pos == std::string::npos ? target : target.substr(0, query_pos); - - if (raw_path.empty() || raw_path[0] != '/') { - error = "static asset request target is not an origin-form path"; - return false; - } - - std::string decoded_path; - if (!percent_decode_url_path(raw_path, decoded_path)) { - error = "static asset path contains invalid percent encoding"; - return false; - } - - if (decoded_path.empty() || decoded_path[0] != '/' || - decoded_path.find('\\') != std::string::npos) { - error = "static asset path is invalid"; - return false; - } - - std::vector segments; - std::size_t pos = 1; - while (pos <= decoded_path.size()) { - const std::size_t slash = decoded_path.find('/', pos); - const std::size_t end = slash == std::string::npos ? decoded_path.size() - : slash; - const std::string segment = decoded_path.substr(pos, end - pos); - - if (!segment.empty() && segment != ".") { - if (segment == "..") { - error = "static asset path attempts to leave document root"; - return false; - } - - segments.push_back(segment); - } - - if (slash == std::string::npos) { - break; - } - - pos = slash + 1; - } - - if (segments.empty() || decoded_path.back() == '/') { - segments.push_back(kDefaultWebIndex); - } - - std::string relative_path; - for (const std::string& segment : segments) { - if (!relative_path.empty()) { - relative_path += '/'; - } - - relative_path += segment; - } - - file_path = web_root_dir() + "/" + relative_path; - return true; -} - -bool read_regular_file(const std::string& file_path, - std::string& body, - bool& not_found, - std::string& error) { - not_found = false; - body.clear(); - - struct stat st {}; - if (::stat(file_path.c_str(), &st) != 0) { - if (errno == ENOENT || errno == ENOTDIR || errno == EACCES) { - not_found = true; - return false; - } - - error = "stat(" + file_path + ") failed: "; - error += std::strerror(errno); - return false; - } - - if (!S_ISREG(st.st_mode)) { - not_found = true; - return false; - } - - if (st.st_size < 0 || - static_cast(st.st_size) > kMaxStaticAssetBytes) { - error = "static asset is too large: " + file_path; - return false; - } - - std::ifstream file(file_path, std::ios::in | std::ios::binary); - if (!file.is_open()) { - if (errno == ENOENT || errno == ENOTDIR || errno == EACCES) { - not_found = true; - return false; - } - - error = "open(" + file_path + ") failed: "; - error += std::strerror(errno); - return false; - } - - body.resize(static_cast(st.st_size)); - if (!body.empty()) { - file.read(&body[0], static_cast(body.size())); - if (!file) { - error = "read(" + file_path + ") failed"; - return false; - } - } - - return true; -} - -bool send_static_asset_ok(int fd, - const std::string& target, - bool suppress_body, - std::string& error) { - std::string file_path; - std::string path_error; - if (!build_static_asset_path(target, file_path, path_error)) { - return send_bad_request(fd, suppress_body, error); - } - - std::string body; - bool not_found = false; - if (!read_regular_file(file_path, body, not_found, error)) { - if (not_found) { - return send_not_found(fd, suppress_body, error); - } - - return send_internal_server_error(fd, error, suppress_body, error); - } - - const std::string content_type = content_type_for_path(file_path); - return send_http_response(fd, - 200, - "OK", - content_type.c_str(), - body, - suppress_body, - error); -} - -bool is_ascii_digit(char c) { - return c >= '0' && c <= '9'; -} - -bool is_versioned_api_path(const std::string& path, - const char* endpoint_path) { - const std::size_t endpoint_len = std::strlen(endpoint_path); - - if (path.size() <= endpoint_len) { - return false; - } - - if (path.compare(path.size() - endpoint_len, - endpoint_len, - endpoint_path) != 0) { - return false; - } - - const std::string prefix = - path.substr(0, path.size() - endpoint_len); - - /* - * Accept: - * - * /v1.40/_ping - * /v1.40/version - * - * Prefix must be: - * - * /v. - */ - if (prefix.size() < 5) { - return false; - } - - if (prefix[0] != '/' || prefix[1] != 'v') { - return false; - } - - std::size_t i = 2; - const std::size_t major_start = i; - - while (i < prefix.size() && is_ascii_digit(prefix[i])) { - ++i; - } - - if (i == major_start) { - return false; - } - - if (i >= prefix.size() || prefix[i] != '.') { - return false; - } - - ++i; - const std::size_t minor_start = i; - - while (i < prefix.size() && is_ascii_digit(prefix[i])) { - ++i; - } - - if (i == minor_start) { - return false; - } - - return i == prefix.size(); -} - - -bool is_api_target(const std::string& target, - const char* endpoint_path) { - const std::size_t query_pos = target.find('?'); - const std::string path = - query_pos == std::string::npos ? target : target.substr(0, query_pos); - - return path == endpoint_path || - is_versioned_api_path(path, endpoint_path); -} - - -bool strip_api_version_prefix(const std::string& path, - std::string& unversioned_path) { - if (path.rfind("/v", 0) != 0) { - unversioned_path = path; - return true; - } - - std::size_t i = 2; - const std::size_t major_start = i; - - while (i < path.size() && is_ascii_digit(path[i])) { - ++i; - } - - if (i == major_start || i >= path.size() || path[i] != '.') { - unversioned_path = path; - return true; - } - - ++i; - const std::size_t minor_start = i; - - while (i < path.size() && is_ascii_digit(path[i])) { - ++i; - } - - if (i == minor_start || i >= path.size() || path[i] != '/') { - unversioned_path = path; - return true; - } - - unversioned_path = path.substr(i); - return true; -} - -bool parse_container_ref_with_suffix(const std::string& target, - const char* suffix, - std::string& ref_out) { - const std::size_t query_pos = target.find('?'); - const std::string path = - query_pos == std::string::npos ? target : target.substr(0, query_pos); - - std::string unversioned_path; - if (!strip_api_version_prefix(path, unversioned_path)) { - return false; - } - - constexpr const char* kPrefix = "/containers/"; - const std::size_t prefix_len = std::strlen(kPrefix); - const std::size_t suffix_len = std::strlen(suffix); - - if (unversioned_path.size() <= prefix_len + suffix_len || - unversioned_path.compare(0, prefix_len, kPrefix) != 0 || - unversioned_path.compare(unversioned_path.size() - suffix_len, - suffix_len, - suffix) != 0) { - return false; - } - - ref_out = unversioned_path.substr( - prefix_len, - unversioned_path.size() - prefix_len - suffix_len); - - return !ref_out.empty() && ref_out.find('/') == std::string::npos; -} - -bool parse_container_inspect_ref(const std::string& target, - std::string& ref_out) { - return parse_container_ref_with_suffix(target, "/json", ref_out); -} - -bool parse_container_action_ref(const std::string& target, - const char* action_suffix, - std::string& ref_out) { - return parse_container_ref_with_suffix(target, action_suffix, ref_out); -} - -bool is_truthy_query_value(const std::string& value) { - return value.empty() || - value == "1" || - value == "true"; -} - -ContainerListRequest parse_container_list_request( - const std::string& target) { - ContainerListRequest request {}; - - const std::size_t query_pos = target.find('?'); - if (query_pos == std::string::npos || - query_pos + 1 >= target.size()) { - return request; - } - - /* - * This is intentionally a very small query parser for the bring-up phase. - * It extracts only the public API semantic that socketd currently cares - * about: ?all=1. Unknown query parameters are ignored. - */ - std::size_t pos = query_pos + 1; - - while (pos <= target.size()) { - const std::size_t amp = target.find('&', pos); - const std::size_t end = - amp == std::string::npos ? target.size() : amp; - - const std::string item = target.substr(pos, end - pos); - const std::size_t eq = item.find('='); - - const std::string key = - eq == std::string::npos ? item : item.substr(0, eq); - - const std::string value = - eq == std::string::npos ? "" : item.substr(eq + 1); - - if (key == "all") { - request.include_all = is_truthy_query_value(value); - } - - if (amp == std::string::npos) { - break; - } - - pos = amp + 1; - } - - return request; -} - -EventsRequest parse_events_request(const std::string& target) { - EventsRequest request {}; - - const std::size_t query_pos = target.find('?'); - if (query_pos == std::string::npos || - query_pos + 1 >= target.size()) { - return request; - } - - /* - * Small, deliberate parser for only the fields observed from Portainer's - * event-log request. Unknown parameters are ignored for now. - */ - std::size_t pos = query_pos + 1; - - while (pos <= target.size()) { - const std::size_t amp = target.find('&', pos); - const std::size_t end = - amp == std::string::npos ? target.size() : amp; - - const std::string item = target.substr(pos, end - pos); - const std::size_t eq = item.find('='); - - const std::string key = - eq == std::string::npos ? item : item.substr(0, eq); - - const std::string value = - eq == std::string::npos ? "" : item.substr(eq + 1); - - if (key == "since") { - request.since = value; - } else if (key == "until") { - request.until = value; - } - - if (amp == std::string::npos) { - break; - } - - pos = amp + 1; - } - - return request; -} - - -struct ContainerLifecycleRequest { - int timeout_seconds = -1; -}; - -bool parse_nonnegative_int(const std::string& value, - int& out, - std::string& error) { - if (value.empty()) { - error = "empty integer value"; - return false; - } - - errno = 0; - char* end = nullptr; - const long parsed = std::strtol(value.c_str(), &end, 10); - if (errno != 0 || end == value.c_str() || *end != '\0' || parsed < 0 || - parsed > std::numeric_limits::max()) { - error = "invalid non-negative integer: "; - error += value; - return false; - } - - out = static_cast(parsed); - return true; -} - -bool parse_container_lifecycle_request(const std::string& target, - ContainerLifecycleRequest& request, - std::string& error) { - request = ContainerLifecycleRequest {}; - - const std::size_t query_pos = target.find('?'); - if (query_pos == std::string::npos || query_pos + 1 >= target.size()) { - return true; - } - - std::size_t pos = query_pos + 1; - while (pos <= target.size()) { - const std::size_t amp = target.find('&', pos); - const std::size_t end = - amp == std::string::npos ? target.size() : amp; - - const std::string item = target.substr(pos, end - pos); - const std::size_t eq = item.find('='); - const std::string key = - eq == std::string::npos ? item : item.substr(0, eq); - const std::string value = - eq == std::string::npos ? "" : item.substr(eq + 1); - - if (key == "t") { - if (!parse_nonnegative_int(value, request.timeout_seconds, error)) { - error = "invalid lifecycle timeout query parameter: " + value; - return false; - } - } - - if (amp == std::string::npos) { - break; - } - pos = amp + 1; - } - - return true; -} - -bool parse_port(const std::string& value, - std::uint16_t& port_out, - std::string& error) { - if (value.empty()) { - error = "TCP port is empty"; - return false; - } - - errno = 0; - char* end = nullptr; - const unsigned long parsed = std::strtoul(value.c_str(), &end, 10); - - if (errno != 0 || end == value.c_str() || *end != '\0') { - error = "invalid TCP port: "; - error += value; - return false; - } - - if (parsed == 0 || parsed > 65535UL) { - error = "TCP port is outside valid range: "; - error += value; - return false; - } - - port_out = static_cast(parsed); - return true; -} - -std::string build_version_json() { - const std::string arch = socketd_arch_name(); - const std::string kernel_version = socketd_kernel_version(); - - std::string body; - body.reserve(512); - - body += "{"; - - body += "\"Platform\":{\"Name\":\""; - body += json_escape(DS_PROJECT_NAME); - body += "\"},"; - - body += "\"Components\":[{"; - body += "\"Name\":\"Engine\","; - body += "\"Version\":\""; - body += json_escape(DS_VERSION); - body += "\","; - body += "\"Details\":{}"; - body += "}],"; - - body += "\"Version\":\""; - body += json_escape(DS_VERSION); - body += "\","; - - body += "\"ApiVersion\":\""; - body += kSocketApiVersion; - body += "\","; - - body += "\"MinAPIVersion\":\""; - body += kSocketMinApiVersion; - body += "\","; - - body += "\"Os\":\""; - body += kSocketOsType; - body += "\","; - - body += "\"Arch\":\""; - body += json_escape(arch); - body += "\""; - - if (!kernel_version.empty()) { - body += ",\"KernelVersion\":\""; - body += json_escape(kernel_version); - body += "\""; - } - - body += "}\n"; - return body; -} - -bool send_version_ok(int fd, bool suppress_body, std::string& error) { - const std::string body = build_version_json(); - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool build_info_json(std::string& body, std::string& error) { - BackendClient backend; - InfoResult info; - - if (!backend.info(info, error)) { - return false; - } - - /* - * /info remains primarily a socketd-local Docker-shaped compatibility - * document: host facts such as kernel, architecture, CPU count, and memory - * are gathered in this process. Backend INFO contributes the live - * Droidspaces inventory counters. - */ - const std::string arch = socketd_arch_name(); - const std::string kernel_version = socketd_kernel_version(); - const std::string hostname = socketd_hostname(); - const std::string system_time = socketd_system_time_utc(); - const unsigned int ncpu = socketd_ncpu(); - const std::uint64_t mem_total = socketd_mem_total_bytes(); - - body.clear(); - body.reserve(1400); - - body += "{"; - - body += "\"ID\":\"\","; - - body += "\"Containers\":"; - body += std::to_string(info.containers_total); - body += ","; - - body += "\"ContainersRunning\":"; - body += std::to_string(info.containers_running); - body += ","; - - body += "\"ContainersPaused\":0,"; - - body += "\"ContainersStopped\":"; - body += std::to_string(info.containers_stopped); - body += ","; - - /* - * CONCERN(socketd-info): - * The current INFO backend payload carries container inventory counters only. - * Preserve the existing Images=0 field until the plan explicitly extends the - * INFO wire payload or chooses a socketd-side count source for pseudo-images. - */ - body += "\"Images\":0,"; - - body += "\"Driver\":\"droidspaces\","; - body += "\"DriverStatus\":[],"; - body += "\"Plugins\":{"; - body += "\"Volume\":[],"; - body += "\"Network\":[],"; - body += "\"Authorization\":[],"; - body += "\"Log\":[]"; - body += "},"; - - body += "\"MemoryLimit\":false,"; - body += "\"SwapLimit\":false,"; - body += "\"CpuCfsPeriod\":false,"; - body += "\"CpuCfsQuota\":false,"; - body += "\"CPUShares\":false,"; - body += "\"CPUSet\":false,"; - body += "\"PidsLimit\":false,"; - body += "\"IPv4Forwarding\":false,"; - body += "\"Debug\":false,"; - body += "\"NFd\":0,"; - body += "\"OomKillDisable\":false,"; - body += "\"NGoroutines\":0,"; - - body += "\"SystemTime\":\""; - body += json_escape(system_time); - body += "\","; - - body += "\"LoggingDriver\":\"\","; - body += "\"CgroupDriver\":\"\","; - body += "\"NEventsListener\":0,"; - - body += "\"KernelVersion\":\""; - body += json_escape(kernel_version); - body += "\","; - - body += "\"OperatingSystem\":\"Droidspaces\","; - body += "\"OSVersion\":\"\","; - body += "\"OSType\":\"linux\","; - - body += "\"Architecture\":\""; - body += json_escape(arch); - body += "\","; - - body += "\"IndexServerAddress\":\"\","; - body += "\"RegistryConfig\":null,"; - - body += "\"NCPU\":"; - body += std::to_string(ncpu); - body += ","; - - body += "\"MemTotal\":"; - body += std::to_string(mem_total); - body += ","; - - body += "\"GenericResources\":[],"; - body += "\"DockerRootDir\":\"\","; - body += "\"HttpProxy\":\"\","; - body += "\"HttpsProxy\":\"\","; - body += "\"NoProxy\":\"\","; - - body += "\"Name\":\""; - body += json_escape(hostname); - body += "\","; - - body += "\"Labels\":[],"; - body += "\"ExperimentalBuild\":false,"; - - body += "\"ServerVersion\":\""; - body += json_escape(DS_VERSION); - body += "\","; - - body += "\"Runtimes\":{},"; - body += "\"DefaultRuntime\":\"\","; - body += "\"Swarm\":{\"NodeID\":\"\"},"; - body += "\"LiveRestoreEnabled\":false,"; - body += "\"Isolation\":\"\","; - body += "\"InitBinary\":\"\","; - body += "\"ContainerdCommit\":{\"ID\":\"\"},"; - body += "\"RuncCommit\":{\"ID\":\"\"},"; - body += "\"InitCommit\":{\"ID\":\"\"},"; - body += "\"SecurityOptions\":[],"; - body += "\"Warnings\":[]"; - - body += "}\n"; - return true; -} - -bool send_info_ok(int fd, bool suppress_body, std::string& error) { - std::string body; - - if (!build_info_json(body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_container_list_ok(int fd, - const std::string& target, - bool suppress_body, - std::string& error) { - const ContainerListRequest request = - parse_container_list_request(target); - - std::string body; - if (!request_container_list_json_from_core(request, body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - - -bool send_container_inspect_ok(int fd, - const std::string& ref, - bool suppress_body, - std::string& error) { - std::string body; - bool not_found = false; - - if (!request_container_inspect_json_from_core(ref, body, not_found, error)) { - if (not_found) { - return send_not_found(fd, suppress_body, error); - } - - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_container_start_ok(int fd, - const std::string& ref, - std::string& error) { - BackendClient backend; - LifecycleResult result; - std::string backend_error; - - if (backend.start_container(ref, result, backend_error)) { - return send_no_content(fd, error); - } - - if (result.not_found) { - return send_not_found(fd, false, error); - } - - if (result.already_running) { - return send_not_modified(fd, error); - } - - return send_internal_server_error(fd, backend_error, false, error); -} - -bool send_container_stop_ok(int fd, - const std::string& target, - const std::string& ref, - std::string& error) { - ContainerLifecycleRequest request; - std::string parse_error; - if (!parse_container_lifecycle_request(target, request, parse_error)) { - return send_bad_request(fd, false, error); - } - - BackendClient backend; - LifecycleResult result; - std::string backend_error; - - if (backend.stop_container(ref, request.timeout_seconds, result, - backend_error)) { - return send_no_content(fd, error); - } - - if (result.not_found) { - return send_not_found(fd, false, error); - } - - if (result.already_stopped) { - return send_not_modified(fd, error); - } - - return send_internal_server_error(fd, backend_error, false, error); -} - -bool send_container_restart_ok(int fd, - const std::string& target, - const std::string& ref, - std::string& error) { - ContainerLifecycleRequest request; - std::string parse_error; - if (!parse_container_lifecycle_request(target, request, parse_error)) { - return send_bad_request(fd, false, error); - } - - BackendClient backend; - LifecycleResult result; - std::string backend_error; - - if (backend.restart_container(ref, request.timeout_seconds, result, - backend_error)) { - return send_no_content(fd, error); - } - - if (result.not_found) { - return send_not_found(fd, false, error); - } - - return send_internal_server_error(fd, backend_error, false, error); -} - -bool send_image_list_ok(int fd, - bool suppress_body, - std::string& error) { - std::string body; - if (!request_image_list_json_from_core(body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_volume_list_ok(int fd, - bool suppress_body, - std::string& error) { - std::string body; - if (!request_volume_list_json_from_core(body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_network_list_ok(int fd, - bool suppress_body, - std::string& error) { - std::string body; - if (!request_network_list_json_from_core(body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_events_ok(int fd, - const std::string& target, - bool suppress_body, - std::string& error) { - const EventsRequest request = parse_events_request(target); - - std::string body; - if (!request_event_log_stream_from_core(request, body, error)) { - return false; - } - - /* - * API v1.40-compatible behavior: - * Moby used application/json for event streams at this API level. - * An empty body is intentional and accepted by Portainer's event-log parser. - */ - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -} // namespace - -bool parse_tcp_listen_endpoint(const std::string& value, - TcpListenConfig& out, - std::string& error) { - out = TcpListenConfig {}; - - if (value.empty()) { - return true; - } - - const std::size_t colon_pos = value.rfind(':'); - - if (colon_pos == std::string::npos) { - return parse_port(value, out.port, error); - } - - const std::string address = value.substr(0, colon_pos); - const std::string port = value.substr(colon_pos + 1); - - if (address.empty()) { - error = "TCP bind address is empty"; - return false; - } - - out.bind_address = address; - return parse_port(port, out.port, error); -} - -ApiServer::ApiServer(TcpListenConfig config) : config_(std::move(config)) {} - -bool ApiServer::create_listener(int& fd_out, std::string& error) const { - fd_out = -1; - - const int fd = ::socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) { - error = "socket(AF_INET, SOCK_STREAM) failed: "; - error += std::strerror(errno); - return false; - } - - const int one = 1; - if (::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) { - error = "setsockopt(SO_REUSEADDR) failed: "; - error += std::strerror(errno); - ::close(fd); - return false; - } - - sockaddr_in addr {}; - addr.sin_family = AF_INET; - addr.sin_port = htons(config_.port); - - const int pton_result = - ::inet_pton(AF_INET, config_.bind_address.c_str(), &addr.sin_addr); - - if (pton_result != 1) { - error = "invalid IPv4 bind address: "; - error += config_.bind_address; - ::close(fd); - return false; - } - - if (::bind(fd, - reinterpret_cast(&addr), - sizeof(addr)) < 0) { - error = "bind("; - error += config_.bind_address; - error += ':'; - error += std::to_string(config_.port); - error += ") failed: "; - error += std::strerror(errno); - ::close(fd); - return false; - } - - if (::listen(fd, SOMAXCONN) < 0) { - error = "listen() failed: "; - error += std::strerror(errno); - ::close(fd); - return false; - } - - fd_out = fd; - return true; -} - -bool ApiServer::handle_client(int client_fd, std::string& error) const { - std::string request; - request.reserve(1024); - - char buffer[4096]; - - while (request.find("\r\n\r\n") == std::string::npos) { - const ssize_t n = ::recv(client_fd, buffer, sizeof(buffer), 0); - - if (n < 0) { - if (errno == EINTR) { - continue; - } - - error = "recv() failed: "; - error += std::strerror(errno); - return false; - } - - if (n == 0) { - error = "client closed connection before sending full HTTP headers"; - return false; - } - - request.append(buffer, static_cast(n)); - - if (request.size() > kMaxRequestHeaderBytes) { - std::string response_error; - (void)send_header_too_large(client_fd, false, response_error); - - error = "request header exceeded configured limit"; - return false; - } - } -// DEBUG FEATURE: perhaps remove later; do NOT expect this to stay. - debug_log_request_headers(request); - - const std::size_t line_end = request.find("\r\n"); - if (line_end == std::string::npos) { - return send_bad_request(client_fd, false, error); - } - - const std::string request_line = request.substr(0, line_end); - - std::istringstream line_stream(request_line); - std::string method; - std::string target; - std::string version; - std::string trailing; - - if (!(line_stream >> method >> target >> version) || - (line_stream >> trailing)) { - return send_bad_request(client_fd, false, error); - } - - if (version.rfind("HTTP/", 0) != 0) { - return send_bad_request(client_fd, false, error); - } - - const bool is_head = method == "HEAD"; - const bool is_get = method == "GET"; - const bool is_post = method == "POST"; - - if ((is_get || is_head) && is_api_target(target, "/_ping")) { - return send_ping_ok(client_fd, is_head, error); - } - - if (is_get && is_api_target(target, "/version")) { - return send_version_ok(client_fd, false, error); - } - - if (is_get && is_api_target(target, "/info")) { - return send_info_ok(client_fd, false, error); - } - - - if (is_get && is_api_target(target, "/containers/json")) { - return send_container_list_ok(client_fd, target, false, error); - } - - std::string inspect_ref; - if (is_get && parse_container_inspect_ref(target, inspect_ref)) { - return send_container_inspect_ok(client_fd, inspect_ref, false, error); - } - - std::string lifecycle_ref; - if (is_post && parse_container_action_ref(target, "/start", lifecycle_ref)) { - return send_container_start_ok(client_fd, lifecycle_ref, error); - } - - if (is_post && parse_container_action_ref(target, "/stop", lifecycle_ref)) { - return send_container_stop_ok(client_fd, target, lifecycle_ref, error); - } - - if (is_post && parse_container_action_ref(target, "/restart", lifecycle_ref)) { - return send_container_restart_ok(client_fd, target, lifecycle_ref, error); - } - - if (is_get && is_api_target(target, "/images/json")) { - return send_image_list_ok(client_fd, false, error); - } - - if (is_get && is_api_target(target, "/volumes")) { - return send_volume_list_ok(client_fd, false, error); - } - - if (is_get && is_api_target(target, "/networks")) { - return send_network_list_ok(client_fd, false, error); - } - - if (is_get && is_api_target(target, "/events")) { - return send_events_ok(client_fd, target, false, error); - } - - if (is_get || is_head) { - return send_static_asset_ok(client_fd, target, is_head, error); - } - - return send_not_found(client_fd, false, error); -} - -bool ApiServer::run(std::string& error) { - int listener_fd = -1; - if (!create_listener(listener_fd, error)) { - return false; - } -// To tty - std::cerr << "socketd: listening on http://" - << config_.bind_address - << ':' - << config_.port - << '\n'; -// To API - const std::string listen_target = - "tcp://" + config_.bind_address + ":" + std::to_string(config_.port); - - const SocketdEventAttributes attrs[] = { - {"name", "droidspaces-socketd"}, - {"component", "socketd"}, - {"listen", listen_target}, - }; - - record_socketd_event("daemon", - "start", - "droidspaces-socketd", - attrs, - sizeof(attrs) / sizeof(attrs[0])); - - for (;;) { - const int client_fd = ::accept(listener_fd, nullptr, nullptr); - if (client_fd < 0) { - if (errno == EINTR) { - continue; - } - - error = "accept() failed: "; - error += std::strerror(errno); - ::close(listener_fd); - return false; - } - - std::string client_error; - if (!handle_client(client_fd, client_error)) { - std::cerr << "socketd: client request failed: " - << client_error - << '\n'; - } - - ::close(client_fd); - } -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/api_server.h b/src/socketd/api_server.h deleted file mode 100644 index c1120e1b..00000000 --- a/src/socketd/api_server.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include -#include - -namespace droidspaces::socketd { - -struct TcpListenConfig { - std::string bind_address = "0.0.0.0"; - std::uint16_t port = 2375; -}; - -/* - * Parses: - * - * "" -> 0.0.0.0:2375 - * "2375" -> 0.0.0.0:2375 - * "127.0.0.1:2375" -> 127.0.0.1:2375 - * "0.0.0.0:2375" -> 0.0.0.0:2375 - * - * This first revision intentionally accepts IPv4 only. - */ -bool parse_tcp_listen_endpoint(const std::string &value, TcpListenConfig &out, - std::string &error); - -class ApiServer { -public: - explicit ApiServer(TcpListenConfig config); - - bool run(std::string &error); - -private: - bool create_listener(int &fd_out, std::string &error) const; - bool handle_client(int client_fd, std::string &error) const; - - TcpListenConfig config_; -}; - -} // namespace droidspaces::socketd diff --git a/src/socketd/backend_client.cpp b/src/socketd/backend_client.cpp deleted file mode 100644 index 10927c88..00000000 --- a/src/socketd/backend_client.cpp +++ /dev/null @@ -1,766 +0,0 @@ -#include "backend_client.h" - -#include "socketd_protocol.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -bool write_exact(int fd, const void* buf, std::size_t len, std::string& error) { - const auto* p = static_cast(buf); - - while (len > 0) { - const ssize_t written = ::write(fd, p, len); - if (written < 0) { - if (errno == EINTR) { - continue; - } - - error = "write() failed: "; - error += std::strerror(errno); - return false; - } - - if (written == 0) { - error = "write() returned 0 unexpectedly"; - return false; - } - - p += static_cast(written); - len -= static_cast(written); - } - - return true; -} - -bool read_exact(int fd, void* buf, std::size_t len, std::string& error) { - auto* p = static_cast(buf); - - while (len > 0) { - const ssize_t received = ::read(fd, p, len); - if (received < 0) { - if (errno == EINTR) { - continue; - } - - error = "read() failed: "; - error += std::strerror(errno); - return false; - } - - if (received == 0) { - error = "peer closed connection before full response arrived"; - return false; - } - - p += static_cast(received); - len -= static_cast(received); - } - - return true; -} - -int connect_backend(std::string& error) { - const int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); - if (fd < 0) { - error = "socket(AF_UNIX) failed: "; - error += std::strerror(errno); - return -1; - } - - sockaddr_un addr {}; - addr.sun_family = AF_UNIX; - - const std::size_t name_len = std::strlen(DS_SOCKETD_BACKEND_SOCK_NAME); - if (name_len >= sizeof(addr.sun_path)) { - error = "backend abstract socket name is too long"; - ::close(fd); - return -1; - } - - /* - * Linux abstract AF_UNIX address: - * sun_path[0] = '\0' - * sun_path[1..] = socket name - */ - std::memcpy(addr.sun_path + 1, DS_SOCKETD_BACKEND_SOCK_NAME, name_len); - - const socklen_t addr_len = static_cast( - offsetof(sockaddr_un, sun_path) + 1 + name_len); - - if (::connect(fd, reinterpret_cast(&addr), addr_len) < 0) { - error = "connect(@"; - error += DS_SOCKETD_BACKEND_SOCK_NAME; - error += ") failed: "; - error += std::strerror(errno); - ::close(fd); - return -1; - } - - return fd; -} - -std::uint64_t ntoh64(std::uint64_t value) { -#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__) - return value; -#else - return (static_cast( - ntohl(static_cast(value & 0xffffffffULL))) - << 32) | - static_cast( - ntohl(static_cast(value >> 32))); -#endif -} - -std::uint64_t hton64(std::uint64_t value) { - return ntoh64(value); -} - -std::string decode_fixed_string(const char* data, std::size_t size) { - if (!data || size == 0) { - return {}; - } - - const void* nul = std::memchr(data, '\0', size); - const std::size_t len = - nul ? static_cast(nul) - data : size; - - /* - * CONCERN(socketd-wire): - * Backend wire strings are fixed-width char arrays rather than - * length-prefixed blobs. Decode defensively and tolerate a field that fills - * its entire slot without an in-bounds NUL terminator. - */ - return std::string(data, len); -} - -bool expect_ok_status(std::uint16_t status, - const char* operation, - std::string& error) { - if (status == DS_SOCKETD_STATUS_OK) { - return true; - } - - error = operation; - error += " returned backend status "; - error += std::to_string(status); - return false; -} - -} // namespace - -bool BackendClient::request(std::uint16_t opcode, - const void* payload, - std::uint32_t payload_len, - std::uint16_t& status_out, - std::string& payload_out, - std::string& error) const { - if (payload_len > DS_SOCKETD_MAX_PAYLOAD) { - error = "request payload exceeds DS_SOCKETD_MAX_PAYLOAD"; - return false; - } - - /* - * The current C bridge handles exactly one request per accepted connection, - * then closes it. Mirror that contract here: one RPC == one connection. - */ - const int fd = connect_backend(error); - if (fd < 0) { - return false; - } - - ds_socketd_request_header req {}; - req.magic_be = htonl(DS_SOCKETD_PROTO_MAGIC); - req.version_be = htons(DS_SOCKETD_PROTO_VERSION); - req.opcode_be = htons(opcode); - req.payload_len_be = htonl(payload_len); - - if (!write_exact(fd, &req, sizeof(req), error)) { - ::close(fd); - return false; - } - - if (payload_len > 0 && payload != nullptr) { - if (!write_exact(fd, payload, payload_len, error)) { - ::close(fd); - return false; - } - } - - ds_socketd_response_header resp {}; - if (!read_exact(fd, &resp, sizeof(resp), error)) { - ::close(fd); - return false; - } - - const std::uint32_t magic = ntohl(resp.magic_be); - const std::uint16_t version = ntohs(resp.version_be); - const std::uint16_t status = ntohs(resp.status_be); - const std::uint32_t response_payload_len = ntohl(resp.payload_len_be); - - if (magic != DS_SOCKETD_PROTO_MAGIC) { - error = "backend response used invalid protocol magic"; - ::close(fd); - return false; - } - - if (version != DS_SOCKETD_PROTO_VERSION) { - error = "backend response used unsupported protocol version"; - ::close(fd); - return false; - } - - if (response_payload_len > DS_SOCKETD_MAX_PAYLOAD) { - error = "backend response payload exceeds DS_SOCKETD_MAX_PAYLOAD"; - ::close(fd); - return false; - } - - payload_out.clear(); - payload_out.resize(response_payload_len); - - if (response_payload_len > 0) { - if (!read_exact(fd, payload_out.data(), response_payload_len, error)) { - ::close(fd); - return false; - } - } - - ::close(fd); - - status_out = status; - return true; -} - -bool BackendClient::ping(std::string& error) const { - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_PING, nullptr, 0, status, payload, error)) { - return false; - } - - if (status != DS_SOCKETD_STATUS_OK) { - error = "PING returned backend status "; - error += std::to_string(status); - return false; - } - - if (payload != "PONG") { - error = "PING returned unexpected payload: "; - error += payload; - return false; - } - - return true; -} - -bool BackendClient::capabilities(CapabilitiesResult& out, - std::string& error) const { - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_CAPABILITIES, nullptr, 0, - status, payload, error)) { - return false; - } - - if (status != DS_SOCKETD_STATUS_OK) { - error = "CAPABILITIES returned backend status "; - error += std::to_string(status); - return false; - } - - if (payload.size() != sizeof(std::uint32_t)) { - error = "CAPABILITIES returned payload of unexpected size"; - return false; - } - - std::uint32_t mask_be = 0; - std::memcpy(&mask_be, payload.data(), sizeof(mask_be)); - out.mask = ntohl(mask_be); - - return true; -} - -bool BackendClient::list_containers( - bool include_all, - std::vector& out, - std::string& error) const { - ds_socketd_list_containers_req req {}; - req.include_all = include_all ? 1u : 0u; - - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_LIST_CONTAINERS, - &req, - static_cast(sizeof(req)), - status, - payload, - error)) { - return false; - } - - if (!expect_ok_status(status, "LIST_CONTAINERS", error)) { - return false; - } - - if (payload.size() % sizeof(ds_socketd_container_record) != 0) { - error = "LIST_CONTAINERS returned payload of invalid size"; - return false; - } - - out.clear(); - - const std::size_t count = - payload.size() / sizeof(ds_socketd_container_record); - out.reserve(count); - - for (std::size_t i = 0; i < count; ++i) { - ds_socketd_container_record wire {}; - std::memcpy(&wire, - payload.data() + i * sizeof(wire), - sizeof(wire)); - - ContainerRecordResult result; - result.name = - decode_fixed_string(wire.name, sizeof(wire.name)); - result.uuid = - decode_fixed_string(wire.uuid, sizeof(wire.uuid)); - result.rootfs_path = - decode_fixed_string(wire.rootfs_path, sizeof(wire.rootfs_path)); - result.hostname = - decode_fixed_string(wire.hostname, sizeof(wire.hostname)); - result.nat_ip = - decode_fixed_string(wire.nat_ip, sizeof(wire.nat_ip)); - result.custom_init = - decode_fixed_string(wire.custom_init, sizeof(wire.custom_init)); - - result.pid = static_cast( - ntohl(static_cast(wire.pid_be))); - - result.net_mode = wire.net_mode; - - result.started_at = static_cast( - ntoh64(static_cast(wire.started_at_be))); - - std::size_t port_count = wire.port_count; - if (port_count > DS_SOCKETD_RECORD_PORTS_MAX) { - port_count = DS_SOCKETD_RECORD_PORTS_MAX; - } - - result.ports.reserve(port_count); - - for (std::size_t j = 0; j < port_count; ++j) { - const ds_socketd_port_record& port_wire = wire.ports[j]; - - ContainerPortResult port; - port.host_port = ntohs(port_wire.host_port_be); - port.host_port_end = ntohs(port_wire.host_port_end_be); - port.container_port = ntohs(port_wire.container_port_be); - port.container_port_end = - ntohs(port_wire.container_port_end_be); - port.proto = port_wire.proto; - - result.ports.push_back(std::move(port)); - } - - out.push_back(std::move(result)); - } - - return true; -} - - -bool BackendClient::inspect_container(const std::string& ref, - ContainerInspectResult& out, - bool& not_found, - std::string& error) const { - not_found = false; - - if (ref.empty() || ref.size() >= DS_SOCKETD_RECORD_NAME_MAX) { - error = "container reference is empty or too long"; - return false; - } - - ds_socketd_inspect_container_req req {}; - std::memcpy(req.target, ref.c_str(), ref.size()); - - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_INSPECT_CONTAINER, - &req, - static_cast(sizeof(req)), - status, - payload, - error)) { - return false; - } - - if (status == DS_SOCKETD_STATUS_NOT_FOUND) { - not_found = true; - error = "container not found"; - return false; - } - - if (!expect_ok_status(status, "INSPECT_CONTAINER", error)) { - return false; - } - - if (payload.size() != sizeof(ds_socketd_inspect_container_record_v1)) { - error = "INSPECT_CONTAINER returned payload of unexpected size"; - return false; - } - - ds_socketd_inspect_container_record_v1 wire {}; - std::memcpy(&wire, payload.data(), sizeof(wire)); - - const std::uint16_t record_version = ntohs(wire.record_version_be); - const std::uint32_t record_size = ntohl(wire.record_size_be); - if (record_version != 1u || record_size != sizeof(wire)) { - error = "INSPECT_CONTAINER returned unsupported inspect record version"; - return false; - } - - ContainerInspectResult result; - result.name = decode_fixed_string(wire.name, sizeof(wire.name)); - result.uuid = decode_fixed_string(wire.uuid, sizeof(wire.uuid)); - result.rootfs_path = - decode_fixed_string(wire.rootfs_path, sizeof(wire.rootfs_path)); - result.image_ref = - decode_fixed_string(wire.image_ref, sizeof(wire.image_ref)); - result.hostname = decode_fixed_string(wire.hostname, sizeof(wire.hostname)); - result.nat_ip = decode_fixed_string(wire.nat_ip, sizeof(wire.nat_ip)); - result.custom_init = - decode_fixed_string(wire.custom_init, sizeof(wire.custom_init)); - result.dns_servers = - decode_fixed_string(wire.dns_servers, sizeof(wire.dns_servers)); - - result.pid = static_cast( - ntohl(static_cast(wire.pid_be))); - result.started_at = static_cast( - ntoh64(static_cast(wire.started_at_be))); - result.memory_limit = static_cast( - ntoh64(static_cast(wire.memory_limit_be))); - result.cpu_quota = static_cast( - ntoh64(static_cast(wire.cpu_quota_be))); - result.cpu_period = static_cast( - ntoh64(static_cast(wire.cpu_period_be))); - result.pids_limit = static_cast( - ntoh64(static_cast(wire.pids_limit_be))); - result.privileged_mask = static_cast( - ntohl(static_cast(wire.privileged_mask_be))); - - result.net_mode = wire.net_mode; - result.foreground = wire.foreground != 0; - result.volatile_mode = wire.volatile_mode != 0; - result.force_cgroupv1 = wire.force_cgroupv1 != 0; - result.disable_ipv6 = wire.disable_ipv6 != 0; - result.android_storage = wire.android_storage != 0; - result.selinux_permissive = wire.selinux_permissive != 0; - result.hw_access = wire.hw_access != 0; - result.gpu_mode = wire.gpu_mode != 0; - result.termux_x11 = wire.termux_x11 != 0; - result.block_nested_ns = wire.block_nested_ns != 0; - result.is_img_mount = wire.is_img_mount != 0; - - std::size_t env_count = ntohs(wire.env_count_be); - if (env_count > DS_SOCKETD_INSPECT_ENV_MAX) { - env_count = DS_SOCKETD_INSPECT_ENV_MAX; - } - result.env_total_count = ntohs(wire.env_total_count_be); - result.env.reserve(env_count); - for (std::size_t i = 0; i < env_count; ++i) { - InspectEnvResult env; - env.key = decode_fixed_string(wire.env[i].key, sizeof(wire.env[i].key)); - env.value = - decode_fixed_string(wire.env[i].value, sizeof(wire.env[i].value)); - result.env.push_back(std::move(env)); - } - - std::size_t bind_count = ntohs(wire.bind_count_be); - if (bind_count > DS_SOCKETD_INSPECT_BINDS_MAX) { - bind_count = DS_SOCKETD_INSPECT_BINDS_MAX; - } - result.bind_total_count = ntohs(wire.bind_total_count_be); - result.binds.reserve(bind_count); - for (std::size_t i = 0; i < bind_count; ++i) { - InspectBindResult bind; - bind.source = decode_fixed_string(wire.binds[i].source, - sizeof(wire.binds[i].source)); - bind.destination = decode_fixed_string(wire.binds[i].destination, - sizeof(wire.binds[i].destination)); - bind.read_only = wire.binds[i].read_only != 0; - result.binds.push_back(std::move(bind)); - } - - std::size_t port_count = ntohs(wire.port_count_be); - if (port_count > DS_SOCKETD_RECORD_PORTS_MAX) { - port_count = DS_SOCKETD_RECORD_PORTS_MAX; - } - result.port_total_count = ntohs(wire.port_total_count_be); - result.ports.reserve(port_count); - for (std::size_t i = 0; i < port_count; ++i) { - const ds_socketd_port_record& port_wire = wire.ports[i]; - - ContainerPortResult port; - port.host_port = ntohs(port_wire.host_port_be); - port.host_port_end = ntohs(port_wire.host_port_end_be); - port.container_port = ntohs(port_wire.container_port_be); - port.container_port_end = ntohs(port_wire.container_port_end_be); - port.proto = port_wire.proto; - result.ports.push_back(std::move(port)); - } - - out = std::move(result); - return true; -} - - -bool BackendClient::info(InfoResult& out, std::string& error) const { - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_INFO, nullptr, 0, - status, payload, error)) { - return false; - } - - if (!expect_ok_status(status, "INFO", error)) { - return false; - } - - if (payload.size() != sizeof(ds_socketd_info_payload)) { - error = "INFO returned payload of unexpected size"; - return false; - } - - ds_socketd_info_payload wire {}; - std::memcpy(&wire, payload.data(), sizeof(wire)); - - out.containers_total = ntohl(wire.containers_total_be); - out.containers_running = ntohl(wire.containers_running_be); - out.containers_stopped = ntohl(wire.containers_stopped_be); - - return true; -} - -bool BackendClient::list_images( - std::vector& out, - std::string& error) const { - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_LIST_IMAGES, nullptr, 0, - status, payload, error)) { - return false; - } - - if (!expect_ok_status(status, "LIST_IMAGES", error)) { - return false; - } - - if (payload.size() % sizeof(ds_socketd_image_record) != 0) { - error = "LIST_IMAGES returned payload of invalid size"; - return false; - } - - out.clear(); - - const std::size_t count = - payload.size() / sizeof(ds_socketd_image_record); - out.reserve(count); - - for (std::size_t i = 0; i < count; ++i) { - ds_socketd_image_record wire {}; - std::memcpy(&wire, - payload.data() + i * sizeof(wire), - sizeof(wire)); - - ImageRecordResult result; - result.name = - decode_fixed_string(wire.name, sizeof(wire.name)); - result.rootfs_path = - decode_fixed_string(wire.rootfs_path, sizeof(wire.rootfs_path)); - result.uuid = - decode_fixed_string(wire.uuid, sizeof(wire.uuid)); - - result.is_running = - ntohl(static_cast(wire.is_running_be)) != 0; - - result.created_at = static_cast( - ntoh64(static_cast(wire.created_at_be))); - - out.push_back(std::move(result)); - } - - return true; -} - - - -bool BackendClient::lifecycle_request(std::uint16_t opcode, - const std::string& ref, - int timeout_seconds, - LifecycleResult& out, - std::string& error) const { - out = LifecycleResult {}; - - if (ref.empty() || ref.size() >= DS_SOCKETD_RECORD_NAME_MAX) { - error = "container reference is empty or too long"; - return false; - } - - if (timeout_seconds < -1) { - error = "container lifecycle timeout is invalid"; - return false; - } - - ds_socketd_lifecycle_req req {}; - std::memcpy(req.target, ref.c_str(), ref.size()); - req.timeout_seconds_be = static_cast( - htonl(static_cast(timeout_seconds))); - - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(opcode, - &req, - static_cast(sizeof(req)), - status, - payload, - error)) { - return false; - } - - if (status == DS_SOCKETD_STATUS_OK) { - return true; - } - - switch (status) { - case DS_SOCKETD_STATUS_NOT_FOUND: - out.not_found = true; - error = "container not found"; - return false; - case DS_SOCKETD_STATUS_ALREADY_RUNNING: - out.already_running = true; - error = "container already running"; - return false; - case DS_SOCKETD_STATUS_ALREADY_STOPPED: - out.already_stopped = true; - error = "container already stopped"; - return false; - default: - return expect_ok_status(status, "LIFECYCLE", error); - } -} - -bool BackendClient::start_container(const std::string& ref, - LifecycleResult& out, - std::string& error) const { - return lifecycle_request(DS_SOCKETD_OP_START_CONTAINER, ref, -1, out, error); -} - -bool BackendClient::stop_container(const std::string& ref, - int timeout_seconds, - LifecycleResult& out, - std::string& error) const { - return lifecycle_request(DS_SOCKETD_OP_STOP_CONTAINER, - ref, - timeout_seconds, - out, - error); -} - -bool BackendClient::restart_container(const std::string& ref, - int timeout_seconds, - LifecycleResult& out, - std::string& error) const { - return lifecycle_request(DS_SOCKETD_OP_RESTART_CONTAINER, - ref, - timeout_seconds, - out, - error); -} - -bool BackendClient::poll_events( - std::int64_t since, - std::vector& out, - std::string& error) const { - if (since < 0) { - error = "POLL_EVENTS does not accept a negative 'since' value"; - return false; - } - - ds_socketd_poll_events_req req {}; - req.since_be = static_cast( - hton64(static_cast(since))); - - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_POLL_EVENTS, - &req, - static_cast(sizeof(req)), - status, - payload, - error)) { - return false; - } - - if (!expect_ok_status(status, "POLL_EVENTS", error)) { - return false; - } - - if (payload.size() % sizeof(ds_socketd_core_event_record) != 0) { - error = "POLL_EVENTS returned payload of invalid size"; - return false; - } - - out.clear(); - - const std::size_t count = - payload.size() / sizeof(ds_socketd_core_event_record); - out.reserve(count); - - for (std::size_t i = 0; i < count; ++i) { - ds_socketd_core_event_record wire {}; - std::memcpy(&wire, - payload.data() + i * sizeof(wire), - sizeof(wire)); - - CoreEventResult result; - result.time = static_cast( - ntoh64(static_cast(wire.time_be))); - result.time_nano = static_cast( - ntoh64(static_cast(wire.time_nano_be))); - - result.type = - decode_fixed_string(wire.type, sizeof(wire.type)); - result.action = - decode_fixed_string(wire.action, sizeof(wire.action)); - result.actor_id = - decode_fixed_string(wire.actor_id, sizeof(wire.actor_id)); - result.actor_name = - decode_fixed_string(wire.actor_name, sizeof(wire.actor_name)); - - out.push_back(std::move(result)); - } - - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/backend_client.h b/src/socketd/backend_client.h deleted file mode 100644 index b138f62c..00000000 --- a/src/socketd/backend_client.h +++ /dev/null @@ -1,151 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace droidspaces::socketd { - -struct CapabilitiesResult { - std::uint32_t mask = 0; -}; - -struct ContainerPortResult { - std::uint16_t host_port = 0; - std::uint16_t host_port_end = 0; - std::uint16_t container_port = 0; - std::uint16_t container_port_end = 0; - std::uint8_t proto = 0; /* 0 = tcp, 1 = udp */ -}; - -struct InspectEnvResult { - std::string key; - std::string value; -}; - -struct InspectBindResult { - std::string source; - std::string destination; - bool read_only = false; -}; - -struct ContainerInspectResult { - std::string name; - std::string uuid; - std::string rootfs_path; - std::string image_ref; - std::string hostname; - std::string nat_ip; - std::string custom_init; - std::string dns_servers; - std::int32_t pid = 0; - std::uint8_t net_mode = 0; /* 0 = host, 1 = nat, 2 = none */ - std::int64_t started_at = 0; - std::int64_t memory_limit = 0; - std::int64_t cpu_quota = 0; - std::int64_t cpu_period = 0; - std::int64_t pids_limit = 0; - std::int32_t privileged_mask = 0; - bool foreground = false; - bool volatile_mode = false; - bool force_cgroupv1 = false; - bool disable_ipv6 = false; - bool android_storage = false; - bool selinux_permissive = false; - bool hw_access = false; - bool gpu_mode = false; - bool termux_x11 = false; - bool block_nested_ns = false; - bool is_img_mount = false; - std::uint16_t env_total_count = 0; - std::uint16_t bind_total_count = 0; - std::uint16_t port_total_count = 0; - std::vector env; - std::vector binds; - std::vector ports; -}; - -struct ContainerRecordResult { - std::string name; - std::string uuid; - std::string rootfs_path; - std::string hostname; - std::string nat_ip; - std::string custom_init; - std::int32_t pid = 0; - std::uint8_t net_mode = 0; /* 0 = host, 1 = nat, 2 = none */ - std::int64_t started_at = 0; - std::vector ports; -}; - -struct InfoResult { - std::uint32_t containers_total = 0; - std::uint32_t containers_running = 0; - std::uint32_t containers_stopped = 0; -}; - -struct ImageRecordResult { - std::string name; - std::string rootfs_path; - std::string uuid; - bool is_running = false; - std::int64_t created_at = 0; -}; - -struct CoreEventResult { - std::int64_t time = 0; - std::int64_t time_nano = 0; - std::string type; - std::string action; - std::string actor_id; - std::string actor_name; -}; - -struct LifecycleResult { - bool not_found = false; - bool already_running = false; - bool already_stopped = false; -}; - -class BackendClient { -public: - BackendClient() = default; - - bool ping(std::string &error) const; - bool capabilities(CapabilitiesResult &out, std::string &error) const; - - bool list_containers(bool include_all, - std::vector &out, - std::string &error) const; - - bool inspect_container(const std::string &ref, - ContainerInspectResult &out, - bool ¬_found, - std::string &error) const; - - bool info(InfoResult &out, std::string &error) const; - - bool list_images(std::vector &out, - std::string &error) const; - - bool poll_events(std::int64_t since, std::vector &out, - std::string &error) const; - - bool start_container(const std::string &ref, LifecycleResult &out, - std::string &error) const; - bool stop_container(const std::string &ref, int timeout_seconds, - LifecycleResult &out, std::string &error) const; - bool restart_container(const std::string &ref, int timeout_seconds, - LifecycleResult &out, std::string &error) const; - -private: - bool lifecycle_request(std::uint16_t opcode, const std::string &ref, - int timeout_seconds, LifecycleResult &out, - std::string &error) const; - - bool request(std::uint16_t opcode, const void *payload, - std::uint32_t payload_len, std::uint16_t &status_out, - std::string &payload_out, std::string &error) const; -}; - -} // namespace droidspaces::socketd diff --git a/src/socketd/container_inspect.cpp b/src/socketd/container_inspect.cpp deleted file mode 100644 index 9c9dde85..00000000 --- a/src/socketd/container_inspect.cpp +++ /dev/null @@ -1,410 +0,0 @@ -#include "container_inspect.h" - -#include "backend_client.h" - -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -constexpr const char* kDockerZeroTime = "0001-01-01T00:00:00Z"; - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -std::string rfc3339_utc(std::int64_t unix_seconds) { - if (unix_seconds <= 0) { - return kDockerZeroTime; - } - - std::time_t value = static_cast(unix_seconds); - if (static_cast(value) != unix_seconds) { - return kDockerZeroTime; - } - - std::tm tm {}; -#if defined(_POSIX_THREAD_SAFE_FUNCTIONS) || defined(__ANDROID__) || defined(__linux__) - if (::gmtime_r(&value, &tm) == nullptr) { - return kDockerZeroTime; - } -#else - const std::tm* tmp = std::gmtime(&value); - if (tmp == nullptr) { - return kDockerZeroTime; - } - tm = *tmp; -#endif - - char buffer[64] {}; - if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm) == 0) { - return kDockerZeroTime; - } - - return buffer; -} - -const char* port_type(std::uint8_t proto) { - return proto == 1u ? "udp" : "tcp"; -} - -const char* docker_network_mode(std::uint8_t mode) { - switch (mode) { - case 1u: - return "bridge"; - case 2u: - return "none"; - case 0u: - default: - return "host"; - } -} - -std::string container_command(const ContainerInspectResult& record) { - return record.custom_init.empty() ? "/sbin/init" : record.custom_init; -} - -const char* container_state_status(const ContainerInspectResult& record) { - return record.pid > 0 ? "running" : "exited"; -} - -void append_string_field(std::string& out, - const char* name, - const std::string& value) { - out += '"'; - out += name; - out += "\":\""; - out += json_escape(value); - out += '"'; -} - -void append_port_map_json(std::string& out, - const ContainerInspectResult& record, - bool include_host_bindings) { - out += '{'; - - for (std::size_t i = 0; i < record.ports.size(); ++i) { - if (i != 0) { - out += ','; - } - - const ContainerPortResult& port = record.ports[i]; - out += '"'; - out += std::to_string(port.container_port); - out += '/'; - out += port_type(port.proto); - out += "\":"; - - if (include_host_bindings) { - out += "[{\"HostIp\":\"\",\"HostPort\":\""; - out += std::to_string(port.host_port); - out += "\"}]"; - } else { - out += "{}"; - } - } - - out += '}'; -} - -void append_config_json(std::string& out, - const ContainerInspectResult& record, - const std::string& command) { - out += "\"Config\":{"; - append_string_field(out, "Hostname", record.hostname); - out += ",\"Domainname\":\"\","; - out += "\"User\":\"\","; - out += "\"AttachStdin\":false,"; - out += "\"AttachStdout\":false,"; - out += "\"AttachStderr\":false,"; - out += "\"ExposedPorts\":"; - append_port_map_json(out, record, false); - out += ','; - out += "\"Tty\":false,"; - out += "\"OpenStdin\":false,"; - out += "\"StdinOnce\":false,"; - out += "\"Env\":[],"; - out += "\"Cmd\":[\""; - out += json_escape(command); - out += "\"],"; - append_string_field(out, "Image", record.image_ref.empty() ? record.rootfs_path : record.image_ref); - out += ",\"Volumes\":{},"; - out += "\"WorkingDir\":\"\","; - out += "\"Entrypoint\":[],"; - out += "\"OnBuild\":[],"; - out += "\"Labels\":{}"; - out += '}'; -} - -void append_host_config_json(std::string& out, - const ContainerInspectResult& record) { - out += "\"HostConfig\":{"; - out += "\"Binds\":[],"; - out += "\"ContainerIDFile\":\"\","; - out += "\"LogConfig\":{\"Type\":\"\",\"Config\":{}},"; - out += "\"NetworkMode\":\""; - out += docker_network_mode(record.net_mode); - out += "\","; - out += "\"PortBindings\":"; - append_port_map_json(out, record, true); - out += ','; - out += "\"RestartPolicy\":{\"Name\":\"\",\"MaximumRetryCount\":0},"; - out += "\"AutoRemove\":false,"; - out += "\"VolumeDriver\":\"\","; - out += "\"VolumesFrom\":[],"; - out += "\"CapAdd\":[],"; - out += "\"CapDrop\":[],"; - out += "\"CgroupnsMode\":\"\","; - out += "\"Dns\":[],"; - out += "\"DnsOptions\":[],"; - out += "\"DnsSearch\":[],"; - out += "\"ExtraHosts\":[],"; - out += "\"GroupAdd\":[],"; - out += "\"IpcMode\":\"\","; - out += "\"Cgroup\":\"\","; - out += "\"Links\":[],"; - out += "\"OomScoreAdj\":0,"; - out += "\"PidMode\":\"\","; - out += "\"Privileged\":false,"; - out += "\"PublishAllPorts\":false,"; - out += "\"ReadonlyRootfs\":false,"; - out += "\"SecurityOpt\":[],"; - out += "\"UTSMode\":\"\","; - out += "\"UsernsMode\":\"\","; - out += "\"ShmSize\":0,"; - out += "\"Runtime\":\"\","; - out += "\"ConsoleSize\":[0,0],"; - out += "\"Isolation\":\"\","; - out += "\"CpuShares\":0,"; - out += "\"Memory\":0,"; - out += "\"NanoCpus\":0,"; - out += "\"CgroupParent\":\"\","; - out += "\"BlkioWeight\":0,"; - out += "\"BlkioWeightDevice\":[],"; - out += "\"BlkioDeviceReadBps\":[],"; - out += "\"BlkioDeviceWriteBps\":[],"; - out += "\"BlkioDeviceReadIOps\":[],"; - out += "\"BlkioDeviceWriteIOps\":[],"; - out += "\"CpuPeriod\":0,"; - out += "\"CpuQuota\":0,"; - out += "\"CpuRealtimePeriod\":0,"; - out += "\"CpuRealtimeRuntime\":0,"; - out += "\"CpusetCpus\":\"\","; - out += "\"CpusetMems\":\"\","; - out += "\"Devices\":[],"; - out += "\"DeviceCgroupRules\":[],"; - out += "\"DeviceRequests\":[],"; - out += "\"KernelMemory\":0,"; - out += "\"KernelMemoryTCP\":0,"; - out += "\"MemoryReservation\":0,"; - out += "\"MemorySwap\":0,"; - out += "\"MemorySwappiness\":0,"; - out += "\"OomKillDisable\":false,"; - out += "\"PidsLimit\":0,"; - out += "\"Ulimits\":[],"; - out += "\"CpuCount\":0,"; - out += "\"CpuPercent\":0,"; - out += "\"IOMaximumIOps\":0,"; - out += "\"IOMaximumBandwidth\":0,"; - out += "\"MaskedPaths\":[],"; - out += "\"ReadonlyPaths\":[]"; - out += '}'; -} - -void append_state_json(std::string& out, - const ContainerInspectResult& record, - const std::string& started_at) { - out += "\"State\":{"; - out += "\"Status\":\""; - out += container_state_status(record); - out += "\","; - out += "\"Running\":"; - out += record.pid > 0 ? "true" : "false"; - out += ','; - out += "\"Paused\":false,"; - out += "\"Restarting\":false,"; - out += "\"OOMKilled\":false,"; - out += "\"Dead\":false,"; - out += "\"Pid\":"; - out += std::to_string(record.pid > 0 ? record.pid : 0); - out += ','; - out += "\"ExitCode\":0,"; - out += "\"Error\":\"\","; - out += "\"StartedAt\":\""; - out += json_escape(started_at); - out += "\","; - out += "\"FinishedAt\":\""; - out += kDockerZeroTime; - out += "\""; - out += '}'; -} - -void append_networks_json(std::string& out, - const ContainerInspectResult& record) { - out += "\"Networks\":{"; - - if (record.net_mode == 1u) { - out += "\"droidspaces-bridge\":{"; - out += "\"IPAMConfig\":{},"; - out += "\"Links\":[],"; - out += "\"Aliases\":[],"; - out += "\"NetworkID\":\"\","; - out += "\"EndpointID\":\"\","; - out += "\"Gateway\":\"\","; - out += "\"IPAddress\":\""; - out += json_escape(record.nat_ip); - out += "\","; - out += "\"IPPrefixLen\":0,"; - out += "\"IPv6Gateway\":\"\","; - out += "\"GlobalIPv6Address\":\"\","; - out += "\"GlobalIPv6PrefixLen\":0,"; - out += "\"MacAddress\":\"\","; - out += "\"DriverOpts\":{}"; - out += '}'; - } - - out += '}'; -} - -void append_network_settings_json(std::string& out, - const ContainerInspectResult& record) { - out += "\"NetworkSettings\":{"; - out += "\"Bridge\":\"\","; - out += "\"SandboxID\":\"\","; - out += "\"HairpinMode\":false,"; - out += "\"LinkLocalIPv6Address\":\"\","; - out += "\"LinkLocalIPv6PrefixLen\":0,"; - out += "\"Ports\":"; - append_port_map_json(out, record, true); - out += ','; - out += "\"SandboxKey\":\"\","; - out += "\"SecondaryIPAddresses\":[],"; - out += "\"SecondaryIPv6Addresses\":[],"; - out += "\"EndpointID\":\"\","; - out += "\"Gateway\":\"\","; - out += "\"GlobalIPv6Address\":\"\","; - out += "\"GlobalIPv6PrefixLen\":0,"; - out += "\"IPAddress\":\""; - if (record.net_mode == 1u) { - out += json_escape(record.nat_ip); - } - out += "\","; - out += "\"IPPrefixLen\":0,"; - out += "\"IPv6Gateway\":\"\","; - out += "\"MacAddress\":\"\","; - append_networks_json(out, record); - out += '}'; -} - -void append_inspect_json(std::string& out, - const ContainerInspectResult& record) { - const std::string command = container_command(record); - const std::string started_at = rfc3339_utc(record.started_at); - const std::string created = started_at; - - out += '{'; - - out += "\"AppArmorProfile\":\"\","; - out += "\"Args\":[],"; - append_config_json(out, record, command); - out += ','; - out += "\"Created\":\""; - out += json_escape(created); - out += "\","; - out += "\"Driver\":\"droidspaces\","; - out += "\"ExecIDs\":[],"; - append_host_config_json(out, record); - out += ','; - out += "\"HostnamePath\":\"\","; - out += "\"HostsPath\":\"\","; - out += "\"LogPath\":\"\","; - append_string_field(out, "Id", record.uuid); - out += ','; - append_string_field(out, "Image", record.image_ref.empty() ? record.rootfs_path : record.image_ref); - out += ','; - out += "\"MountLabel\":\"\","; - out += "\"Name\":\"/"; - out += json_escape(record.name); - out += "\","; - append_network_settings_json(out, record); - out += ','; - append_string_field(out, "Path", command); - out += ','; - out += "\"ProcessLabel\":\"\","; - out += "\"ResolvConfPath\":\"\","; - out += "\"RestartCount\":0,"; - append_state_json(out, record, started_at); - out += ','; - out += "\"Mounts\":[]"; - - out += "}\n"; -} - -} // namespace - -bool request_container_inspect_json_from_core(const std::string& ref, - std::string& json_out, - bool& not_found, - std::string& error) { - error.clear(); - not_found = false; - - BackendClient backend; - ContainerInspectResult record; - - if (!backend.inspect_container(ref, record, not_found, error)) { - return false; - } - - std::string body; - body.reserve(4096); - append_inspect_json(body, record); - - json_out = std::move(body); - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/container_inspect.h b/src/socketd/container_inspect.h deleted file mode 100644 index ff565793..00000000 --- a/src/socketd/container_inspect.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include - -namespace droidspaces::socketd { - -/* - * Socketd-owned renderer for Docker-compatible container inspect payloads. - * The backend supplies Droidspaces facts; this layer fills the broad Docker - * object skeleton with stable placeholders for unsupported Docker-only fields. - */ -bool request_container_inspect_json_from_core(const std::string& ref, - std::string& json_out, - bool& not_found, - std::string& error); - -} // namespace droidspaces::socketd diff --git a/src/socketd/container_list.cpp b/src/socketd/container_list.cpp deleted file mode 100644 index be14f0b6..00000000 --- a/src/socketd/container_list.cpp +++ /dev/null @@ -1,237 +0,0 @@ -#include "container_list.h" - -#include "backend_client.h" - -#include -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -const char* container_state(const ContainerRecordResult& record) { - return record.pid > 0 ? "running" : "exited"; -} - -const char* container_status(const ContainerRecordResult& record) { - return record.pid > 0 ? "Up" : "Exited"; -} - -const char* port_type(std::uint8_t proto) { - return proto == 1u ? "udp" : "tcp"; -} - -void append_ports_json(std::string& out, - const ContainerRecordResult& record) { - out += "\"Ports\":["; - - for (std::size_t i = 0; i < record.ports.size(); ++i) { - if (i != 0) { - out += ","; - } - - const ContainerPortResult& port = record.ports[i]; - - out += "{"; - out += "\"PrivatePort\":"; - out += std::to_string(port.container_port); - out += ","; - out += "\"PublicPort\":"; - out += std::to_string(port.host_port); - out += ","; - out += "\"Type\":\""; - out += port_type(port.proto); - out += "\""; - out += "}"; - } - - out += "]"; -} - -void append_network_settings_json(std::string& out, - const ContainerRecordResult& record) { - out += "\"NetworkSettings\":{\"Networks\":{"; - - /* - * The Phase 4 public JSON projection follows the current compatibility plan: - * only NAT-mode containers receive a Docker-style synthetic bridge attachment - * in /containers/json. Host and none modes remain represented by an empty - * Networks object here. - */ - if (record.net_mode == 1u && !record.nat_ip.empty()) { - out += "\"droidspaces-bridge\":{"; - out += "\"IPAddress\":\""; - out += json_escape(record.nat_ip); - out += "\""; - out += "}"; - } - - out += "}}"; -} - - -/* - * Docker inspect field-mapping design note - * ---------------------------------------- - * Keep the future GET /containers/{id}/json inspect renderer explicit about - * which fields are Droidspaces-native, compatibility-only, or deliberately not - * exposed. The /containers/json projection below should stay aligned with these - * identities so Docker-compatible clients see stable values across list and - * inspect responses. - * - * | Inspect field | Classification | Droidspaces source / compatibility value | - * | --- | --- | --- | - * | Id | Real Droidspaces-backed value | cfg.uuid / ContainerRecordResult::uuid. | - * | Name | Real Droidspaces-backed value | "/" + cfg.container_name / record.name. | - * | Config.Hostname | Real Droidspaces-backed value | cfg.hostname / record.hostname. | - * | Config.Image, Image | Real Droidspaces-backed value | rootfs image path when present, otherwise rootfs path or the pseudo-image UUID used by /images/json. | - * | Config.Env | Real Droidspaces-backed value | cfg.env_vars; include the default PATH when no environment is configured. | - * | Config.Cmd | Real Droidspaces-backed value | Single-element array containing cfg.custom_init, or /sbin/init when unset. | - * | Path | Real Droidspaces-backed value | First Config.Cmd element. | - * | Args | Stable compatibility placeholder | Remaining Config.Cmd elements; currently [] for the Droidspaces init model. | - * | HostConfig.NetworkMode | Real Droidspaces-backed value | "bridge" for NAT, "host" for host networking, "none" for no network. | - * | HostConfig.PortBindings | Real Droidspaces-backed value | cfg.port_forwards. | - * | HostConfig.Memory, CpuPeriod, CpuQuota, PidsLimit | Real Droidspaces-backed value | cfg.memory_limit, cfg.cpu_period, cfg.cpu_quota, cfg.pids_limit; 0 means unlimited/unset. | - * | HostConfig.Privileged | Real Droidspaces-backed value | cfg.privileged_mask != 0. | - * | Mounts | Real Droidspaces-backed value | cfg.binds. | - * | NetworkSettings.Networks | Real Droidspaces-backed value | Extend the current droidspaces-bridge NAT projection with address/attachment details as they become available. | - * | State.Running, State.Pid, State.StartedAt, State.Status | Real Droidspaces-backed value | PID and started_at; stopped containers use pid 0, zero time/empty time, and exited status. | - * | Docker-specific fields with no Droidspaces equivalent | Stable compatibility placeholder | Empty strings, false, zero, empty arrays, or empty objects, chosen consistently by field type. | - * | Fields that would imply unsupported Docker semantics | Intentionally omitted field | Omit rather than invent behavior, unless a Docker client requires a typed placeholder for compatibility. | - * - * Public-facing rationale lives in Documentation/socketd-inspect-field-mapping.md. - */ - -void append_container_json(std::string& out, - const ContainerRecordResult& record) { - const std::string command = - record.custom_init.empty() ? "/sbin/init" : record.custom_init; - - out += "{"; - - out += "\"Id\":\""; - out += json_escape(record.uuid); - out += "\","; - - out += "\"Names\":[\"/"; - out += json_escape(record.name); - out += "\"],"; - - out += "\"Image\":\""; - out += json_escape(record.rootfs_path); - out += "\","; - - out += "\"ImageID\":\""; - out += json_escape(record.uuid); - out += "\","; - - out += "\"Command\":\""; - out += json_escape(command); - out += "\","; - - out += "\"Created\":"; - out += std::to_string(record.started_at > 0 ? record.started_at : 0); - out += ","; - - append_ports_json(out, record); - out += ","; - - out += "\"Labels\":{},"; - - out += "\"State\":\""; - out += container_state(record); - out += "\","; - - out += "\"Status\":\""; - out += container_status(record); - out += "\","; - - append_network_settings_json(out, record); - out += ","; - - out += "\"Mounts\":[]"; - - out += "}"; -} - -} // namespace - -bool request_container_list_json_from_core( - const ContainerListRequest& request, - std::string& json_out, - std::string& error) { - error.clear(); - - BackendClient backend; - std::vector records; - - if (!backend.list_containers(request.include_all, records, error)) { - return false; - } - - std::string body; - body.reserve(256 + records.size() * 512); - - body += "["; - - for (std::size_t i = 0; i < records.size(); ++i) { - if (i != 0) { - body += ","; - } - - append_container_json(body, records[i]); - } - - body += "]\n"; - - json_out = std::move(body); - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/container_list.h b/src/socketd/container_list.h deleted file mode 100644 index a4c4f2f3..00000000 --- a/src/socketd/container_list.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -namespace droidspaces::socketd { - -struct ContainerListRequest { - /* - * Docker-compatible public API spelling: - * - * GET /containers/json?all=1 - * - * The extension keeps this as a semantic flag rather than exposing the - * raw HTTP query string to any eventual core-side implementation. - */ - bool include_all = false; -}; - -/* - * Socketd-owned seam for obtaining the Docker-compatible container list - * payload. The HTTP server remains independent of backend protocol details; - * this layer requests typed core records through BackendClient and renders the - * public JSON shape expected by Docker-compatible consumers. - */ -bool request_container_list_json_from_core(const ContainerListRequest &request, - std::string &json_out, - std::string &error); - -} // namespace droidspaces::socketd diff --git a/src/socketd/event_log.cpp b/src/socketd/event_log.cpp deleted file mode 100644 index 687f0a01..00000000 --- a/src/socketd/event_log.cpp +++ /dev/null @@ -1,315 +0,0 @@ -#include "event_log.h" - -#include "backend_client.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -constexpr std::size_t kMaxSocketdEvents = 128; - -struct EventAttribute { - std::string key; - std::string value; -}; - -struct SocketdEvent { - std::string type; - std::string action; - std::string actor_id; - std::vector attributes; - std::int64_t time = 0; - std::int64_t time_nano = 0; -}; - -std::deque g_socketd_events; - -std::int64_t unix_time_seconds_now() { - using clock = std::chrono::system_clock; - const auto now = clock::now(); - return std::chrono::duration_cast( - now.time_since_epoch()) - .count(); -} - -std::int64_t unix_time_nanos_now() { - using clock = std::chrono::system_clock; - const auto now = clock::now(); - return std::chrono::duration_cast( - now.time_since_epoch()) - .count(); -} - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -bool parse_optional_epoch_seconds(const std::string& value, - std::int64_t& out, - std::string& error) { - out = 0; - - if (value.empty()) { - return true; - } - - char* end = nullptr; - errno = 0; - const long long parsed = std::strtoll(value.c_str(), &end, 10); - - if (errno != 0 || end == value.c_str() || *end != '\0') { - error = "invalid event timestamp: "; - error += value; - return false; - } - - if (parsed < 0) { - error = "negative event timestamp is unsupported: "; - error += value; - return false; - } - - out = static_cast(parsed); - return true; -} - -bool event_in_window(const SocketdEvent& event, - std::int64_t since, - std::int64_t until) { - if (since > 0 && event.time < since) { - return false; - } - - if (until > 0 && event.time > until) { - return false; - } - - return true; -} - -std::string encode_event_json_line(const SocketdEvent& event) { - std::string out; - out.reserve(512); - - out += "{"; - - out += "\"Type\":\""; - out += json_escape(event.type); - out += "\","; - - out += "\"Action\":\""; - out += json_escape(event.action); - out += "\","; - - out += "\"Actor\":{"; - out += "\"ID\":\""; - out += json_escape(event.actor_id); - out += "\","; - out += "\"Attributes\":{"; - - for (std::size_t i = 0; i < event.attributes.size(); ++i) { - if (i != 0) { - out += ","; - } - - out += "\""; - out += json_escape(event.attributes[i].key); - out += "\":\""; - out += json_escape(event.attributes[i].value); - out += "\""; - } - - out += "}"; - out += "},"; - - out += "\"scope\":\"local\","; - - out += "\"time\":"; - out += std::to_string(event.time); - out += ","; - - out += "\"timeNano\":"; - out += std::to_string(event.time_nano); - - out += "}\n"; - return out; -} - -SocketdEvent socketd_event_from_core_event(const CoreEventResult& core) { - SocketdEvent event; - event.type = core.type; - event.action = core.action; - event.actor_id = core.actor_id; - event.time = core.time; - event.time_nano = core.time_nano; - - if (!core.actor_name.empty()) { - event.attributes.push_back(EventAttribute{ - "name", - core.actor_name, - }); - } - - return event; -} - -} // namespace - -void record_socketd_event(const std::string& type, - const std::string& action, - const std::string& actor_id, - const SocketdEventAttributes* attributes, - std::size_t attribute_count) { - SocketdEvent event; - event.type = type; - event.action = action; - event.actor_id = actor_id; - event.time = unix_time_seconds_now(); - event.time_nano = unix_time_nanos_now(); - - event.attributes.reserve(attribute_count); - for (std::size_t i = 0; i < attribute_count; ++i) { - event.attributes.push_back(EventAttribute{ - attributes[i].key, - attributes[i].value, - }); - } - - if (g_socketd_events.size() >= kMaxSocketdEvents) { - g_socketd_events.pop_front(); - } - - g_socketd_events.push_back(std::move(event)); -} - -bool request_event_log_stream_from_core( - const EventsRequest& request, - std::string& stream_out, - std::string& error) { - error.clear(); - stream_out.clear(); - - std::int64_t since = 0; - std::int64_t until = 0; - - if (!parse_optional_epoch_seconds(request.since, since, error)) { - return false; - } - - if (!parse_optional_epoch_seconds(request.until, until, error)) { - return false; - } - - if (since > 0 && until > 0 && since > until) { - error = "event 'since' timestamp is after 'until'"; - return false; - } - - std::vector merged_events; - merged_events.reserve(g_socketd_events.size()); - - for (const SocketdEvent& event : g_socketd_events) { - if (!event_in_window(event, since, until)) { - continue; - } - - merged_events.push_back(event); - } - - BackendClient backend; - std::vector core_events; - std::string backend_error; - - if (backend.poll_events(since, core_events, backend_error)) { - merged_events.reserve(merged_events.size() + core_events.size()); - - for (const CoreEventResult& core_event : core_events) { - SocketdEvent event = socketd_event_from_core_event(core_event); - - if (!event_in_window(event, since, until)) { - continue; - } - - merged_events.push_back(std::move(event)); - } - } else { - /* - * CONCERN(socketd-events): - * The event endpoint follows the current compatibility plan and degrades - * gracefully when the privileged backend is unavailable: socketd-local - * daemon events remain visible instead of failing the entire /events - * response. If a later contract requires hard backend failure, tighten - * that policy here. - */ - } - - std::stable_sort( - merged_events.begin(), - merged_events.end(), - [](const SocketdEvent& lhs, const SocketdEvent& rhs) { - if (lhs.time_nano != rhs.time_nano) { - return lhs.time_nano < rhs.time_nano; - } - - return lhs.time < rhs.time; - }); - - for (const SocketdEvent& event : merged_events) { - stream_out += encode_event_json_line(event); - } - - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/event_log.h b/src/socketd/event_log.h deleted file mode 100644 index ddbd3e3f..00000000 --- a/src/socketd/event_log.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include -#include - -namespace droidspaces::socketd { - -struct EventsRequest { - std::string since; - std::string until; -}; - -struct SocketdEventAttributes { - std::string key; - std::string value; -}; - -/* - * Record a socketd-owned engine-style event in the extension-local journal. - * These local daemon events are merged with backend core lifecycle events by - * request_event_log_stream_from_core(). - */ -void record_socketd_event(const std::string &type, const std::string &action, - const std::string &actor_id, - const SocketdEventAttributes *attributes, - std::size_t attribute_count); - -/* - * Return events matching the requested time window as a Docker-compatible - * stream of JSON objects. The response merges: - * - * - socketd-owned local events recorded in this process, and - * - core lifecycle events fetched from the privileged backend bridge. - */ -bool request_event_log_stream_from_core(const EventsRequest &request, - std::string &stream_out, - std::string &error); - -} // namespace droidspaces::socketd diff --git a/src/socketd/main.cpp b/src/socketd/main.cpp deleted file mode 100644 index e5e977fd..00000000 --- a/src/socketd/main.cpp +++ /dev/null @@ -1,183 +0,0 @@ -#include "api_server.h" -#include "backend_client.h" -#include "event_log.h" - -#include "socketd_protocol.h" - -#include -#include -#include -#include -#include - -namespace { - -using droidspaces::socketd::ApiServer; -using droidspaces::socketd::BackendClient; -using droidspaces::socketd::CapabilitiesResult; -using droidspaces::socketd::TcpListenConfig; -using droidspaces::socketd::parse_tcp_listen_endpoint; -using droidspaces::socketd::SocketdEventAttributes; -using droidspaces::socketd::record_socketd_event; - -constexpr std::uint32_t kRequiredBackendCapabilities = - DS_SOCKETD_CAP_PROTOCOL_V1 | - DS_SOCKETD_CAP_PING | - DS_SOCKETD_CAP_CAPABILITIES; - -void print_usage(const char* argv0) { - std::cerr - << "Usage:\n" - << " " << argv0 << " --listen-tcp [PORT]\n" - << " " << argv0 << " --listen-tcp [ADDR:PORT]\n" - << "\n" - << "Examples:\n" - << " " << argv0 << " --listen-tcp\n" - << " " << argv0 << " --listen-tcp 2375\n" - << " " << argv0 << " --listen-tcp 127.0.0.1:2375\n" - << " " << argv0 << " --listen-tcp 0.0.0.0:2375\n"; -} - -bool check_backend(std::string& error) { - BackendClient backend; - - if (!backend.ping(error)) { - error = "backend PING failed: " + error; - return false; - } - - CapabilitiesResult caps {}; - if (!backend.capabilities(caps, error)) { - error = "backend CAPABILITIES failed: " + error; - return false; - } - - if ((caps.mask & kRequiredBackendCapabilities) != - kRequiredBackendCapabilities) { - error = "backend is missing required base capabilities"; - return false; - } -// To tty - std::cerr << "socketd: backend handshake OK, capabilities mask: 0x" - << std::hex - << caps.mask - << std::dec - << '\n'; - -// To API -const std::string caps_text = "0x" + [&caps]() { - char buffer[32] {}; - std::snprintf(buffer, sizeof(buffer), "%x", caps.mask); - return std::string(buffer); -}(); - -const SocketdEventAttributes attrs[] = { - {"name", "droidspaces-backend"}, - {"component", "socketd"}, - {"capabilities", caps_text}, -}; - -record_socketd_event("daemon", - "connect", - "droidspaces-backend", - attrs, - sizeof(attrs) / sizeof(attrs[0])); - - return true; -} - -} // namespace - -int main(int argc, char** argv) { - /* - * Avoid process termination if a TCP peer disappears while a response - * is being written. - */ - (void)std::signal(SIGPIPE, SIG_IGN); - - bool listen_tcp = false; - TcpListenConfig tcp_config {}; - - for (int i = 1; i < argc; ++i) { - const std::string arg = argv[i]; - - if (arg == "--help" || arg == "-h") { - print_usage(argv[0]); - return 0; - } - - if (arg == "--listen-tcp") { - if (listen_tcp) { - std::cerr << "socketd: --listen-tcp specified more than once\n"; - return 2; - } - - listen_tcp = true; - - if (i + 1 < argc) { - const std::string next = argv[i + 1]; - - if (!next.empty() && next[0] != '-') { - std::string parse_error; - if (!parse_tcp_listen_endpoint(next, tcp_config, parse_error)) { - std::cerr << "socketd: " << parse_error << '\n'; - return 2; - } - - ++i; - } - } - - continue; - } - - if (arg.rfind("--listen-tcp=", 0) == 0) { - if (listen_tcp) { - std::cerr << "socketd: --listen-tcp specified more than once\n"; - return 2; - } - - listen_tcp = true; - - const std::string value = arg.substr(std::string("--listen-tcp=").size()); - - std::string parse_error; - if (!parse_tcp_listen_endpoint(value, tcp_config, parse_error)) { - std::cerr << "socketd: " << parse_error << '\n'; - return 2; - } - - continue; - } - - std::cerr << "socketd: unknown argument: " << arg << '\n'; - print_usage(argv[0]); - return 2; - } - - if (!listen_tcp) { - std::cerr << "socketd: no listener configured\n"; - print_usage(argv[0]); - return 2; - } - - std::string error; - if (!check_backend(error)) { - /* - * Keep the HTTP listener available even when the privileged backend is - * absent. This lets the socketd HTTP/static WebUI surface be tested in - * isolation. Backend-dependent Docker API routes still fail normally when - * they attempt their per-request BackendClient RPC. - */ - std::cerr << "socketd: warning: " << error << '\n'; - std::cerr << "socketd: warning: continuing without backend handshake\n"; - } - - ApiServer server(tcp_config); - if (!server.run(error)) { - std::cerr << "socketd: server failed: " << error << '\n'; - return 1; - } - - return 0; -} diff --git a/src/socketd/snapshot_lists.cpp b/src/socketd/snapshot_lists.cpp deleted file mode 100644 index eb7fd4e4..00000000 --- a/src/socketd/snapshot_lists.cpp +++ /dev/null @@ -1,207 +0,0 @@ -#include "snapshot_lists.h" - -#include "backend_client.h" - -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -void append_image_json(std::string& out, - const ImageRecordResult& image) { - out += "{"; - - out += "\"Containers\":"; - out += image.is_running ? "1" : "0"; - out += ","; - - out += "\"Created\":"; - out += std::to_string(image.created_at > 0 ? image.created_at : 0); - out += ","; - - out += "\"Id\":\""; - out += json_escape(image.uuid); - out += "\","; - - out += "\"Labels\":{},"; - - out += "\"ParentId\":\"\","; - - out += "\"RepoDigests\":[],"; - - out += "\"RepoTags\":[\""; - out += json_escape(image.name); - out += ":latest\"],"; - - /* - * CONCERN(socketd-images): - * This public image summary intentionally follows the current compatibility - * plan's minimal pseudo-image projection. If a later Docker/Portainer - * compatibility pass requires additional image-summary fields, extend this - * socketd JSON seam rather than changing the core-side image record. - */ - out += "\"Size\":0,"; - out += "\"VirtualSize\":0"; - - out += "}"; -} - -} // namespace - -bool request_image_list_json_from_core(std::string& json_out, - std::string& error) { - error.clear(); - - BackendClient backend; - std::vector images; - - if (!backend.list_images(images, error)) { - return false; - } - - std::string body; - body.reserve(256 + images.size() * 256); - - body += "["; - - for (std::size_t i = 0; i < images.size(); ++i) { - if (i != 0) { - body += ","; - } - - append_image_json(body, images[i]); - } - - body += "]\n"; - - json_out = std::move(body); - return true; -} - -bool request_volume_list_json_from_core(std::string& json_out, - std::string& error) { - error.clear(); - - /* - * Docker-managed named volumes have no direct Droidspaces analogue. - * Keep the endpoint structurally valid while intentionally exposing none. - */ - json_out = "{\"Volumes\":[],\"Warnings\":[]}\n"; - return true; -} - -bool request_network_list_json_from_core(std::string& json_out, - std::string& error) { - error.clear(); - - /* - * Fixed public compatibility facade for Droidspaces networking modes. - * These are socketd-owned Docker-shaped descriptors; no mutable Docker - * network object is being introduced into the core runtime. - */ - json_out = - "[" - "{" - "\"Name\":\"droidspaces-bridge\"," - "\"Id\":\"droidspaces-bridge\"," - "\"Created\":\"1970-01-01T00:00:00Z\"," - "\"Scope\":\"local\"," - "\"Driver\":\"bridge\"," - "\"EnableIPv6\":false," - "\"IPAM\":{\"Driver\":\"default\",\"Options\":{},\"Config\":[]}," - "\"Internal\":false," - "\"Attachable\":false," - "\"Ingress\":false," - "\"ConfigFrom\":{\"Network\":\"\"}," - "\"ConfigOnly\":false," - "\"Containers\":{}," - "\"Options\":{}," - "\"Labels\":{}" - "}," - "{" - "\"Name\":\"host\"," - "\"Id\":\"host\"," - "\"Created\":\"1970-01-01T00:00:00Z\"," - "\"Scope\":\"local\"," - "\"Driver\":\"host\"," - "\"EnableIPv6\":false," - "\"IPAM\":{\"Driver\":\"default\",\"Options\":{},\"Config\":[]}," - "\"Internal\":false," - "\"Attachable\":false," - "\"Ingress\":false," - "\"ConfigFrom\":{\"Network\":\"\"}," - "\"ConfigOnly\":false," - "\"Containers\":{}," - "\"Options\":{}," - "\"Labels\":{}" - "}," - "{" - "\"Name\":\"none\"," - "\"Id\":\"none\"," - "\"Created\":\"1970-01-01T00:00:00Z\"," - "\"Scope\":\"local\"," - "\"Driver\":\"null\"," - "\"EnableIPv6\":false," - "\"IPAM\":{\"Driver\":\"default\",\"Options\":{},\"Config\":[]}," - "\"Internal\":false," - "\"Attachable\":false," - "\"Ingress\":false," - "\"ConfigFrom\":{\"Network\":\"\"}," - "\"ConfigOnly\":false," - "\"Containers\":{}," - "\"Options\":{}," - "\"Labels\":{}" - "}" - "]\n"; - - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/snapshot_lists.h b/src/socketd/snapshot_lists.h deleted file mode 100644 index d3e3ba80..00000000 --- a/src/socketd/snapshot_lists.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include - -namespace droidspaces::socketd { - -/* - * Extension-owned seams for Portainer snapshot inventory probes. - * - * Images are rendered from typed backend pseudo-image records. Volumes and - * networks remain socketd-owned compatibility projections: Droidspaces has no - * Docker-managed named-volume model, while the network list is a fixed public - * facade over Droidspaces networking modes. - */ - -bool request_image_list_json_from_core(std::string &json_out, - std::string &error); - -bool request_volume_list_json_from_core(std::string &json_out, - std::string &error); - -bool request_network_list_json_from_core(std::string &json_out, - std::string &error); - -} // namespace droidspaces::socketd diff --git a/www/html/assets/droidspaces-mark.svg b/www/html/assets/droidspaces-mark.svg deleted file mode 100644 index dcaf41f7..00000000 --- a/www/html/assets/droidspaces-mark.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/www/html/index.html b/www/html/index.html deleted file mode 100644 index 0e4a3c34..00000000 --- a/www/html/index.html +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - Droidspaces WebUI - - - - - - - - -
-
-
-
-
checking local daemon
-

Droidspaces
WebUI.

-

- A local browser control surface for the Docker-compatible Droidspaces socket API. Served by socketd, controlled by relative API paths, no CORS detour. -

-
- - -
-
-
containers
-
running
-
stopped
-
-
- - -
-
- -
- -
-
-
-
- -

Containers

-

Start, stop, restart, and inspect Droidspaces containers through the local socketd API.

-
-
- -
-
- - -
- -
-
- -
- - - -
- -
-
- -

API health

-

These checks use only relative paths, so they exercise the same-origin WebUI model served directly by socketd.

-
-
-

Daemon

-
    -
  • Waiting for first check…
  • -
-
-
-

Manual endpoint

-
- - -
-
No manual request yet.
-
-
-
-
-
- -
-

Droidspaces WebUI · served locally by droidspaces-socketd

-
- - - - diff --git a/www/html/webui.css b/www/html/webui.css deleted file mode 100644 index 8bc7e025..00000000 --- a/www/html/webui.css +++ /dev/null @@ -1,229 +0,0 @@ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; - -webkit-tap-highlight-color: transparent !important; - outline: none !important; -} - -a, button, input, select, textarea { - -webkit-tap-highlight-color: rgba(0,0,0,0) !important; - -webkit-tap-highlight-color: transparent !important; - outline: none !important; -} - -:root { - --bg: #0a0c0f; - --bg2: #0f1217; - --bg3: #151a22; - --border: #1e2530; - --border2: #2a3340; - --text: #e8edf5; - --muted: #6b7990; - --accent: #4f8ef7; - --accent2: #7eb3ff; - --android-color: rgb(52, 168, 83); - --green: #3ecf8e; - --red: #ff6b6b; - --yellow: #febc2e; - --mono: "Space Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; - --sans: "DM Sans", Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -@media (prefers-color-scheme: light) { - :root:not([data-theme="dark"]) { - --bg: #ffffff; - --bg2: #f9fafb; - --bg3: #f3f4f6; - --border: #e5e7eb; - --border2: #d1d5db; - --text: #111827; - --muted: #4b5563; - --accent: #2563eb; - --accent2: #1d4ed8; - } -} - -:root[data-theme="light"] { - --bg: #ffffff; - --bg2: #f9fafb; - --bg3: #f3f4f6; - --border: #e5e7eb; - --border2: #d1d5db; - --text: #111827; - --muted: #4b5563; - --accent: #2563eb; - --accent2: #1d4ed8; -} - -html { scroll-behavior: smooth; } - -body { - min-height: 100vh; - background: var(--bg); - color: var(--text); - font-family: var(--sans); - font-size: 15px; - line-height: 1.6; - -webkit-font-smoothing: antialiased; -} - -body::before { - content: ""; - position: fixed; - inset: 0; - background: - radial-gradient(ellipse 55% 40% at 20% 10%, rgba(79,142,247,0.08), transparent 60%), - radial-gradient(ellipse 35% 50% at 85% 18%, rgba(62,207,142,0.05), transparent 55%); - pointer-events: none; - z-index: -1; -} - -button, input { font: inherit; } -code, pre { font-family: var(--mono); } - -nav { - position: sticky; - top: 0; - z-index: 100; - padding: 0 2rem; - height: 56px; - background: rgba(10,12,15,0.86); - backdrop-filter: blur(12px); - border-bottom: 1px solid var(--border); - transition: background 0.3s, border-color 0.3s; -} -:root[data-theme="light"] nav { background: rgba(255,255,255,0.86); } -@media (prefers-color-scheme: light) { - :root:not([data-theme="dark"]) nav { background: rgba(255,255,255,0.86); } -} - -.container { max-width: 1100px; margin: 0 auto; width: 100%; } -.nav-inner { height: 100%; display: flex; align-items: center; justify-content: space-between; gap: 1rem; } -.nav-logo { display: flex; align-items: center; gap: 0.6rem; text-decoration: none; min-width: 0; } -.logo-icon { width: 20px; height: 20px; color: var(--accent2); flex-shrink: 0; } -.logo-text { font-family: var(--mono); font-size: 1.1rem; font-weight: 700; color: var(--accent2); letter-spacing: -0.02em; transform: translateY(-1px); white-space: nowrap; } -.nav-subtitle { font-family: var(--mono); font-size: 0.72rem; color: var(--muted); border-left: 1px solid var(--border2); padding-left: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; } -.nav-actions { display: flex; align-items: center; gap: 0.45rem; } -.nav-link { color: var(--muted); text-decoration: none; border-radius: 6px; padding: 0.35rem 0.75rem; font-size: 0.85rem; font-weight: 500; transition: background 0.15s, color 0.15s; } -.nav-link:hover { color: var(--text); background: var(--bg3); } -.theme-toggle { background: none; border: none; color: var(--muted); cursor: pointer; padding: 0.25rem; display: flex; align-items: center; justify-content: center; transition: color 0.15s; } -.theme-toggle:hover { color: var(--text); } -.theme-toggle svg { width: 18px; height: 18px; } - -.hero { padding: 5.25rem 2rem 3.75rem; position: relative; overflow: hidden; } -.webui-hero-inner { max-width: 1100px; display: grid; grid-template-columns: minmax(0, 0.95fr) minmax(420px, 1.05fr); gap: 4rem; align-items: center; } -.hero-badge { display: inline-flex; align-items: center; gap: 0.5rem; background: rgba(79,142,247,0.08); border: 1px solid rgba(79,142,247,0.2); border-radius: 100px; padding: 0.3rem 0.75rem; font-family: var(--mono); font-size: 0.72rem; color: var(--accent2); margin-bottom: 1.5rem; letter-spacing: 0.04em; } -.hero-badge span { width: 6px; height: 6px; background: var(--yellow); border-radius: 50%; animation: pulse 2s infinite; } -.hero-badge.online span { background: var(--green); } -.hero-badge.offline span { background: var(--red); } -@keyframes pulse { 0%,100%{opacity:1}50%{opacity:0.35} } - -h1 { font-family: var(--mono); font-size: clamp(2.2rem, 4vw, 3.2rem); font-weight: 700; line-height: 1.1; letter-spacing: -0.04em; color: var(--text); margin-bottom: 1.25rem; } -h1 em { font-style: normal; color: var(--android-color); } -h2 { font-family: var(--mono); font-size: clamp(1.5rem, 2.5vw, 2rem); font-weight: 700; letter-spacing: -0.03em; color: var(--text); margin-bottom: 1rem; } -h3 { font-family: var(--mono); } -.hero-desc { color: var(--muted); font-size: 1rem; font-weight: 300; line-height: 1.7; margin-bottom: 2rem; max-width: 520px; } -.hero-cta { display: flex; gap: 0.75rem; flex-wrap: wrap; } -.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.65rem 1.4rem; border-radius: 8px; font-family: var(--sans); font-size: 0.875rem; font-weight: 600; text-decoration: none; transition: all 0.15s; cursor: pointer; border: none; } -.btn-primary { background: var(--accent); color: #fff; } -.btn-primary:hover { background: #6ba4ff; transform: translateY(-1px); } -.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border2); } -.btn-ghost:hover { color: var(--text); border-color: var(--muted); } -.btn-danger { background: transparent; color: var(--red); border: 1px solid rgba(255,107,107,0.35); } -.btn-danger:hover { border-color: var(--red); background: rgba(255,107,107,0.06); } -.btn:disabled { opacity: 0.55; cursor: not-allowed; transform: none; } - -.hero-stats { display: flex; gap: 2rem; margin-top: 2.5rem; } -.stat-val { font-family: var(--mono); font-size: 1.4rem; font-weight: 700; color: var(--text); } -.stat-label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.1rem; } - -.terminal { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; font-family: var(--mono); font-size: 0.78rem; } -.terminal-bar { background: var(--bg3); padding: 0.6rem 1rem; display: flex; align-items: center; gap: 0.5rem; border-bottom: 1px solid var(--border); } -.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } -.dot-r { background: #ff5f57; } -.dot-y { background: #febc2e; } -.dot-g { background: #28c840; } -.terminal-title { font-size: 0.7rem; color: var(--muted); margin: 0 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.terminal-body { padding: 1.25rem; line-height: 1.8; overflow-x: auto; } -.t-dim { color: var(--muted); } -.t-green { color: var(--green); } -.t-blue { color: var(--accent2); } -.t-red { color: var(--red); } -.t-prompt { color: var(--accent); } -.t-white { color: var(--text); } - -.section { padding: 4rem 2rem; } -.section-label { font-family: var(--mono); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); margin-bottom: 0.75rem; } -.section-desc { color: var(--muted); max-width: 620px; line-height: 1.7; margin-bottom: 2rem; } -.section-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.75rem; } -.section-tools { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.4rem; } -.toggle-line { color: var(--muted); font-size: 0.82rem; display: inline-flex; align-items: center; gap: 0.45rem; white-space: nowrap; } -.toggle-line input { accent-color: var(--accent); } -.divider { height: 1px; background: var(--border); max-width: 1100px; margin: 0 auto; } - -.alert { background: rgba(255,107,107,0.08); color: var(--red); border: 1px solid rgba(255,107,107,0.22); border-radius: 10px; padding: 0.85rem 1rem; margin-bottom: 1rem; font-size: 0.86rem; } -.alert.ok { background: rgba(62,207,142,0.08); color: var(--green); border-color: rgba(62,207,142,0.22); } - -.containers-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } -.container-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 1.35rem; transition: transform 0.15s, border-color 0.15s, background 0.15s; } -.container-card:hover { background: var(--bg); border-color: var(--border2); transform: translateY(-1px); } -.card-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1rem; } -.container-name { font-family: var(--mono); color: var(--text); font-weight: 700; letter-spacing: -0.01em; word-break: break-word; } -.container-id { color: var(--muted); font-family: var(--mono); font-size: 0.72rem; margin-top: 0.15rem; } -.status-pill { display: inline-flex; align-items: center; gap: 0.4rem; border: 1px solid var(--border2); border-radius: 999px; padding: 0.18rem 0.55rem; color: var(--muted); font-family: var(--mono); font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; } -.status-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--muted); } -.status-pill.running { color: var(--green); border-color: rgba(62,207,142,0.28); } -.status-pill.running::before { background: var(--green); } -.status-pill.exited, .status-pill.stopped { color: var(--red); border-color: rgba(255,107,107,0.28); } -.status-pill.exited::before, .status-pill.stopped::before { background: var(--red); } -.card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 0.65rem; margin: 1rem 0 1.15rem; } -.meta-item { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem; min-width: 0; } -.meta-label { color: var(--muted); font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 0.15rem; } -.meta-value { color: var(--text); font-family: var(--mono); font-size: 0.76rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.card-actions { display: flex; flex-wrap: wrap; gap: 0.55rem; } -.card-actions .btn { padding: 0.5rem 0.9rem; font-size: 0.8rem; } - -.empty-state { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 2rem; text-align: center; color: var(--muted); } -.empty-state h3 { color: var(--text); margin: 0.5rem 0; } -.empty-icon { color: var(--accent2); font-size: 2rem; font-family: var(--mono); } - -.req-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } -.req-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 1.5rem; } -.req-card h3 { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent2); margin-bottom: 0.75rem; } -.req-card ul { list-style: none; } -.req-card li { font-size: 0.83rem; color: var(--muted); padding: 0.3rem 0; padding-left: 1rem; position: relative; } -.req-card li::before { content: "-"; position: absolute; left: 0; color: var(--border2); } - -.details-grid { display: grid; grid-template-columns: 0.75fr 1.25fr; gap: 1rem; align-items: start; } -.detail-list { display: grid; gap: 0.65rem; } -.detail-list div { display: grid; grid-template-columns: 9rem 1fr; gap: 0.75rem; border-bottom: 1px solid var(--border); padding-bottom: 0.55rem; } -.detail-list div:last-child { border-bottom: none; padding-bottom: 0; } -.detail-list dt { color: var(--muted); font-size: 0.76rem; text-transform: uppercase; letter-spacing: 0.06em; } -.detail-list dd { color: var(--text); font-family: var(--mono); font-size: 0.78rem; word-break: break-word; } -.json-view { color: var(--muted); padding: 1rem; max-height: 420px; overflow: auto; font-size: 0.73rem; line-height: 1.55; } - -.manual-form { display: flex; gap: 0.5rem; margin-bottom: 0.9rem; } -.manual-form input { flex: 1; min-width: 0; background: var(--bg3); color: var(--text); border: 1px solid var(--border2); border-radius: 8px; padding: 0.55rem 0.7rem; font-family: var(--mono); font-size: 0.8rem; } -.manual-output { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 0.8rem; color: var(--muted); min-height: 7rem; max-height: 17rem; overflow: auto; font-size: 0.73rem; } - -footer { border-top: 1px solid var(--border); padding: 2rem; text-align: center; } -footer p { font-size: 0.8rem; color: var(--muted); } - -@media (max-width: 860px) { - .webui-hero-inner { grid-template-columns: 1fr; gap: 3rem; } - .containers-grid, .req-grid, .details-grid { grid-template-columns: 1fr; } -} - -@media (max-width: 640px) { - nav { padding: 0 1rem; } - .nav-link-wide, .nav-subtitle { display: none; } - .hero { padding: 3.25rem 1rem 2.5rem; } - .section { padding: 3rem 1rem; } - .webui-terminal { display: none; } - .hero-stats { gap: 1.35rem; } - .section-head { flex-direction: column; } - .card-meta { grid-template-columns: 1fr; } - .manual-form { flex-direction: column; } - .detail-list div { grid-template-columns: 1fr; gap: 0.1rem; } -} diff --git a/www/html/webui.js b/www/html/webui.js deleted file mode 100644 index 7ffb1c79..00000000 --- a/www/html/webui.js +++ /dev/null @@ -1,420 +0,0 @@ -(function () { - "use strict"; - - const state = { - containers: [], - version: null, - info: null, - busyRefs: new Set(), - lastError: "" - }; - - const $ = (id) => document.getElementById(id); - - const els = { - badge: $("daemon-badge"), - badgeText: $("daemon-badge-text"), - consolePing: $("console-ping"), - consoleVersion: $("console-version"), - consoleContainers: $("console-containers"), - statTotal: $("stat-total"), - statRunning: $("stat-running"), - statStopped: $("stat-stopped"), - refreshButton: $("refresh-button"), - recheckButton: $("recheck-button"), - showAll: $("show-all"), - grid: $("containers-grid"), - empty: $("empty-state"), - alert: $("global-alert"), - details: $("details"), - detailsTitle: $("details-title"), - detailsSummary: $("details-summary"), - detailsJson: $("details-json"), - diagnostics: $("daemon-diagnostics"), - manualForm: $("manual-form"), - manualPath: $("manual-path"), - manualOutput: $("manual-output"), - themeToggle: $("theme-toggle") - }; - - function setText(el, value) { - if (el) el.textContent = value; - } - - function escapeHtml(value) { - return String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - function shortId(value) { - const text = String(value || ""); - return text.length > 12 ? text.slice(0, 12) : text || "—"; - } - - function normalizeName(container) { - if (!container) return "unknown"; - if (Array.isArray(container.Names) && container.Names.length > 0) { - return String(container.Names[0]).replace(/^\/+/, "") || "unknown"; - } - if (typeof container.Names === "string") return container.Names.replace(/^\/+/, "") || "unknown"; - if (container.Name) return String(container.Name).replace(/^\/+/, "") || "unknown"; - if (container.Id) return shortId(container.Id); - return "unknown"; - } - - function containerRef(container) { - return normalizeName(container) || container.Id || ""; - } - - function normalizeState(container) { - const raw = String(container.State || container.Status || "unknown").toLowerCase(); - if (raw.includes("running") || raw === "running") return "running"; - if (raw.includes("exit") || raw.includes("stop") || raw === "created") return "stopped"; - return raw.replace(/[^a-z0-9_-]+/g, "-") || "unknown"; - } - - function normalizeImage(container) { - return container.Image || container.ImageID || container.ImageRef || "—"; - } - - function normalizeStatus(container) { - return container.Status || container.State || "unknown"; - } - - function summarizePorts(container) { - const ports = Array.isArray(container.Ports) ? container.Ports : []; - if (!ports.length) return "—"; - return ports.slice(0, 3).map((port) => { - const type = port.Type || port.Proto || "tcp"; - const privatePort = port.PrivatePort || port.ContainerPort || "?"; - const publicPort = port.PublicPort || port.HostPort; - if (publicPort) return `${publicPort}->${privatePort}/${type}`; - return `${privatePort}/${type}`; - }).join(", ") + (ports.length > 3 ? " …" : ""); - } - - function setDaemonStatus(kind, message) { - els.badge.classList.remove("online", "offline"); - if (kind) els.badge.classList.add(kind); - setText(els.badgeText, message); - } - - function setAlert(message, ok) { - if (!message) { - els.alert.hidden = true; - els.alert.textContent = ""; - els.alert.classList.remove("ok"); - return; - } - els.alert.hidden = false; - els.alert.textContent = message; - els.alert.classList.toggle("ok", Boolean(ok)); - } - - async function apiFetch(path, options) { - const response = await fetch(path, Object.assign({ cache: "no-store" }, options || {})); - const contentType = response.headers.get("content-type") || ""; - let body; - if (contentType.includes("application/json")) { - body = await response.json().catch(() => null); - } else { - body = await response.text().catch(() => ""); - } - - if (!response.ok && response.status !== 304) { - const message = body && typeof body === "object" && body.message - ? body.message - : `${response.status} ${response.statusText}`; - const err = new Error(message); - err.status = response.status; - err.body = body; - throw err; - } - - return { response, body }; - } - - async function checkDaemon() { - setDaemonStatus("", "checking local daemon"); - setText(els.consolePing, "pending"); - setText(els.consoleVersion, "pending"); - - try { - const ping = await apiFetch("/_ping"); - setText(els.consolePing, String(ping.body || "OK")); - - const version = await apiFetch("/version"); - state.version = version.body; - const versionText = version.body && version.body.Version - ? `${version.body.Version} · API ${version.body.ApiVersion || "?"}` - : "version response received"; - setText(els.consoleVersion, versionText); - setDaemonStatus("online", "local daemon online"); - return true; - } catch (err) { - state.lastError = err.message; - setText(els.consolePing, `failed: ${err.message}`); - setText(els.consoleVersion, "skipped"); - setDaemonStatus("offline", "local daemon unavailable"); - return false; - } - } - - async function loadInfo() { - try { - const info = await apiFetch("/info"); - state.info = info.body; - } catch (err) { - state.info = null; - } - } - - async function loadContainers() { - const all = els.showAll.checked ? "1" : "0"; - const result = await apiFetch(`/containers/json?all=${all}`); - state.containers = Array.isArray(result.body) ? result.body : []; - setText(els.consoleContainers, `${state.containers.length} container(s)`); - } - - function renderStats() { - const total = state.containers.length; - const running = state.containers.filter((c) => normalizeState(c) === "running").length; - const stopped = total - running; - setText(els.statTotal, String(total)); - setText(els.statRunning, String(running)); - setText(els.statStopped, String(stopped)); - } - - function actionButton(action, label, container, cssClass) { - const ref = containerRef(container); - const disabled = state.busyRefs.has(`${ref}:${action}`) ? " disabled" : ""; - const cls = cssClass ? ` ${cssClass}` : ""; - return ``; - } - - function renderContainerCard(container) { - const name = normalizeName(container); - const stateClass = normalizeState(container); - const running = stateClass === "running"; - const id = shortId(container.Id || container.ID || container.Name || name); - const image = normalizeImage(container); - const status = normalizeStatus(container); - const ports = summarizePorts(container); - - return ` -
-
-
-
${escapeHtml(name)}
-
${escapeHtml(id)}
-
-
${escapeHtml(stateClass)}
-
-
-
Image
${escapeHtml(image)}
-
Status
${escapeHtml(status)}
-
Ports
${escapeHtml(ports)}
-
Created
${escapeHtml(formatCreated(container.Created))}
-
-
- ${running ? actionButton("stop", "Stop", container, "btn-danger") : actionButton("start", "Start", container, "btn-primary")} - ${actionButton("restart", "Restart", container, "btn-ghost")} - ${actionButton("inspect", "Inspect", container, "btn-ghost")} -
-
`; - } - - function formatCreated(value) { - if (!value) return "—"; - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric <= 0) return "—"; - const date = new Date(numeric * 1000); - if (Number.isNaN(date.getTime())) return "—"; - return date.toLocaleString(); - } - - function renderContainers() { - els.grid.innerHTML = state.containers.map(renderContainerCard).join(""); - els.empty.hidden = state.containers.length !== 0; - renderStats(); - } - - function renderDiagnostics(online) { - const version = state.version || {}; - const info = state.info || {}; - const items = []; - items.push(`API status: ${online ? "online" : "offline"}`); - if (version.Version) items.push(`Engine version: ${version.Version}`); - if (version.ApiVersion) items.push(`API version: ${version.ApiVersion}`); - if (version.Arch) items.push(`Architecture: ${version.Arch}`); - if (info.Containers !== undefined) items.push(`Info containers: ${info.Containers}`); - if (info.ContainersRunning !== undefined) items.push(`Info running: ${info.ContainersRunning}`); - if (!items.length) items.push("No diagnostic information available."); - els.diagnostics.innerHTML = items.map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); - } - - async function refreshDashboard() { - setAlert(""); - els.refreshButton.disabled = true; - try { - const online = await checkDaemon(); - if (online) { - await loadInfo(); - await loadContainers(); - renderContainers(); - renderDiagnostics(true); - } else { - renderDiagnostics(false); - } - } catch (err) { - setText(els.consoleContainers, `failed: ${err.message}`); - setAlert(`Dashboard refresh failed: ${err.message}`); - renderDiagnostics(false); - } finally { - els.refreshButton.disabled = false; - } - } - - async function lifecycleAction(ref, action) { - const key = `${ref}:${action}`; - state.busyRefs.add(key); - renderContainers(); - try { - await apiFetch(`/containers/${encodeURIComponent(ref)}/${action}`, { method: "POST" }); - setAlert(`${action} sent to ${ref}`, true); - await loadContainers(); - renderContainers(); - } catch (err) { - if (err.status === 304) { - setAlert(`${ref} already in requested state`, true); - await loadContainers(); - renderContainers(); - } else { - setAlert(`${action} failed for ${ref}: ${err.message}`); - } - } finally { - state.busyRefs.delete(key); - renderContainers(); - } - } - - function detailValue(value) { - if (value === undefined || value === null || value === "") return "—"; - if (typeof value === "boolean") return value ? "true" : "false"; - return String(value); - } - - function summaryRow(label, value) { - return `
    ${escapeHtml(label)}
    ${escapeHtml(detailValue(value))}
    `; - } - - async function inspectContainer(ref) { - setAlert(""); - try { - const result = await apiFetch(`/containers/${encodeURIComponent(ref)}/json`); - const data = result.body || {}; - els.details.hidden = false; - setText(els.detailsTitle, `${ref} details`); - const stateData = data.State || {}; - const config = data.Config || {}; - const network = data.NetworkSettings || {}; - els.detailsSummary.innerHTML = [ - summaryRow("Name", (data.Name || ref).replace(/^\/+/, "")), - summaryRow("ID", shortId(data.Id || data.ID || ref)), - summaryRow("Image", config.Image || data.Image || data.ImageName), - summaryRow("Running", stateData.Running), - summaryRow("PID", stateData.Pid || data.Pid), - summaryRow("Status", stateData.Status || data.Status), - summaryRow("Hostname", config.Hostname || data.Hostname), - summaryRow("IP address", network.IPAddress || data.IPAddress) - ].join(""); - setText(els.detailsJson, JSON.stringify(data, null, 2)); - location.hash = "details"; - } catch (err) { - setAlert(`inspect failed for ${ref}: ${err.message}`); - } - } - - async function manualGet(path) { - let requestPath = path.trim(); - if (!requestPath.startsWith("/")) requestPath = `/${requestPath}`; - els.manualOutput.textContent = "Loading…"; - try { - const result = await apiFetch(requestPath); - if (typeof result.body === "string") { - els.manualOutput.textContent = result.body || ""; - } else { - els.manualOutput.textContent = JSON.stringify(result.body, null, 2); - } - } catch (err) { - els.manualOutput.textContent = `ERROR: ${err.message}`; - } - } - - function setupThemeToggle() { - const themeToggle = els.themeToggle; - const sunIcon = themeToggle.querySelector(".sun-icon"); - const moonIcon = themeToggle.querySelector(".moon-icon"); - const mediaQuery = window.matchMedia("(prefers-color-scheme: light)"); - - function updateIcons(theme) { - if (theme === "light") { - sunIcon.style.display = "none"; - moonIcon.style.display = "block"; - } else { - sunIcon.style.display = "block"; - moonIcon.style.display = "none"; - } - } - - function getEffectiveTheme() { - const saved = localStorage.getItem("theme"); - if (saved) return saved; - return mediaQuery.matches ? "light" : "dark"; - } - - function applyTheme(theme) { - document.documentElement.setAttribute("data-theme", theme); - updateIcons(theme); - } - - applyTheme(getEffectiveTheme()); - mediaQuery.addEventListener("change", (event) => { - if (!localStorage.getItem("theme")) applyTheme(event.matches ? "light" : "dark"); - }); - themeToggle.addEventListener("click", () => { - const next = getEffectiveTheme() === "dark" ? "light" : "dark"; - localStorage.setItem("theme", next); - applyTheme(next); - }); - } - - function bindEvents() { - els.refreshButton.addEventListener("click", refreshDashboard); - els.recheckButton.addEventListener("click", checkDaemon); - els.showAll.addEventListener("change", refreshDashboard); - els.grid.addEventListener("click", (event) => { - const button = event.target.closest("button[data-action]"); - if (!button) return; - const action = button.dataset.action; - const ref = button.dataset.ref; - if (!ref) return; - if (action === "inspect") inspectContainer(ref); - else lifecycleAction(ref, action); - }); - els.manualForm.addEventListener("submit", (event) => { - event.preventDefault(); - manualGet(els.manualPath.value); - }); - } - - document.addEventListener("DOMContentLoaded", () => { - setupThemeToggle(); - bindEvents(); - refreshDashboard(); - }); -})(); From d8a53f9eafc97af56dbcb6b5ad822b46ec03680c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 10 Jun 2026 13:50:36 +0530 Subject: [PATCH 032/211] docs: unify GPU, audio, and DE autoboot into Graphics-and-Audio guide Rename Documentation/GPU-Acceleration.md to Graphics-and-Audio.md (both en and zh-CN) and expand it into a single unified reference covering: - Android display & GPU (llvmpipe, VirGL, Turnip) - Android sound via PulseAudio (new section) - Desktop environment auto-boot - Linux desktop GPU passthrough Signed-off-by: ravindu644 --- Documentation/Features.md | 2 +- Documentation/GPU-Acceleration.md | 195 ---------- Documentation/Graphics-and-Audio.md | 396 ++++++++++++++++++++ Documentation/Installation-Android.md | 2 +- Documentation/zh-CN/Features.md | 2 +- Documentation/zh-CN/GPU-Acceleration.md | 194 ---------- Documentation/zh-CN/Graphics-and-Audio.md | 396 ++++++++++++++++++++ Documentation/zh-CN/Installation-Android.md | 2 +- README.md | 21 +- README_CN.md | 21 +- 10 files changed, 832 insertions(+), 399 deletions(-) delete mode 100644 Documentation/GPU-Acceleration.md create mode 100644 Documentation/Graphics-and-Audio.md delete mode 100644 Documentation/zh-CN/GPU-Acceleration.md create mode 100644 Documentation/zh-CN/Graphics-and-Audio.md diff --git a/Documentation/Features.md b/Documentation/Features.md index d166cea5..ad4426cd 100644 --- a/Documentation/Features.md +++ b/Documentation/Features.md @@ -187,7 +187,7 @@ For GUI application support, Droidspaces automatically bind-mounts the X11 socke > X11 support can be enabled independently using the `--termux-x11` (`-X`) flag. This is the recommended way to use GUI applications on Android if you do not need full GPU/hardware access, as it preserves a higher level of isolation. -After starting the container, set `DISPLAY=:0` inside the container to use the X11 display. +Droidspaces automatically injects `DISPLAY=:5` and (if VirGL is enabled) `GALLIUM_DRIVER=virpipe` into the container environment via `/run/droidspaces.env`, symlinked from `/etc/profile.d/droidspaces_env.sh`. Shells like `bash` and `sh` source this automatically. If you use `zsh`, `fish`, or another non-login shell, source it manually: `source /run/droidspaces.env`. ### Supported GPU Families diff --git a/Documentation/GPU-Acceleration.md b/Documentation/GPU-Acceleration.md deleted file mode 100644 index 86109a49..00000000 --- a/Documentation/GPU-Acceleration.md +++ /dev/null @@ -1,195 +0,0 @@ - - -# Droidspaces GPU Acceleration Guide - -This guide provides step-by-step instructions for enabling GPU acceleration in your Droidspaces containers. Whether you are running on an Android device or a Linux desktop, Droidspaces offers multiple ways to leverage hardware acceleration for a smooth graphical experience. - -### Quick Navigation - -- [**Android Devices**](#android) - - [01. Termux-X11 + llvmpipe (Software Rendering)](#termux-x11) - - [02. Termux-X11 + VirGL (Non-Qualcomm GPUs)](#virgl) - - [03. Turnip (Native Qualcomm/Adreno)](#turnip) -- [**Linux Desktop (AMD/Intel)**](#linux) - ---- - - - -## Android - -Hardware acceleration on Android is achieved by bridging the container's graphics stack with a host-side X server (Termux-X11). Droidspaces handles the complex mount management and security contexts required to make this seamless. - -> [!TIP] -> -> If you want to enjoy an out-of-the-box XFCE desktop environment experience, you can download our pre-configured XFCE tarballs from the [Rootfs repository](Usage-Android-App.md#rootfs-repository) or from the official [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases). - - - -### 01. Termux-X11 + llvmpipe - -This method uses **software rendering** via `llvmpipe`. While it doesn't provide full hardware acceleration, it is the most stable way to run GUI applications when a compatible GPU driver isn't available. - -#### The "Unified Tmpfs Bridge" -When you enable the **Termux X11** toggle in the Droidspaces app, the following sequence occurs: - -1. **Host-side Preparation**: Droidspaces creates a `tmpfs` mount on top of Termux's `/data/data/com.termux/files/usr/tmp` in the host's mount namespace. - -2. **Bypassing FBE Encryption**: While a direct bind-mount of `/data/data/com.termux/files/usr/tmp` to the container is possible, it frequently breaks applications like `apt` or any tool performing heavy I/O in `/tmp`. This happens because Termux's data directory is protected by Android's File-Based Encryption (FBE), leading to "Required key not available" (ENOKEY) errors. By bridging the path via `tmpfs`, X11 sockets and temporary files become fully readable and writable by the container. - -3. **Bind Mounting**: This "Unified Tmpfs Bridge" is then bind-mounted to the container's `/tmp` directory, enabling seamless communication between the container and the Termux-X11 app. - -#### Setup Requirements - -- **Termux**: `pkg install x11-repo && pkg install termux-x11` -- **Container**: `sudo apt install mesa-utils` (for testing with `glxgears`) - -#### Implementation Steps -1. **Configure Container**: In the Droidspaces app, navigate to your container's configuration. - -2. **Enable X11**: Toggle **Termux-X11** to `ON` (**Hardware Access** is not required for software rendering). - -3. **Environment**: Add `DISPLAY=:0` to the **Environment Variables** section and save. - -4. **Start Container**: Launch your container. - -5. **Launch X Server**: Open the Termux app and run: - - ```bash - termux-x11 :0 - ``` - -6. **Verify**: Run `glxgears` inside the container terminal. The output will render in the Termux-X11 app. - -7. **Start Desktop Environment**: To launch the full XFCE desktop (if installed), run: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - ---- - - - -### 02. Termux-X11 + VirGL - -This method provides **GPU acceleration for non-Qualcomm devices (Mali/PowerVR)** via a `virglrenderer` bridge. It translates OpenGL calls from the container into commands that the host Android OS can execute. - -#### Setup Requirements - -- **Termux**: `pkg install x11-repo && pkg install termux-x11 virglrenderer-android` -- **Container**: `sudo apt install mesa-utils` (for testing with `glxgears`) - -#### Implementation Steps - -1. **Container Configuration**: Enable **Termux-X11** in the Droidspaces container settings. Then, add the following to the **Environment Variables** section: - ```bash - DISPLAY=:0 - GALLIUM_DRIVER=virpipe - ``` - -2. **Start Container**: Launch your container. - -3. **Start VirGL Server**: Open Termux and run the server in the background: - ```bash - virgl_test_server_android & - ``` - -4. **Start X Server**: In Termux, run: - ```bash - termux-x11 :0 - ``` - -5. **Verify Acceleration**: Run `glxinfo -B` and look for "VirGL" in the renderer string. - -6. **Start Desktop Environment**: To launch the full XFCE desktop (if installed), run: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - -> [!TIP] -> -> **If the renderer fails to initialize,** try starting the VirGL server with the Vulkan backend: -> -> `virgl_test_server_android --angle-vulkan &` - ---- - - - -### 03. Turnip (Native Qualcomm/Adreno) - -For Qualcomm Adreno GPUs, Droidspaces supports **native hardware acceleration** using the Turnip driver. This bypasses the need for `virgl` and provides near-native performance. - -#### Requirements - -- **Custom Mesa driver**: Installed following the instructions from the [Mesa for Android Container repository](https://github.com/lfdevs/mesa-for-android-container). -- **Termux**: `pkg install x11-repo && pkg install termux-x11` - -#### Implementation Steps - -1. **Install the custom Mesa driver** from the [Mesa for Android Container repository](https://github.com/lfdevs/mesa-for-android-container). - -2. **Enable GPU Access**: In the container settings, enable **GPU Access** and **Termux X11**. - -3. **Set Display**: Add `DISPLAY=:0` to your environment variables. - -4. **Launch Sequence**: - - Start the container via Droidspaces. - - Open Termux and run `termux-x11 :0` - -5. **Permission Management (Non-Root Users)**: - If you are using a non-root user, you must grant them access to the GPU device nodes: - - ```bash - sudo usermod -aG droidspaces-gpu - ``` - -6. **Start Desktop Environment**: To launch the full XFCE desktop (if installed), run: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - -> [!TIP] -> -> **If you encounter any problems related to DRI3,** try editing `/data/adb/modules/droidspaces/sepolicy.rule` and uncommenting the line: -> -> `allow untrusted_app_27 droidspacesd fd use` - ---- - - - -## Linux Desktop (AMD/Intel) - -On Linux-based hosts, GPU acceleration works natively with zero additional configuration within Droidspaces. - -#### Requirements -- An active X11 or Wayland session on your host. -- Functional GPU drivers (Mesa/Intel/AMD). - -#### Implementation Steps - -1. **Enable Hardware Access**: Ensure the **Hardware Access** toggle is enabled in your container configuration (or use the `--hw-access` CLI flag). - -2. **Xhost Permission**: On your host machine, allow the container to connect to your X server: - - ```bash - xhost +local: - ``` - -3. **Set Display Variable**: Add the host's `DISPLAY` number to the container's environment (usually `:0`): - - ```bash - echo "DISPLAY=:0" >> /etc/environment - ``` - -4. **Run Applications**: GUI applications launched from the container will render natively with full hardware acceleration. diff --git a/Documentation/Graphics-and-Audio.md b/Documentation/Graphics-and-Audio.md new file mode 100644 index 00000000..0db4dd3b --- /dev/null +++ b/Documentation/Graphics-and-Audio.md @@ -0,0 +1,396 @@ + + +# Droidspaces Display, Audio & Desktop Guide + +This guide covers display setup, GPU acceleration, sound (PulseAudio), and desktop environment auto-boot for Droidspaces containers. On Android, as of **v6.3.0**, the X server, VirGL server, and PulseAudio daemon all launch automatically when a container starts - no manual Termux commands required. + +> [!IMPORTANT] +> +> **Droidspaces does not ship PulseAudio, Termux:X11, or virglrenderer-android.** These are upstream Termux packages installed by the setup script. Droidspaces only manages their lifecycle (launching, socket bridging, environment injection). If you encounter issues with audio quality, device compatibility, crashes, or rendering problems with any of these packages, report them to the [Termux packages](https://github.com/termux/termux-packages) project, not to Droidspaces. + +### Quick Navigation + +- [**Universal Requirements (Android)**](#requirements) +- [**Android Display & GPU**](#android) + - [01. Termux-X11 + llvmpipe (Software Rendering)](#termux-x11) + - [02. Termux-X11 + VirGL (Non-Qualcomm GPUs)](#virgl) + - [03. Turnip (Native Qualcomm/Adreno)](#turnip) +- [**Android Sound (PulseAudio)**](#pulseaudio) +- [**Desktop Environment Auto-Boot**](#de-autoboot) +- [**Linux Desktop (AMD/Intel)**](#linux) + +--- + + + +## Universal Requirements (Android) + +Before setting up any display, GPU, or audio feature, ensure all of the following are met: + +1. **Droidspaces v6.3.0 or higher** - after updating, a **full reboot is mandatory** to apply the updated SELinux rules. + +2. **Both [Termux](https://github.com/termux/termux-app) and [Termux:X11](https://github.com/termux/termux-x11) apps must be installed** on your device. + +3. **Run the Droidspaces setup script inside Termux** (mandatory - installs Termux:X11, VirGL, PulseAudio, and patches the audio config): + + ```bash + curl -fsSL https://github.com/ravindu644/Droidspaces-OSS/raw/refs/heads/dev/scripts/setup-termux.sh | bash + ``` + +> [!IMPORTANT] +> +> This script only needs to be run once. It installs all required display and audio dependencies into Termux. + +--- + + + +## Android Display & GPU + +As of v6.3.0, Droidspaces automatically launches the Termux:X11 X server (and VirGL server, if configured) when a container starts. It also injects the required environment variables into the container via `/run/droidspaces.env`, which is symlinked from `/etc/profile.d/droidspaces_env.sh` for automatic sourcing. + +> [!TIP] +> +> **Using `zsh`, `fish`, or another non-login shell?** +> +> These shells may not source `/etc/profile.d` automatically. Run this manually inside your container after booting: `source /run/droidspaces.env` +> +> This sets `DISPLAY=:5` and (if VirGL is enabled) `GALLIUM_DRIVER=virpipe`. + +--- + +> [!TIP] +> **Want an out-of-the-box desktop experience?** +> +> Our official XFCE rootfs tarballs have **built-in XFCE auto-boot support**. When you start the container with Termux:X11 (or VirGL) enabled, XFCE launches automatically and appears in the Termux:X11 app - no terminal commands needed. +> +> Download from the [Rootfs Repository](Usage-Android-App.md#rootfs-repository) (search "XFCE") or directly from [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest). + +--- + + + +### 01. Termux-X11 + llvmpipe + +Software rendering via `llvmpipe`. The most compatible method - works on any device regardless of GPU vendor. + +#### Setup + +1. Open the Droidspaces app and navigate to **Edit container configuration**. +2. Enable the **Configure Termux:X11** toggle and save. +3. Start the container. Droidspaces will automatically launch the Termux:X11 X server - the Termux:X11 app will display the **"X" cursor**, confirming the server is ready. +4. Open a terminal inside the container and run any GUI application: + + ```bash + glxgears + ``` + + ```bash + startxfce4 + ``` + + The window will appear in the Termux:X11 app. + +> [!NOTE] +> +> **Hardware Access/GPU Access** is not required for software rendering. + +--- + + + +### 02. Termux-X11 + VirGL + +Hardware-accelerated rendering for **non-Qualcomm devices (Mali, PowerVR)** via a `virglrenderer` bridge. Translates OpenGL calls from the container into commands the host Android GPU can execute. + +Droidspaces automatically starts both the X server and the VirGL server, and injects `DISPLAY=:5` and `GALLIUM_DRIVER=virpipe` into the container environment. + +#### Setup + +1. Open **Edit container configuration**. +2. Enable both **Configure Termux:X11** and **Configure VirGL 3D Acceleration** toggles and save. +3. Start the container. Both servers launch automatically. The Termux:X11 app will show the **"X" cursor** once ready. +4. Run any GUI application inside the container: + + ```bash + glxgears + ``` + + Look for **"VirGL"** in the renderer string to confirm acceleration is active. + + ```bash + startxfce4 + ``` + +> [!TIP] +> +> **If the VirGL renderer fails to initialize**, you can pass the Vulkan backend flag via the **VirGL Extra Flags** field in the container configuration: +> +> `--angle-vulkan` + +--- + + + +### 03. Turnip (Native Qualcomm/Adreno) + +Near-native hardware acceleration for **Qualcomm Adreno GPUs** using the open-source Turnip Mesa driver. This bypasses VirGL entirely for direct GPU access. + +#### Requirements + +- A custom Mesa driver installed from the [Mesa for Android Container repository](https://github.com/lfdevs/mesa-for-android-container). + +#### Setup + +1. Install the custom Mesa driver following the instructions at [Mesa for Android Container](https://github.com/lfdevs/mesa-for-android-container). + +2. Open **Edit container configuration** and apply the following: + - Enable **GPU Access** and **Configure Termux:X11**. + - **Disable** the **Configure VirGL 3D Acceleration** toggle (VirGL must be off for Turnip). + - Add these two environment variables: + + ``` + MESA_LOADER_DRIVER_OVERRIDE=kgsl + TU_DEBUG=noconform + ``` + +3. Start the container. Droidspaces launches the X server automatically. + +4. Run a GUI application inside the container - Turnip GPU acceleration will be active. + +--- + + + +## Android Sound (PulseAudio) + +Droidspaces bridges Android's audio stack into your container using PulseAudio. When enabled, a PulseAudio daemon runs on the host as the Termux user (so Android's audio HAL grants it access to the device speaker), and its UNIX socket is bind-mounted into the container at `/tmp/.pulse-socket`. The environment variable `PULSE_SERVER=unix:/tmp/.pulse-socket` is injected automatically, so any application inside the container that speaks PulseAudio will produce sound with no manual configuration. + +> [!WARNING] +> +> PulseAudio audio passthrough may not work on all devices. Compatibility depends on the Android version, OEM audio HAL implementation, and the Termux PulseAudio build. If audio does not work on your device, this is a known limitation of the upstream packages on that platform. + +#### Requirements + +- PulseAudio must be installed in Termux - the [setup script](#requirements) handles this automatically. + +#### Setup + +1. Open the Droidspaces app and navigate to **Edit container configuration**. +2. Enable the **Configure PulseAudio** toggle and save. +3. Start the container. Droidspaces will: + - Launch the PulseAudio daemon as the Termux user. + - Wait for the socket at `/tmp/.pulse-socket` to appear before proceeding. + - Run `pactl set-default-sink AAudio_sink` to route audio to the device speaker. + - Bind-mount the socket into the container and inject `PULSE_SERVER`. + + You can also enable PulseAudio via the CLI flag `--pulse-audio`. + +4. Install and run any audio application inside the container. Because `PULSE_SERVER` is already set in the container environment, most apps work without any additional configuration: + + ```bash + # Test audio output + paplay /path/to/sound.wav + ``` + + ```bash + # Verify the PulseAudio connection + pactl info + ``` + +> [!NOTE] +> +> PulseAudio sound is **Android-only**. On Linux desktop hosts, audio passthrough works via the host's own PulseAudio/PipeWire setup - no special configuration is needed in Droidspaces. + +> [!NOTE] +> +> **Samsung One UI 6.1+ devices:** Droidspaces automatically injects `libskcodec.so` via `LD_PRELOAD` before starting PulseAudio. This fixes a hidden dependency in the OpenSL ES audio module specific to Samsung firmware. No action is required from you. + +--- + + + +## Desktop Environment Auto-Boot + +As of v6.3.0, when Termux:X11 is enabled in a container's configuration, Droidspaces guarantees the X server socket (`/tmp/.X11-unix/X5`) is live before the container's init system reaches `graphical.target`. This makes proper systemd-based DE auto-start possible with no race conditions. + +### How the Official XFCE Tarballs Wire It + +Our official XFCE rootfs tarballs ship with a fully pre-configured auto-boot setup. Here's exactly how it works: + +**1. The `xfce-autostart.service` systemd unit** is installed at `/etc/systemd/system/xfce-autostart.service` and enabled under `graphical.target`: + +```ini +[Unit] +Description=XFCE Autostart +After=graphical.target + +[Service] +Type=simple +User=root +ExecCondition=/bin/sh -c "grep -q 'enable_termux_x11=1' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "test -S /tmp/.X11-unix/X5" +ExecStart=/usr/local/bin/xfce-start +Restart=on-failure + +[Install] +WantedBy=graphical.target +``` + +Two `ExecCondition` guards ensure XFCE only starts when it makes sense: the container must have Termux:X11 enabled in its config, and the X server socket must actually exist. If either condition fails, systemd skips the service silently - no errors, no crash loops. + +**2. The `/usr/local/bin/xfce-start` launcher script** handles environment sourcing and user switching: + +```sh +#!/bin/sh + +ENV_FILE=/run/droidspaces.env +CONFIG=/run/droidspaces/container.config + +if [ -f "$ENV_FILE" ]; then + . "$ENV_FILE" + WHITELIST=$(sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$ENV_FILE" | tr '\n' ',' | sed 's/,$//') +else + export DISPLAY=:5 + WHITELIST=DISPLAY + if grep -q 'enable_pulseaudio=1' "$CONFIG" 2>/dev/null; then + export PULSE_SERVER=unix:/tmp/.pulse-socket + WHITELIST="$WHITELIST,PULSE_SERVER" + fi + if grep -q 'enable_virgl=1' "$CONFIG" 2>/dev/null; then + export GALLIUM_DRIVER=virpipe + WHITELIST="$WHITELIST,GALLIUM_DRIVER" + fi +fi + +if [ -n "$XFCE_USER" ]; then + exec su -l -w "$WHITELIST" "$XFCE_USER" -c 'exec /usr/bin/startxfce4' +else + exec /usr/bin/startxfce4 +fi +``` + +The script first tries to source `/run/droidspaces.env` (written by Droidspaces at boot; contains `DISPLAY=:5`, `GALLIUM_DRIVER=virpipe` if VirGL is enabled, and `PULSE_SERVER` if PulseAudio is enabled). If the env file is missing for any reason, it falls back to reading the container config directly and building the environment manually. This makes the script robust against any init system startup ordering. + +### The `XFCE_USER` Variable + +By default, `xfce-start` runs XFCE as `root`. If you want XFCE to run as a non-root user, set the `XFCE_USER` environment variable in your container's **Environment Variables** configuration in the Droidspaces app: + +``` +XFCE_USER=youruser +``` + +When `XFCE_USER` is set, the script uses `su -l -w "$WHITELIST"` to switch to that user while explicitly passing through only the required environment variables (`DISPLAY`, `GALLIUM_DRIVER`, `PULSE_SERVER`, etc.). This keeps the session clean - no root env leaking into the user session. + +> [!TIP] +> +> Make sure `youruser` exists inside the container and has a valid home directory before setting `XFCE_USER`. You can create one with `useradd -m youruser` inside the container. + +--- + +### Power Users: Wire Any Desktop Environment + +You can replicate this exact pattern for any DE (Plasma, GNOME, MATE, i3, etc.) in any container. + +**Step 1:** Create the launcher script at `/usr/local/bin/de-start`: + +```sh +#!/bin/sh + +ENV_FILE=/run/droidspaces.env +CONFIG=/run/droidspaces/container.config + +if [ -f "$ENV_FILE" ]; then + . "$ENV_FILE" + WHITELIST=$(sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$ENV_FILE" | tr '\n' ',' | sed 's/,$//') +else + export DISPLAY=:5 + WHITELIST=DISPLAY + if grep -q 'enable_pulseaudio=1' "$CONFIG" 2>/dev/null; then + export PULSE_SERVER=unix:/tmp/.pulse-socket + WHITELIST="$WHITELIST,PULSE_SERVER" + fi + if grep -q 'enable_virgl=1' "$CONFIG" 2>/dev/null; then + export GALLIUM_DRIVER=virpipe + WHITELIST="$WHITELIST,GALLIUM_DRIVER" + fi +fi + +DE_CMD="startplasma-x11" # replace with your DE's start command + +if [ -n "$XFCE_USER" ]; then + exec su -l -w "$WHITELIST" "$XFCE_USER" -c "exec $DE_CMD" +else + exec $DE_CMD +fi +``` + +```bash +chmod +x /usr/local/bin/de-start +``` + +**Step 2:** Create the systemd service at `/etc/systemd/system/de-autostart.service`: + +```ini +[Unit] +Description=Desktop Environment Autostart +After=graphical.target + +[Service] +Type=simple +ExecCondition=/bin/sh -c "grep -q 'enable_termux_x11=1' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "test -S /tmp/.X11-unix/X5" +ExecStart=/usr/local/bin/de-start +Restart=on-failure + +[Install] +WantedBy=graphical.target +``` + +**Step 3:** Enable it: + +```bash +systemctl enable de-autostart.service +``` + +On next container boot with Termux:X11 enabled, your DE will appear in the Termux:X11 app automatically. + +> [!NOTE] +> +> The `XFCE_USER` variable name is a convention from our official tarballs. You can rename it to anything in your own script - what matters is the `su -l -w "$WHITELIST"` pattern for clean user switching with env passthrough. + +--- + + + +## Linux Desktop (AMD/Intel) + +On Linux-based hosts, GPU acceleration works natively with zero additional configuration within Droidspaces. + +#### Requirements +- An active X11 or Wayland session on your host. +- Functional GPU drivers (Mesa/Intel/AMD). + +#### Implementation Steps + +1. **Enable Hardware Access**: Ensure the **Hardware Access** toggle is enabled in your container configuration (or use the `--hw-access` CLI flag). + +2. **Xhost Permission**: On your host machine, allow the container to connect to your X server: + + ```bash + xhost +local: + ``` + +3. **Set Display Variable**: Add the host's `DISPLAY` number to the container's environment (usually `:0`): + + ```bash + echo "DISPLAY=:0" >> /etc/environment + ``` + +4. **Run Applications**: GUI applications launched from the container will render natively with full hardware acceleration. diff --git a/Documentation/Installation-Android.md b/Documentation/Installation-Android.md index b5a548c7..5b7dbd2d 100644 --- a/Documentation/Installation-Android.md +++ b/Documentation/Installation-Android.md @@ -74,5 +74,5 @@ You can verify your system status at any time: ## Next Steps - [Android App Usage Guide](Usage-Android-App.md) for management details. -- [GPU Acceleration Guide](GPU-Acceleration.md) to enable hardware-accelerated desktop environments. +- [Display, Audio & Desktop Guide](Graphics-and-Audio.md) to enable GPU acceleration, sound, and desktop environment auto-boot. - [Linux CLI Guide](Linux-CLI.md) for expert command-line access. diff --git a/Documentation/zh-CN/Features.md b/Documentation/zh-CN/Features.md index 73e85a00..0682ba6b 100644 --- a/Documentation/zh-CN/Features.md +++ b/Documentation/zh-CN/Features.md @@ -188,7 +188,7 @@ droidspaces --name=gpu-test --rootfs=/path/to/rootfs --hw-access start > X11 支持可以使用 `--termux-x11` (`-X`) 标志独立启用。如果您不需要完整的 GPU/硬件访问权限,这是在 Android 上使用 GUI 应用程序的推荐方式,因为它保留了更高级别的隔离性。 -启动容器后,在容器内设置 `DISPLAY=:0` 以使用 X11 显示。 +Droidspaces 会通过 `/run/droidspaces.env`(从 `/etc/profile.d/droidspaces_env.sh` 符号链接)自动将 `DISPLAY=:5` 以及(启用 VirGL 时)`GALLIUM_DRIVER=virpipe` 注入到容器环境中。`bash` 和 `sh` 会自动读取此文件。如果您使用 `zsh`、`fish` 或其他非登录 shell,请手动执行:`source /run/droidspaces.env`。 ### 支持的 GPU 系列 diff --git a/Documentation/zh-CN/GPU-Acceleration.md b/Documentation/zh-CN/GPU-Acceleration.md deleted file mode 100644 index 58546f6f..00000000 --- a/Documentation/zh-CN/GPU-Acceleration.md +++ /dev/null @@ -1,194 +0,0 @@ - - -# Droidspaces GPU 加速指南 - -本指南提供了在 Droidspaces 容器中启用 GPU 加速的逐步说明。无论你是在 Android 设备上还是在 Linux 桌面上运行,Droidspaces 都提供多种方式来利用硬件加速,以获得流畅的图形体验。 - -### 快速导航 - -- [**Android 设备**](#android) - - [01. Termux-X11 + llvmpipe(软件渲染)](#termux-x11) - - [02. Termux-X11 + VirGL(非 Qualcomm GPU)](#virgl) - - [03. Turnip(原生 Qualcomm/Adreno)](#turnip) -- [**Linux 桌面(AMD/Intel)**](#linux) - ---- - - - -## Android - -在 Android 上的硬件加速是通过将容器的图形栈与宿主端 X 服务器(Termux-X11)桥接来实现的。Droidspaces 处理了实现此无缝衔接所需的复杂挂载管理和安全上下文。 - -> [!TIP] -> -> 如果你想享受开箱即用的 XFCE 桌面环境体验,可以从[Rootfs repository](Usage-Android-App.md#rootfs-repository) 或从官方的 [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases) 下载我们预配置的 XFCE 压缩包。 - - - -### 01. Termux-X11 + llvmpipe - -此方法通过 `llvmpipe` 使用**软件渲染**。虽然它不提供完整的硬件加速,但在没有兼容 GPU 驱动可用时,这是运行 GUI 应用程序的最稳定方式。 - -#### "统一 Tmpfs 桥接" -当你在 Droidspaces App 中启用 **Termux X11** 开关时,会发生以下流程: - -1. **宿主端准备**:Droidspaces 在宿主端的挂载命名空间中,于 Termux 的 `/data/data/com.termux/files/usr/tmp` 之上创建一个 `tmpfs` 挂载。 - -2. **绕过 FBE 加密**:虽然可以直接将 `/data/data/com.termux/files/usr/tmp` 绑定挂载到容器中,但这经常会导致 `apt` 等应用程序或任何在 `/tmp` 中进行大量 I/O 操作的工具出现问题。这是因为 Termux 的数据目录受到 Android 基于文件的加密(FBE)保护,导致出现"Required key not available"(ENOKEY)错误。通过 `tmpfs` 桥接该路径,X11 socket 和临时文件将对容器变为完全可读可写。 - -3. **绑定挂载**:此"统一 Tmpfs 桥接"随后被绑定挂载到容器的 `/tmp` 目录,从而实现容器与 Termux-X11 App 之间的无缝通信。 - -#### 设置要求 - -- **Termux**:`pkg install x11-repo && pkg install termux-x11` -- **容器**:`sudo apt install mesa-utils`(用于使用 `glxgears` 测试) - -#### 实施步骤 -1. **配置容器**:在 Droidspaces App 中,导航到容器的配置页面。 - -2. **启用 X11**:将 **Termux-X11** 开关切换为 `ON`(软件渲染不需要 **Hardware Access**)。 - -3. **环境变量**:在**环境变量**部分添加 `DISPLAY=:0` 并保存。 - -4. **启动容器**:启动你的容器。 - -5. **启动 X 服务器**:打开 Termux App 并运行: - - ```bash - termux-x11 :0 - ``` - -6. **验证**:在容器终端内运行 `glxgears`。输出将渲染在 Termux-X11 App 中。 - -7. **启动桌面环境**:要启动完整的 XFCE 桌面(如果已安装),运行: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - ---- - - - -### 02. Termux-X11 + VirGL - -此方法通过 `virglrenderer` 桥接为**非高通设备(Mali/PowerVR)** 提供 **GPU 加速**。它将容器中的 OpenGL 调用转换为宿主 Android 操作系统可以执行的命令。 - -#### 设置要求 - -- **Termux**:`pkg install x11-repo && pkg install termux-x11 virglrenderer-android` -- **容器**:`sudo apt install mesa-utils`(用于使用 `glxgears` 测试) - -#### 实施步骤 - -1. **容器配置**:在 Droidspaces 容器设置中启用 **Termux-X11**。然后,在**环境变量**部分添加以下内容: - ```bash - DISPLAY=:0 - GALLIUM_DRIVER=virpipe - ``` - -2. **启动容器**:启动你的容器。 - -3. **启动 VirGL 服务器**:打开 Termux 并在后台运行服务器: - ```bash - virgl_test_server_android & - ``` - -4. **启动 X 服务器**:在 Termux 中运行: - ```bash - termux-x11 :0 - ``` - -5. **验证加速**:运行 `glxinfo -B` 并在渲染器字符串中查找"VirGL"。 - -6. **启动桌面环境**:要启动完整的 XFCE 桌面(如果已安装),运行: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - -> [!TIP] -> -> **如果渲染器初始化失败,** 请尝试使用 Vulkan 后端启动 VirGL 服务器: -> -> `virgl_test_server_android --angle-vulkan &` - ---- - - - -### 03. Turnip(原生高通/Adreno) - -对于高通 Adreno GPU,Droidspaces 支持使用 Turnip 驱动实现**原生硬件加速**。这绕过了对 `virgl` 的需求,并提供接近原生的性能。 - -#### 要求 - -- **自定义 Mesa 驱动**:按照 [Mesa for Android Container 仓库](https://github.com/lfdevs/mesa-for-android-container) 中的说明进行安装。 -- **Termux**:`pkg install x11-repo && pkg install termux-x11` - -#### 实施步骤 - -1. 从 [Mesa for Android Container 仓库](https://github.com/lfdevs/mesa-for-android-container) **安装自定义 Mesa 驱动**。 - -2. **启用 GPU 访问**:在容器设置中,启用 **GPU Access** 和 **Termux X11**。 - -3. **设置显示变量**:将 `DISPLAY=:0` 添加到你的环境变量中。 - -4. **启动顺序**: - - 通过 Droidspaces 启动容器。 - - 打开 Termux 并运行 `termux-x11 :0` - -5. **权限管理(非 Root 用户)**: - 如果你使用的是非 root 用户,必须授予他们对 GPU 设备节点的访问权限: - - ```bash - sudo usermod -aG droidspaces-gpu - ``` - -6. **启动桌面环境**:要启动完整的 XFCE 桌面(如果已安装),运行: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - -> [!TIP] -> **如果你遇到任何与 DRI3 相关的问题,** 请尝试编辑 `/data/adb/modules/droidspaces/sepolicy.rule` 并取消注释以下行: -> -> `allow untrusted_app_27 droidspacesd fd use` - ---- - - - -## Linux 桌面端(AMD/Intel) - -在基于 Linux 的主机上,GPU 加速在 Droidspaces 中原生运行,无需额外配置。 - -#### 要求 -- 主机上有一个活跃的 X11 或 Wayland 会话。 -- 正常工作的 GPU 驱动(Mesa/Intel/AMD)。 - -#### 实施步骤 - -1. **启用硬件访问**:确保在容器配置中启用了 **Hardware Access** 开关(或使用 `--hw-access` CLI 参数)。 - -2. **Xhost 权限**:在你的主机上,允许容器连接到你的 X 服务器: - - ```bash - xhost +local: - ``` - -3. **设置显示变量**:将主机的 `DISPLAY` 编号添加到容器的环境中(通常是 `:0`): - - ```bash - echo "DISPLAY=:0" >> /etc/environment - ``` - -4. **运行应用程序**:从容器中启动的 GUI 应用程序将以完整的硬件加速进行原生渲染。 diff --git a/Documentation/zh-CN/Graphics-and-Audio.md b/Documentation/zh-CN/Graphics-and-Audio.md new file mode 100644 index 00000000..242f0467 --- /dev/null +++ b/Documentation/zh-CN/Graphics-and-Audio.md @@ -0,0 +1,396 @@ + + +# Droidspaces 显示、音频与桌面指南 + +本指南介绍如何在 Droidspaces 容器中配置显示、GPU 加速、音效(PulseAudio)以及桌面环境自动启动。在 Android 上,自 **v6.3.0** 起,X 服务器、VirGL 服务器和 PulseAudio 守护进程均会在容器启动时自动拉起 - 无需在 Termux 中手动执行任何命令。 + +> [!IMPORTANT] +> +> **Droidspaces 不提供 PulseAudio、Termux:X11 或 virglrenderer-android 软件包。** 这些均为上游 Termux 软件包,由安装脚本安装。Droidspaces 仅负责管理其生命周期(启动、套接字桥接、环境变量注入)。如遇音频质量、设备兼容性、崩溃或任何这些软件包的渲染问题,请向 [Termux packages](https://github.com/termux/termux-packages) 项目反馈,而非 Droidspaces。 + +### 快速导航 + +- [**通用要求(Android)**](#requirements) +- [**Android 显示与 GPU**](#android) + - [01. Termux-X11 + llvmpipe(软件渲染)](#termux-x11) + - [02. Termux-X11 + VirGL(非高通 GPU)](#virgl) + - [03. Turnip(原生高通/Adreno)](#turnip) +- [**Android 音效(PulseAudio)**](#pulseaudio) +- [**桌面环境自动启动**](#de-autoboot) +- [**Linux 桌面(AMD/Intel)**](#linux) + +--- + + + +## 通用要求(Android) + +在设置任何显示、GPU 或音频功能之前,请确保满足以下所有条件: + +1. **Droidspaces v6.3.0 或更高版本** — 更新后,**必须完整重启设备**以应用更新的 SELinux 规则。 + +2. **必须在设备上安装 [Termux](https://github.com/termux/termux-app) 和 [Termux:X11](https://github.com/termux/termux-x11) 两个应用**。 + +3. **在 Termux 内运行 Droidspaces 安装脚本**(必须执行 — 安装 Termux:X11、VirGL、PulseAudio 并修补音频配置): + + ```bash + curl -fsSL https://github.com/ravindu644/Droidspaces-OSS/raw/refs/heads/dev/scripts/setup-termux.sh | bash + ``` + +> [!IMPORTANT] +> +> 此脚本只需运行一次,它会将所有必要的显示和音频依赖项安装到 Termux 中。 + +--- + + + +## Android 显示与 GPU + +自 v6.3.0 起,Droidspaces 在容器启动时会自动启动 Termux:X11 X 服务器(以及已配置的 VirGL 服务器),并通过 `/run/droidspaces.env`(从 `/etc/profile.d/droidspaces_env.sh` 符号链接)将所需环境变量注入容器。 + +> [!TIP] +> +> **使用 `zsh`、`fish` 或其他非登录 Shell?** +> +> 这些 Shell 可能不会自动读取 `/etc/profile.d`。请在容器启动后手动执行:`source /run/droidspaces.env` +> +> 这将设置 `DISPLAY=:5`,以及(启用 VirGL 时)`GALLIUM_DRIVER=virpipe`。 + +--- + +> [!TIP] +> **想要开箱即用的桌面体验?** +> +> 我们的官方 XFCE rootfs 压缩包内置了 **XFCE 自动启动支持**。启用 Termux:X11(或 VirGL)并启动容器后,XFCE 将自动启动并显示在 Termux:X11 应用中 — 无需任何终端命令。 +> +> 从 [Rootfs 仓库](./Usage-Android-App.md#rootfs-仓库)(搜索"XFCE")下载,或直接从 [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest) 下载。 + +--- + + + +### 01. Termux-X11 + llvmpipe + +通过 `llvmpipe` 进行软件渲染。兼容性最强的方式 — 适用于任何设备,无论 GPU 厂商。 + +#### 设置步骤 + +1. 打开 Droidspaces 应用,进入**编辑容器配置**。 +2. 启用 **配置 Termux:X11** 开关并保存。 +3. 启动容器。Droidspaces 将自动启动 Termux:X11 X 服务器 — Termux:X11 应用将显示 **"X" 光标**,确认服务器已就绪。 +4. 在容器内打开终端,运行任意 GUI 程序: + + ```bash + glxgears + ``` + + ```bash + startxfce4 + ``` + + 窗口将显示在 Termux:X11 应用中。 + +> [!NOTE] +> +> 软件渲染**不需要**启用 **Hardware Access**。 + +--- + + + +### 02. Termux-X11 + VirGL + +通过 `virglrenderer` 桥接为**非高通设备(Mali、PowerVR)**提供硬件加速渲染。将容器中的 OpenGL 调用转换为宿主 Android GPU 可执行的命令。 + +Droidspaces 会自动启动 X 服务器和 VirGL 服务器,并将 `DISPLAY=:5` 和 `GALLIUM_DRIVER=virpipe` 注入容器环境。 + +#### 设置步骤 + +1. 进入**编辑容器配置**。 +2. 同时启用 **配置 Termux:X11** 和 **配置 VirGL 3D 加速** 开关并保存。 +3. 启动容器。两个服务器将自动启动。Termux:X11 应用就绪后会显示 **"X" 光标**。 +4. 在容器内运行任意 GUI 程序: + + ```bash + glxgears + ``` + + 在渲染器字符串中查找 **"VirGL"** 以确认加速已生效。 + + ```bash + startxfce4 + ``` + +> [!TIP] +> +> **如果 VirGL 渲染器初始化失败**,可在容器配置的 **VirGL 额外参数**字段中传入 Vulkan 后端标志: +> +> `--angle-vulkan` + +--- + + + +### 03. Turnip(原生高通/Adreno) + +使用开源 Turnip Mesa 驱动为**高通 Adreno GPU** 提供接近原生的硬件加速。完全绕过 VirGL,直接访问 GPU。 + +#### 要求 + +- 按照 [Mesa for Android Container 仓库](https://github.com/lfdevs/mesa-for-android-container)的说明安装自定义 Mesa 驱动。 + +#### 设置步骤 + +1. 按照 [Mesa for Android Container](https://github.com/lfdevs/mesa-for-android-container) 的说明安装自定义 Mesa 驱动。 + +2. 进入**编辑容器配置**并进行以下设置: + - 启用 **GPU Access** 和 **配置 Termux:X11**。 + - **禁用** **配置 VirGL 3D 加速** 开关(使用 Turnip 时必须关闭 VirGL)。 + - 添加以下两个环境变量: + + ``` + MESA_LOADER_DRIVER_OVERRIDE=kgsl + TU_DEBUG=noconform + ``` + +3. 启动容器。Droidspaces 自动启动 X 服务器。 + +4. 在容器内运行 GUI 程序 — Turnip GPU 加速将自动生效。 + +--- + + + +## Android 音效(PulseAudio) + +Droidspaces 通过 PulseAudio 将 Android 的音频栈桥接到容器中。启用后,PulseAudio 守护进程以 Termux 用户身份在宿主机上运行(从而使 Android 音频 HAL 授予其访问设备扬声器的权限),其 UNIX 套接字以绑定挂载方式挂载到容器的 `/tmp/.pulse-socket`。环境变量 `PULSE_SERVER=unix:/tmp/.pulse-socket` 将自动注入容器,因此容器内支持 PulseAudio 的应用无需任何额外配置即可发出声音。 + +> [!WARNING] +> +> PulseAudio 音频直通并非在所有设备上均可用。兼容性取决于 Android 版本、OEM 音频 HAL 实现以及 Termux PulseAudio 构建版本。若你的设备无法正常使用音频,这属于上游软件包在该平台上的已知限制。 + +#### 要求 + +- Termux 中必须已安装 PulseAudio - [安装脚本](#requirements)会自动完成此操作。 + +#### 设置步骤 + +1. 打开 Droidspaces 应用,进入**编辑容器配置**。 +2. 启用 **配置 PulseAudio** 开关并保存。 +3. 启动容器。Droidspaces 将: + - 以 Termux 用户身份启动 PulseAudio 守护进程。 + - 等待套接字 `/tmp/.pulse-socket` 出现后再继续。 + - 执行 `pactl set-default-sink AAudio_sink`,将音频路由至设备扬声器。 + - 将套接字绑定挂载到容器并注入 `PULSE_SERVER`。 + + 也可以通过 CLI 参数 `--pulse-audio` 启用 PulseAudio。 + +4. 在容器内安装并运行任意音频应用。由于 `PULSE_SERVER` 已在容器环境中设置,大多数应用无需额外配置即可正常使用: + + ```bash + # 测试音频输出 + paplay /path/to/sound.wav + ``` + + ```bash + # 验证 PulseAudio 连接 + pactl info + ``` + +> [!NOTE] +> +> PulseAudio 音效**仅适用于 Android**。在 Linux 桌面端,音频直通通过宿主机自身的 PulseAudio/PipeWire 实现,Droidspaces 无需特殊配置。 + +> [!NOTE] +> +> **三星 One UI 6.1+ 设备:** Droidspaces 在启动 PulseAudio 前会自动通过 `LD_PRELOAD` 注入 `libskcodec.so`,以修复三星固件中 OpenSL ES 音频模块的隐藏依赖问题。无需用户进行任何操作。 + +--- + + + +## 桌面环境自动启动 + +自 v6.3.0 起,当容器配置中启用了 Termux:X11 时,Droidspaces 保证 X 服务器套接字(`/tmp/.X11-unix/X5`)在容器 init 系统到达 `graphical.target` 之前已就绪,使基于 systemd 的桌面环境自动启动得以无竞争条件地运行。 + +### 官方 XFCE 压缩包的实现方式 + +我们的官方 XFCE rootfs 压缩包内置了完整的自动启动配置,以下是其具体实现: + +**1. `xfce-autostart.service` systemd 单元**安装于 `/etc/systemd/system/xfce-autostart.service`,并在 `graphical.target` 下启用: + +```ini +[Unit] +Description=XFCE Autostart +After=graphical.target + +[Service] +Type=simple +User=root +ExecCondition=/bin/sh -c "grep -q 'enable_termux_x11=1' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "test -S /tmp/.X11-unix/X5" +ExecStart=/usr/local/bin/xfce-start +Restart=on-failure + +[Install] +WantedBy=graphical.target +``` + +两个 `ExecCondition` 守卫确保 XFCE 仅在条件满足时启动:容器配置中必须启用 Termux:X11,且 X 服务器套接字必须实际存在。若任一条件不满足,systemd 将静默跳过该服务,不产生错误或崩溃循环。 + +**2. `/usr/local/bin/xfce-start` 启动脚本**负责环境变量读取和用户切换: + +```sh +#!/bin/sh + +ENV_FILE=/run/droidspaces.env +CONFIG=/run/droidspaces/container.config + +if [ -f "$ENV_FILE" ]; then + . "$ENV_FILE" + WHITELIST=$(sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$ENV_FILE" | tr '\n' ',' | sed 's/,$//') +else + export DISPLAY=:5 + WHITELIST=DISPLAY + if grep -q 'enable_pulseaudio=1' "$CONFIG" 2>/dev/null; then + export PULSE_SERVER=unix:/tmp/.pulse-socket + WHITELIST="$WHITELIST,PULSE_SERVER" + fi + if grep -q 'enable_virgl=1' "$CONFIG" 2>/dev/null; then + export GALLIUM_DRIVER=virpipe + WHITELIST="$WHITELIST,GALLIUM_DRIVER" + fi +fi + +if [ -n "$XFCE_USER" ]; then + exec su -l -w "$WHITELIST" "$XFCE_USER" -c 'exec /usr/bin/startxfce4' +else + exec /usr/bin/startxfce4 +fi +``` + +脚本首先尝试读取 `/run/droidspaces.env`(由 Droidspaces 在启动时写入;包含 `DISPLAY=:5`,若启用 VirGL 则含 `GALLIUM_DRIVER=virpipe`,若启用 PulseAudio 则含 `PULSE_SERVER`)。若环境文件因任何原因缺失,则回退到直接读取容器配置并手动构建环境,确保脚本对 init 系统启动顺序具有鲁棒性。 + +### `XFCE_USER` 变量 + +默认情况下,`xfce-start` 以 `root` 身份运行 XFCE。若需以非 root 用户运行,请在 Droidspaces 应用的容器**环境变量**配置中设置 `XFCE_USER`: + +``` +XFCE_USER=youruser +``` + +设置后,脚本使用 `su -l -w "$WHITELIST"` 切换到该用户,同时仅透传必要的环境变量(`DISPLAY`、`GALLIUM_DRIVER`、`PULSE_SERVER` 等),保持会话干净,避免 root 环境泄漏到用户会话中。 + +> [!TIP] +> +> 设置 `XFCE_USER` 前,请确保容器内已存在该用户且有有效的主目录。可通过 `useradd -m youruser` 在容器内创建。 + +--- + +### 高级用户:为任意桌面环境配置自动启动 + +你可以将此模式复制到任意桌面环境(Plasma、GNOME、MATE、i3 等)的任意容器中。 + +**第一步:** 创建启动脚本 `/usr/local/bin/de-start`: + +```sh +#!/bin/sh + +ENV_FILE=/run/droidspaces.env +CONFIG=/run/droidspaces/container.config + +if [ -f "$ENV_FILE" ]; then + . "$ENV_FILE" + WHITELIST=$(sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$ENV_FILE" | tr '\n' ',' | sed 's/,$//') +else + export DISPLAY=:5 + WHITELIST=DISPLAY + if grep -q 'enable_pulseaudio=1' "$CONFIG" 2>/dev/null; then + export PULSE_SERVER=unix:/tmp/.pulse-socket + WHITELIST="$WHITELIST,PULSE_SERVER" + fi + if grep -q 'enable_virgl=1' "$CONFIG" 2>/dev/null; then + export GALLIUM_DRIVER=virpipe + WHITELIST="$WHITELIST,GALLIUM_DRIVER" + fi +fi + +DE_CMD="startplasma-x11" # 替换为你的桌面环境启动命令 + +if [ -n "$XFCE_USER" ]; then + exec su -l -w "$WHITELIST" "$XFCE_USER" -c "exec $DE_CMD" +else + exec $DE_CMD +fi +``` + +```bash +chmod +x /usr/local/bin/de-start +``` + +**第二步:** 创建 systemd 服务 `/etc/systemd/system/de-autostart.service`: + +```ini +[Unit] +Description=Desktop Environment Autostart +After=graphical.target + +[Service] +Type=simple +ExecCondition=/bin/sh -c "grep -q 'enable_termux_x11=1' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "test -S /tmp/.X11-unix/X5" +ExecStart=/usr/local/bin/de-start +Restart=on-failure + +[Install] +WantedBy=graphical.target +``` + +**第三步:** 启用服务: + +```bash +systemctl enable de-autostart.service +``` + +下次启用 Termux:X11 启动容器时,你的桌面环境将自动出现在 Termux:X11 应用中。 + +> [!NOTE] +> +> `XFCE_USER` 变量名是我们官方压缩包的约定,你可以在自己的脚本中自由重命名。关键在于 `su -l -w "$WHITELIST"` 模式,它实现了干净的用户切换与环境变量透传。 + +--- + + + +## Linux 桌面端(AMD/Intel) + +在基于 Linux 的主机上,GPU 加速在 Droidspaces 中原生运行,无需额外配置。 + +#### 要求 +- 主机上有一个活跃的 X11 或 Wayland 会话。 +- 正常工作的 GPU 驱动(Mesa/Intel/AMD)。 + +#### 实施步骤 + +1. **启用硬件访问**:确保在容器配置中启用了 **Hardware Access** 开关(或使用 `--hw-access` CLI 参数)。 + +2. **Xhost 权限**:在你的主机上,允许容器连接到你的 X 服务器: + + ```bash + xhost +local: + ``` + +3. **设置显示变量**:将主机的 `DISPLAY` 编号添加到容器的环境中(通常是 `:0`): + + ```bash + echo "DISPLAY=:0" >> /etc/environment + ``` + +4. **运行应用程序**:从容器中启动的 GUI 应用程序将以完整的硬件加速进行原生渲染。 diff --git a/Documentation/zh-CN/Installation-Android.md b/Documentation/zh-CN/Installation-Android.md index f4979135..70efd76c 100644 --- a/Documentation/zh-CN/Installation-Android.md +++ b/Documentation/zh-CN/Installation-Android.md @@ -72,5 +72,5 @@ Droidspaces 在 Android 上旨在提供"零终端"体验。从首次安装到运 ## 下一步 - [Android 应用使用指南](./Usage-Android-App.md):了解管理详情。 -- [GPU 加速指南](./GPU-Acceleration.md):启用硬件加速的桌面环境。 +- [显示、音频与桌面指南](./Graphics-and-Audio.md):配置 GPU 加速、音效与桌面环境自动启动。 - [Linux CLI 指南](./Linux-CLI.md):提供专家级命令行访问。 diff --git a/README.md b/README.md index ede8a27f..d6d1a67a 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ What makes Droidspaces unique is its **zero-dependency, native execution** on bo - [What is Droidspaces?](#what-is-droidspaces) - [Features](#features) +- [Display, Audio & Desktop](#display-audio-desktop) - [Security & Isolation Philosophy](#security-model) - [Droidspaces vs The Alternatives](#droidspaces-vs-the-alternatives) - [Requirements](#requirements) @@ -175,8 +176,9 @@ The entire runtime is a **single static binary** under 400KB, compiled against m | **Deep Android Integration** | Supports two daemon modes: **Native init.rc** (lowest-level integration with auto-spawn/unkillable persistence) and **Userspace Daemon** (app-togglable, starts via `post-fs-data.sh`, no image modification required). **Both modes bypass root-domain seccomp blocks to ensure stable container lifecycles** [[init.rc Developer Guide](./init/README.md)]. | | **Namespace Isolation** | Complete isolation via PID, MNT, UTS, IPC, and Cgroup namespaces. Each container has its own process tree, mount table, hostname, IPC resources, and cgroup hierarchy. | | **Network Isolation** | **3 Networking Modes (Host, NAT, None)**. Pure network isolation via `CLONE_NEWNET` (NAT/None modes) or shared host networking (Host mode). Works on both Android and Linux. | -| **Android GPU Acceleration** | Native hardware acceleration for Qualcomm Adreno GPUs via the Turnip driver. Use our [pre-built rootfs templates](https://github.com/ravindu644/Droidspaces-rootfs-builder/releases/latest) for an out-of-the-box experience. [[More info](./Documentation/GPU-Acceleration.md)] | -| **Linux GPU Acceleration** | Zero-configuration GPU acceleration for AMD and Intel GPUs on Linux desktop hosts. [[More info](./Documentation/GPU-Acceleration.md)] | +| **Android Display & GPU** | Three acceleration modes: **llvmpipe** (software, all devices), **VirGL** (Mali/PowerVR), and **Turnip** (native Qualcomm/Adreno). As of Droidspaces v6.3.0, the X server and VirGL server launch automatically when the container starts - zero manual Termux commands required. Environment variables (`DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`) are injected automatically. [[More info](./Documentation/Graphics-and-Audio.md)] | +| **Android Sound** | PulseAudio daemon runs on the host as the Termux user so Android's audio HAL grants it device access. The socket is bind-mounted into the container at `/tmp/.pulse-socket` and `PULSE_SERVER` is injected automatically - audio just works. [[More info](./Documentation/Graphics-and-Audio.md#pulseaudio)] | +| **Linux GPU Acceleration** | Zero-configuration GPU acceleration for AMD and Intel GPUs on Linux desktop hosts. [[More info](./Documentation/Graphics-and-Audio.md)] | | **Port Forwarding** | Forward host ports to the container in NAT mode (e.g., `--port 22:22`). Supports TCP and UDP, as well as ranges like `1-500:1-500`. | | **Volatile Mode** | Ephemeral containers using OverlayFS. All changes are stored in RAM and discarded on exit. Perfect for testing and development. | | **Custom Bind Mounts** | Map host directories into containers at arbitrary mount points. Supports both chained (`-B a:b -B c:d`) and comma-separated (`-B a:b,c:d`) syntax. | @@ -196,6 +198,18 @@ The entire runtime is a **single static binary** under 400KB, compiled against m --- + + +## Display, Audio & Desktop + +As of v6.3.0, Droidspaces automatically launches the Termux:X11 X server, VirGL server, and PulseAudio daemon when the corresponding toggles are enabled in a container's configuration. The environment variables `DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`, and `PULSE_SERVER` are injected into the container via `/run/droidspaces.env` before the init system finishes booting - displays, GPU acceleration, and audio all work without manual setup. + +Our official XFCE rootfs tarballs ship with **XFCE auto-boot** pre-wired: when Termux:X11 is enabled, XFCE launches automatically and appears in the Termux:X11 app - no terminal commands needed. Download from the [Rootfs Repository](./Documentation/Usage-Android-App.md#rootfs-repository) (search "XFCE") or from [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest). + +For GPU acceleration methods, sound setup, DE auto-boot internals, and Linux desktop configuration, see the **[Display, Audio & Desktop Guide](./Documentation/Graphics-and-Audio.md)**. + +--- + ## Security & Isolation Philosophy @@ -246,7 +260,7 @@ The entire runtime is a **single static binary** under 400KB, compiled against m | **Run at Boot** | **Yes (native `init.rc` / `service.d`). Auto-starts containers even if the phone is locked, `/data` is encrypted, and before any user app has even started.** | No | No | No | No | | **Network Isolation on Android** | **First-in-class. Full NAT/Veth + internet works out of the box. No manual configuration needed.** | Internet works only in host-network mode (`lxc.net.0.type = none`). True network isolation (veth + NAT) often requires manual bridge, iptables, and ip_forward setup - and still breaks on most devices. | Requires `--network host` to get internet; actual network isolation with internet access often does not work reliably on Android. | None (no network namespace) | None (no network namespace) | | **Hardware & Native GPU Access** | **Full (single toggle). Adreno Turnip, USB, sensors, network interfaces, block devices. Full `systemd-udevd` support - behaves like a real Linux PC.** | Manual bind mounts, no udev | Manual bind mounts, no udev | Manual bind mounts, no udev | None | -| **Termux-X11 Support** | **Full (single toggle)** | Manual socket passthrough | Manual socket passthrough | Manual socket passthrough | Manual socket passthrough | +| **Termux-X11 Support** | **Full (single toggle). X server and VirGL server auto-launch on container start. Env vars (`DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`) injected automatically.** | Manual socket passthrough | Manual socket passthrough | Manual socket passthrough | Manual socket passthrough | | **Privileged Mode** | **Full + customizable (`--nomask`, `--nocaps`, `--noseccomp`, etc.)** | Manual config | Yes (`--privileged`) | Full (no guardrails) | No | | **Nested Containers (Docker-in-DS)** | **Natively supported on all kernels** | Complex manual setup | Complex manual setup | No | No | | **Ephemeral / Volatile Containers** | **Yes (OverlayFS, RAM-backed, zero persistence on exit)** | No | Yes | No | No | @@ -373,6 +387,7 @@ sudo ./droidspaces check | Document | Description | |----------|-------------| | [Feature Deep Dives](Documentation/Features.md) | Detailed explanation of each major feature. | +| [Display, Audio & Desktop Guide](Documentation/Graphics-and-Audio.md) | GPU acceleration, PulseAudio sound, and desktop environment auto-boot on Android and Linux. | | [Cool Things You Can Do (Tailscale, Docker, etc.)](Documentation/Cool-things-you-can-do.md) | | [Uninstallation Guide](Documentation/Uninstallation.md) | How to remove Droidspaces from your system. | diff --git a/README_CN.md b/README_CN.md index 6fb8893c..fab163b5 100644 --- a/README_CN.md +++ b/README_CN.md @@ -131,6 +131,7 @@ Droidspaces 的独特之处在于其对 Android 和 Linux 本身**零依赖**、 - [什么是 Droidspaces?](#what-is-droidspaces) - [功能特性](#features) +- [显示、音频与桌面](#display-audio-desktop) - [安全与隔离的理念](#security-model) - [Droidspaces与其他方案对比](#droidspaces-vs-the-alternatives) - [系统需求](#requirements) @@ -175,8 +176,9 @@ Droidspaces 的设计目标是原生运行在任何搭载 Linux 内核的设备 | **深度 Android 集成** | 支持两种守护进程模式:**原生 init.rc**(最底层的集成方式,自动生成/不可被终止的持久性)和**用户空间守护进程**(应用内可切换,通过 `post-fs-data.sh` 启动,无需修改镜像)。**两种模式均绕过 root 域 seccomp 限制,确保容器生命周期稳定** [[init.rc 开发者指南](./init/README_CN.md)]。 | | **命名空间隔离** | 通过 PID、MNT、UTS、IPC 以及 Cgroup 命名空间实现完全隔离。每个容器拥有自己的进程树、挂载表、主机名、IPC 资源和 cgroup 层级。 | | **网络隔离** | **3 种网络模式(Host、NAT、None)**。通过 `CLONE_NEWNET` 实现纯网络隔离(NAT/None 模式)或共享宿主机网络(Host 模式)。在 Android 和 Linux 版本上均可使用。 | -| **Android GPU 加速** | 通过 Turnip 驱动为 Qualcomm Adreno GPU 提供原生硬件加速。使用我们的[预构建 rootfs 模板](https://github.com/ravindu644/Droidspaces-rootfs-builder/releases/latest)获得开箱即用的体验。[[更多信息](./Documentation/zh-CN/GPU-Acceleration.md)] | -| **Linux GPU 加速** | 在 Linux 桌面端上无需额外配置 AMD 和 Intel GPU 硬件加速。[[更多信息](./Documentation/zh-CN/GPU-Acceleration.md)] | +| **Android 显示与 GPU** | 三种加速模式:**llvmpipe**(软件渲染,适用所有设备)、**VirGL**(Mali/PowerVR)和 **Turnip**(原生高通/Adreno)。自 v6.3.0 起,X 服务器和 VirGL 服务器在容器启动时自动拉起,无需任何 Termux 命令。环境变量(`DISPLAY=:5`、`GALLIUM_DRIVER=virpipe`)自动注入。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md)] | +| **Android 音效** | PulseAudio 守护进程以 Termux 用户身份在宿主机上运行,使 Android 音频 HAL 授予其设备访问权限。套接字以绑定挂载方式挂载到容器的 `/tmp/.pulse-socket`,`PULSE_SERVER` 自动注入 — 音效开箱即用。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md#pulseaudio)] | +| **Linux GPU 加速** | 在 Linux 桌面端上无需额外配置 AMD 和 Intel GPU 硬件加速。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md)] | | **端口转发** | 在 NAT 模式下将宿主机端口转发到容器(例如 `--port 22:22`)。支持 TCP 和 UDP,以及端口范围如 `1-500:1-500`。 | | **易失模式** | 使用 OverlayFS 的临时容器。所有更改存储在内存中,退出后丢弃。非常适合用于测试和开发。 | | **自定义绑定挂载** | 将宿主机目录映射到容器中的任意挂载点。支持链式语法(`-B a:b -B c:d`)和逗号分隔语法(`-B a:b,c:d`)。 | @@ -196,6 +198,18 @@ Droidspaces 的设计目标是原生运行在任何搭载 Linux 内核的设备 --- + + +## 显示、音频与桌面 + +自 v6.3.0 起,Droidspaces 在容器启动时会自动拉起 Termux:X11 X 服务器、VirGL 服务器和 PulseAudio 守护进程(对应开关已启用时)。环境变量 `DISPLAY=:5`、`GALLIUM_DRIVER=virpipe` 和 `PULSE_SERVER` 均通过 `/run/droidspaces.env` 在 init 系统完成启动前注入容器 — 显示、GPU 加速和音效无需手动配置即可正常使用。 + +我们的官方 XFCE rootfs 压缩包已预置 **XFCE 自动启动**配置:启用 Termux:X11 后启动容器,XFCE 会自动启动并出现在 Termux:X11 应用中,无需任何终端命令。从[Rootfs 仓库](./Documentation/zh-CN/Usage-Android-App.md#rootfs-repository)(搜索"XFCE")下载,或直接从 [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest) 下载。 + +GPU 加速方式、音效配置、桌面环境自动启动原理以及 Linux 桌面配置,请参阅 **[显示、音频与桌面指南](./Documentation/zh-CN/Graphics-and-Audio.md)**。 + +--- + ## 安全与隔离理念 @@ -246,7 +260,7 @@ Droidspaces 的设计目标是原生运行在任何搭载 Linux 内核的设备 | **开机自启** | **支持(原生 `init.rc` / `service.d`)。即使手机锁定、`/data` 加密,甚至在任何用户应用启动之前,即可自动启动容器。** | 不支持 | 不支持 | 不支持 | 不支持 | | **Android 端网络隔离** | **同类首创。完整的 NAT/Veth + 互联网访问开箱即用。无需手动配置。** | 仅主机网络模式下互联网可用(`lxc.net.0.type = none`)。真正的网络隔离(veth + NAT)通常需要手动设置网桥、iptables 和 ip_forward — 且在大多数设备上仍然不可用。 | 需要 `--network host` 才能上网;真正的带互联网访问的网络隔离在 Android 上通常无法可靠工作。 | 无(没有网络命名空间) | 无(没有网络命名空间) | | **硬件与原生 GPU 直通** | **完全(一键开关)。Adreno Turnip、USB、传感器、网络接口、块设备。完整的 `systemd-udevd` 支持 — 如同真正的 Linux PC。** | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 无 | -| **Termux-X11 支持** | **完全(一键开关)** | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | +| **Termux-X11 支持** | **完全(一键开关)。X 服务器和 VirGL 服务器在容器启动时自动拉起。环境变量(`DISPLAY=:5`、`GALLIUM_DRIVER=virpipe`)自动注入。** | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | | **特权模式** | **完全 + 可定制(`--nomask`、`--nocaps`、`--noseccomp` 等)** | 手动配置 | 支持(`--privileged`) | 完全(无任何防护) | 不支持 | | **嵌套容器(Docker-in-DS)** | **所有内核上原生支持** | 复杂的手动配置 | 复杂的手动配置 | 不支持 | 不支持 | | **临时/易失容器** | **支持(OverlayFS,RAM 后端存储,退出后零持久化)** | 不支持 | 支持 | 不支持 | 不支持 | @@ -373,6 +387,7 @@ sudo ./droidspaces check | 文档 | 描述 | |----------|-------------| | [功能深度解析](./Documentation/zh-CN/Features.md) | 每个主要功能的详细说明。 | +| [显示、音频与桌面指南](./Documentation/zh-CN/Graphics-and-Audio.md) | Android 和 Linux 上的 GPU 加速、PulseAudio 音效与桌面环境自动启动。 | | [你可以做的酷炫事情(Tailscale、Docker 等)](./Documentation/zh-CN/Cool-things-you-can-do.md) | | [卸载指南](./Documentation/zh-CN/Uninstallation.md) | 如何从系统中移除 Droidspaces。 | From 605d31d362f437570118f593a0303401507d030b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 10 Jun 2026 15:09:39 +0530 Subject: [PATCH 033/211] docs: reorganize README and README_CN structure --- README.md | 99 ++++++++++++++++++++++++------------------------ README_CN.md | 104 +++++++++++++++++++++++++-------------------------- 2 files changed, 102 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index d6d1a67a..7eb0a623 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ What makes Droidspaces unique is its **zero-dependency, native execution** on bo **Android** + **Linux Namespaces** = **Droidspaces**. Since Android is built on the Linux kernel, Droidspaces works seamlessly on Linux Desktop too. Both platforms are equally supported and maintained. > [!TIP] +> > Check out [Community-supported Android devices](./Documentation/community-supported-devices.md) for a growing list of phones known to run Droidspaces.
    @@ -131,8 +132,6 @@ What makes Droidspaces unique is its **zero-dependency, native execution** on bo - [What is Droidspaces?](#what-is-droidspaces) - [Features](#features) -- [Display, Audio & Desktop](#display-audio-desktop) -- [Security & Isolation Philosophy](#security-model) - [Droidspaces vs The Alternatives](#droidspaces-vs-the-alternatives) - [Requirements](#requirements) - [Android](#a-android-devices) @@ -143,10 +142,12 @@ What makes Droidspaces unique is its **zero-dependency, native execution** on bo - [GKI (Modern Kernels)](#GKI) - [Linux Desktop](#b-linux-desktop) - [Installation](#installation) -- [Community-supported Android devices](./Documentation/community-supported-devices.md) - [Usage](#usage) -- [Troubleshooting](./Documentation/Troubleshooting.md) +- [Display, Audio & Desktop](#display-audio-desktop) +- [Security & Isolation Philosophy](#security-model) - [Additional Documentation](#additional-documentation) +- [Troubleshooting](./Documentation/Troubleshooting.md) +- [Community-supported Android devices](./Documentation/community-supported-devices.md) - [Contributing](#contribution) - [Credits](#credits) @@ -176,7 +177,7 @@ The entire runtime is a **single static binary** under 400KB, compiled against m | **Deep Android Integration** | Supports two daemon modes: **Native init.rc** (lowest-level integration with auto-spawn/unkillable persistence) and **Userspace Daemon** (app-togglable, starts via `post-fs-data.sh`, no image modification required). **Both modes bypass root-domain seccomp blocks to ensure stable container lifecycles** [[init.rc Developer Guide](./init/README.md)]. | | **Namespace Isolation** | Complete isolation via PID, MNT, UTS, IPC, and Cgroup namespaces. Each container has its own process tree, mount table, hostname, IPC resources, and cgroup hierarchy. | | **Network Isolation** | **3 Networking Modes (Host, NAT, None)**. Pure network isolation via `CLONE_NEWNET` (NAT/None modes) or shared host networking (Host mode). Works on both Android and Linux. | -| **Android Display & GPU** | Three acceleration modes: **llvmpipe** (software, all devices), **VirGL** (Mali/PowerVR), and **Turnip** (native Qualcomm/Adreno). As of Droidspaces v6.3.0, the X server and VirGL server launch automatically when the container starts - zero manual Termux commands required. Environment variables (`DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`) are injected automatically. [[More info](./Documentation/Graphics-and-Audio.md)] | +| **Android Display & GPU** | Three acceleration modes: **llvmpipe** (software, all devices), **VirGL** (Mali/PowerVR), and **Turnip** (native Qualcomm/Adreno). As of v6.3.0, the X server and VirGL server launch automatically when the container starts - zero manual Termux commands required. Environment variables (`DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`) are injected automatically. [[More info](./Documentation/Graphics-and-Audio.md)] | | **Android Sound** | PulseAudio daemon runs on the host as the Termux user so Android's audio HAL grants it device access. The socket is bind-mounted into the container at `/tmp/.pulse-socket` and `PULSE_SERVER` is injected automatically - audio just works. [[More info](./Documentation/Graphics-and-Audio.md#pulseaudio)] | | **Linux GPU Acceleration** | Zero-configuration GPU acceleration for AMD and Intel GPUs on Linux desktop hosts. [[More info](./Documentation/Graphics-and-Audio.md)] | | **Port Forwarding** | Forward host ports to the container in NAT mode (e.g., `--port 22:22`). Supports TCP and UDP, as well as ranges like `1-500:1-500`. | @@ -198,49 +199,6 @@ The entire runtime is a **single static binary** under 400KB, compiled against m --- - - -## Display, Audio & Desktop - -As of v6.3.0, Droidspaces automatically launches the Termux:X11 X server, VirGL server, and PulseAudio daemon when the corresponding toggles are enabled in a container's configuration. The environment variables `DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`, and `PULSE_SERVER` are injected into the container via `/run/droidspaces.env` before the init system finishes booting - displays, GPU acceleration, and audio all work without manual setup. - -Our official XFCE rootfs tarballs ship with **XFCE auto-boot** pre-wired: when Termux:X11 is enabled, XFCE launches automatically and appears in the Termux:X11 app - no terminal commands needed. Download from the [Rootfs Repository](./Documentation/Usage-Android-App.md#rootfs-repository) (search "XFCE") or from [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest). - -For GPU acceleration methods, sound setup, DE auto-boot internals, and Linux desktop configuration, see the **[Display, Audio & Desktop Guide](./Documentation/Graphics-and-Audio.md)**. - ---- - - - -## Security & Isolation Philosophy - -> [!IMPORTANT] -> -> Droidspaces is a **privileged container runtime** built for **power users** who prioritize simplicity, performance, and native integration over complex, production-grade jailing. -> -> To provide full systemd support, native hardware acceleration (GPU), and complex mounts/networking on Android, the container root needs real privileges. Even though Droidspaces does not use the heavily restricted "unprivileged" (User Namespace) mode, it applies several security layers: -> - **Capability Dropping**: By default, Droidspaces drops high-risk capabilities (e.g., `CAP_SYS_MODULE`, `CAP_SYS_RAWIO`). -> - **Mount Hardening**: Critical host paths are masked or remounted as read-only. -> - **Seccomp Filters**: Common exploit vectors (like CVE-2026-31431 and malicious kernel module loading) are blocked by default. - -> [!WARNING] -> -> **A Container is not a Jail** -> If a process runs as **root** inside a Droidspaces container, it has significant power. -> -> A malicious root user can attempt to escape or manipulate the host. **Droidspaces is not a sandbox for untrusted code.** -> -> We focus on bringing a full Linux server experience to your pocket, not on building a production-grade fortress. - -> [!NOTE] -> -> **Our Security Advice:** -> 1. **Don't daily-drive root**: Just as you would on a standard Linux PC, create a normal user inside your container and use `sudo`. -> 2. **Be Careful with Modes**: Flags like `--privileged` and `--hw-access` intentionally relax security barriers. Use them only when necessary. -> 3. **Respect the Host**: If you compromise your container's root, you compromise your device. - ---- - ## Droidspaces vs The Alternatives @@ -380,6 +338,49 @@ sudo ./droidspaces check --- + + +## Display, Audio & Desktop + +As of v6.3.0, Droidspaces automatically launches the Termux:X11 X server, VirGL server, and PulseAudio daemon when the corresponding toggles are enabled in a container's configuration. The environment variables `DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`, and `PULSE_SERVER` are injected into the container via `/run/droidspaces.env` before the init system finishes booting - displays, GPU acceleration, and audio all work without manual setup. + +Our official XFCE rootfs tarballs ship with **XFCE auto-boot** pre-wired: when Termux:X11 is enabled, XFCE launches automatically and appears in the Termux:X11 app - no terminal commands needed. Download from the [Rootfs Repository](./Documentation/Usage-Android-App.md#rootfs-repository) (search "XFCE") or from [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest). + +For GPU acceleration methods, sound setup, DE auto-boot internals, and Linux desktop configuration, see the **[Display, Audio & Desktop Guide](./Documentation/Graphics-and-Audio.md)**. + +--- + + + +## Security & Isolation Philosophy + +> [!IMPORTANT] +> +> Droidspaces is a **privileged container runtime** built for **power users** who prioritize simplicity, performance, and native integration over complex, production-grade jailing. +> +> To provide full systemd support, native hardware acceleration (GPU), and complex mounts/networking on Android, the container root needs real privileges. Even though Droidspaces does not use the heavily restricted "unprivileged" (User Namespace) mode, it applies several security layers: +> - **Capability Dropping**: By default, Droidspaces drops high-risk capabilities (e.g., `CAP_SYS_MODULE`, `CAP_SYS_RAWIO`). +> - **Mount Hardening**: Critical host paths are masked or remounted as read-only. +> - **Seccomp Filters**: Common exploit vectors (like CVE-2026-31431 and malicious kernel module loading) are blocked by default. + +> [!WARNING] +> +> **A Container is not a Jail** +> If a process runs as **root** inside a Droidspaces container, it has significant power. +> +> A malicious root user can attempt to escape or manipulate the host. **Droidspaces is not a sandbox for untrusted code.** +> +> We focus on bringing a full Linux server experience to your pocket, not on building a production-grade fortress. + +> [!NOTE] +> +> **Our Security Advice:** +> 1. **Don't daily-drive root**: Just as you would on a standard Linux PC, create a normal user inside your container and use `sudo`. +> 2. **Be Careful with Modes**: Flags like `--privileged` and `--hw-access` intentionally relax security barriers. Use them only when necessary. +> 3. **Respect the Host**: If you compromise your container's root, you compromise your device. + +--- + ## Additional Documentation @@ -388,7 +389,7 @@ sudo ./droidspaces check |----------|-------------| | [Feature Deep Dives](Documentation/Features.md) | Detailed explanation of each major feature. | | [Display, Audio & Desktop Guide](Documentation/Graphics-and-Audio.md) | GPU acceleration, PulseAudio sound, and desktop environment auto-boot on Android and Linux. | -| [Cool Things You Can Do (Tailscale, Docker, etc.)](Documentation/Cool-things-you-can-do.md) | +| [Cool Things You Can Do (Tailscale, Docker, etc.)](Documentation/Cool-things-you-can-do.md) | Practical recipes for running Tailscale, Docker, and other tools inside containers. | | [Uninstallation Guide](Documentation/Uninstallation.md) | How to remove Droidspaces from your system. | --- diff --git a/README_CN.md b/README_CN.md index fab163b5..ce1e4527 100644 --- a/README_CN.md +++ b/README_CN.md @@ -131,9 +131,7 @@ Droidspaces 的独特之处在于其对 Android 和 Linux 本身**零依赖**、 - [什么是 Droidspaces?](#what-is-droidspaces) - [功能特性](#features) -- [显示、音频与桌面](#display-audio-desktop) -- [安全与隔离的理念](#security-model) -- [Droidspaces与其他方案对比](#droidspaces-vs-the-alternatives) +- [Droidspaces 与其他方案对比](#droidspaces-vs-the-alternatives) - [系统需求](#requirements) - [Android](#a-android-devices) - [Root 环境要求](#rooting-requirements) @@ -143,10 +141,12 @@ Droidspaces 的独特之处在于其对 Android 和 Linux 本身**零依赖**、 - [GKI(现代内核)](#GKI) - [Linux 桌面端](#b-linux-desktop) - [安装指南](#installation) -- [社区支持的 Android 设备](./Documentation/zh-CN/community-supported-devices.md) - [使用指南](#usage) -- [疑难解答](./Documentation/zh-CN/Troubleshooting.md) +- [显示、音频与桌面](#display-audio-desktop) +- [安全与隔离的理念](#security-model) - [更多文档](#additional-documentation) +- [疑难解答](./Documentation/zh-CN/Troubleshooting.md) +- [社区支持的 Android 设备](./Documentation/zh-CN/community-supported-devices.md) - [参与贡献](#contribution) - [致谢](#credits) @@ -177,7 +177,7 @@ Droidspaces 的设计目标是原生运行在任何搭载 Linux 内核的设备 | **命名空间隔离** | 通过 PID、MNT、UTS、IPC 以及 Cgroup 命名空间实现完全隔离。每个容器拥有自己的进程树、挂载表、主机名、IPC 资源和 cgroup 层级。 | | **网络隔离** | **3 种网络模式(Host、NAT、None)**。通过 `CLONE_NEWNET` 实现纯网络隔离(NAT/None 模式)或共享宿主机网络(Host 模式)。在 Android 和 Linux 版本上均可使用。 | | **Android 显示与 GPU** | 三种加速模式:**llvmpipe**(软件渲染,适用所有设备)、**VirGL**(Mali/PowerVR)和 **Turnip**(原生高通/Adreno)。自 v6.3.0 起,X 服务器和 VirGL 服务器在容器启动时自动拉起,无需任何 Termux 命令。环境变量(`DISPLAY=:5`、`GALLIUM_DRIVER=virpipe`)自动注入。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md)] | -| **Android 音效** | PulseAudio 守护进程以 Termux 用户身份在宿主机上运行,使 Android 音频 HAL 授予其设备访问权限。套接字以绑定挂载方式挂载到容器的 `/tmp/.pulse-socket`,`PULSE_SERVER` 自动注入 — 音效开箱即用。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md#pulseaudio)] | +| **Android 音效** | PulseAudio 守护进程以 Termux 用户身份在宿主机上运行,使 Android 音频 HAL 授予其设备访问权限。套接字以绑定挂载方式挂载到容器的 `/tmp/.pulse-socket`,`PULSE_SERVER` 自动注入,音效开箱即用。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md#pulseaudio)] | | **Linux GPU 加速** | 在 Linux 桌面端上无需额外配置 AMD 和 Intel GPU 硬件加速。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md)] | | **端口转发** | 在 NAT 模式下将宿主机端口转发到容器(例如 `--port 22:22`)。支持 TCP 和 UDP,以及端口范围如 `1-500:1-500`。 | | **易失模式** | 使用 OverlayFS 的临时容器。所有更改存储在内存中,退出后丢弃。非常适合用于测试和开发。 | @@ -198,49 +198,6 @@ Droidspaces 的设计目标是原生运行在任何搭载 Linux 内核的设备 --- - - -## 显示、音频与桌面 - -自 v6.3.0 起,Droidspaces 在容器启动时会自动拉起 Termux:X11 X 服务器、VirGL 服务器和 PulseAudio 守护进程(对应开关已启用时)。环境变量 `DISPLAY=:5`、`GALLIUM_DRIVER=virpipe` 和 `PULSE_SERVER` 均通过 `/run/droidspaces.env` 在 init 系统完成启动前注入容器 — 显示、GPU 加速和音效无需手动配置即可正常使用。 - -我们的官方 XFCE rootfs 压缩包已预置 **XFCE 自动启动**配置:启用 Termux:X11 后启动容器,XFCE 会自动启动并出现在 Termux:X11 应用中,无需任何终端命令。从[Rootfs 仓库](./Documentation/zh-CN/Usage-Android-App.md#rootfs-repository)(搜索"XFCE")下载,或直接从 [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest) 下载。 - -GPU 加速方式、音效配置、桌面环境自动启动原理以及 Linux 桌面配置,请参阅 **[显示、音频与桌面指南](./Documentation/zh-CN/Graphics-and-Audio.md)**。 - ---- - - - -## 安全与隔离理念 - -> [!IMPORTANT] -> -> Droidspaces 是一个**特权容器运行环境**,专为追求简洁、性能和原生集成的**高级用户**打造,而非复杂的生产级沙箱。 -> -> 为了在 Android 上提供完整的 systemd 支持、原生硬件加速(GPU)以及复杂的挂载/网络功能,容器 root 需要真正的特权。尽管 Droidspaces 不使用严格受限的"非特权"(用户命名空间)模式,但它仍然应用了多层安全防护: -> - **权能裁剪**:默认情况下,Droidspaces 会剥离高风险权能(如 `CAP_SYS_MODULE`、`CAP_SYS_RAWIO`)。 -> - **挂载加固**:关键的宿主机路径被屏蔽或重新挂载为只读。 -> - **Seccomp 过滤**:默认阻止常见的攻击向量(如 CVE-2026-31431 和恶意内核模块加载)。 - -> [!WARNING] -> -> **容器不等于监狱** -> 如果进程在 Droidspaces 容器中以 **root** 身份运行,它将拥有相当大的权限。 -> -> 恶意的 root 用户可能试图逃逸或操纵宿主机。**Droidspaces 不是为不受信任代码设计的沙箱。** -> -> 我们专注于将完整的 Linux 服务器体验带到你的口袋中,而不是构建一个生产级的堡垒。 - -> [!NOTE] -> -> **我们的安全建议:** -> 1. **日常使用不要使用 root 用户**:就像在常规 Linux PC 上一样,在容器内创建普通用户并使用 `sudo`。 -> 2. **谨慎使用特殊模式**:`--privileged` 和 `--hw-access` 等标志会刻意放宽安全限制,仅在必要时使用。 -> 3. **尊重宿主机**:如果你容器的 root 权限被攻破,你的设备也将被攻破。 - ---- - ## Droidspaces 与其他方案对比 @@ -258,8 +215,8 @@ GPU 加速方式、音效配置、桌面环境自动启动原理以及 Linux 桌 | **Android 端容器持久性** | **真正的不可终止。存活超过 15 天。完全免疫开发者选项中的"不保留活动"和"无后台进程"。** | 低(会被 Android LMK / 电池优化杀死) | 低(被 Android LMK / 电池优化杀死) | 低(会被 Android LMK / 电池优化杀死) | 低(被 Android LMK / 电池优化杀死) | | **数据持久性(应用卸载)** | **零数据丢失。所有容器、配置和数据保存在 `/data/local/Droidspaces`,完全独立于应用。二进制和守护进程在自己的进程会话中运行(`setsid`),与应用的进程组分离。卸载应用不会停止任何东西,也不会删除任何数据。** | 卸载 Termux 后一切消亡。LXC 配置和 rootfs 存储在 Termux 内部(`/data/data/com.termux`),卸载 Termux 会清除一切。 | 卸载 Termux 后一切消亡。`/data/docker` 中的容器数据保留,但若不重新安装整套工具链则无法访问。 | 若 rootfs 在 `/data/local/` 中则安全。若存储在 Termux 主目录内则不安全。 | 卸载 Termux 后一切消亡。PRoot rootfs 通常位于 `/data/data/com.termux`,卸载 Termux 时一并删除。 | | **开机自启** | **支持(原生 `init.rc` / `service.d`)。即使手机锁定、`/data` 加密,甚至在任何用户应用启动之前,即可自动启动容器。** | 不支持 | 不支持 | 不支持 | 不支持 | -| **Android 端网络隔离** | **同类首创。完整的 NAT/Veth + 互联网访问开箱即用。无需手动配置。** | 仅主机网络模式下互联网可用(`lxc.net.0.type = none`)。真正的网络隔离(veth + NAT)通常需要手动设置网桥、iptables 和 ip_forward — 且在大多数设备上仍然不可用。 | 需要 `--network host` 才能上网;真正的带互联网访问的网络隔离在 Android 上通常无法可靠工作。 | 无(没有网络命名空间) | 无(没有网络命名空间) | -| **硬件与原生 GPU 直通** | **完全(一键开关)。Adreno Turnip、USB、传感器、网络接口、块设备。完整的 `systemd-udevd` 支持 — 如同真正的 Linux PC。** | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 无 | +| **Android 端网络隔离** | **同类首创。完整的 NAT/Veth + 互联网访问开箱即用。无需手动配置。** | 仅主机网络模式下互联网可用(`lxc.net.0.type = none`)。真正的网络隔离(veth + NAT)通常需要手动设置网桥、iptables 和 ip_forward,且在大多数设备上仍然不可用。 | 需要 `--network host` 才能上网;真正的带互联网访问的网络隔离在 Android 上通常无法可靠工作。 | 无(没有网络命名空间) | 无(没有网络命名空间) | +| **硬件与原生 GPU 直通** | **完全(一键开关)。Adreno Turnip、USB、传感器、网络接口、块设备。完整的 `systemd-udevd` 支持,如同真正的 Linux PC。** | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 无 | | **Termux-X11 支持** | **完全(一键开关)。X 服务器和 VirGL 服务器在容器启动时自动拉起。环境变量(`DISPLAY=:5`、`GALLIUM_DRIVER=virpipe`)自动注入。** | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | | **特权模式** | **完全 + 可定制(`--nomask`、`--nocaps`、`--noseccomp` 等)** | 手动配置 | 支持(`--privileged`) | 完全(无任何防护) | 不支持 | | **嵌套容器(Docker-in-DS)** | **所有内核上原生支持** | 复杂的手动配置 | 复杂的手动配置 | 不支持 | 不支持 | @@ -380,6 +337,49 @@ sudo ./droidspaces check --- + + +## 显示、音频与桌面 + +自 v6.3.0 起,Droidspaces 在容器启动时会自动拉起 Termux:X11 X 服务器、VirGL 服务器和 PulseAudio 守护进程(对应开关已启用时)。环境变量 `DISPLAY=:5`、`GALLIUM_DRIVER=virpipe` 和 `PULSE_SERVER` 均通过 `/run/droidspaces.env` 在 init 系统完成启动前注入容器,显示、GPU 加速和音效无需手动配置即可正常使用。 + +我们的官方 XFCE rootfs 压缩包已预置 **XFCE 自动启动**配置:启用 Termux:X11 后启动容器,XFCE 会自动启动并出现在 Termux:X11 应用中,无需任何终端命令。从 [Rootfs 仓库](./Documentation/zh-CN/Usage-Android-App.md#rootfs-仓库)(搜索"XFCE")下载,或直接从 [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest) 下载。 + +GPU 加速方式、音效配置、桌面环境自动启动原理以及 Linux 桌面配置,请参阅 **[显示、音频与桌面指南](./Documentation/zh-CN/Graphics-and-Audio.md)**。 + +--- + + + +## 安全与隔离理念 + +> [!IMPORTANT] +> +> Droidspaces 是一个**特权容器运行环境**,专为追求简洁、性能和原生集成的**高级用户**打造,而非复杂的生产级沙箱。 +> +> 为了在 Android 上提供完整的 systemd 支持、原生硬件加速(GPU)以及复杂的挂载/网络功能,容器 root 需要真正的特权。尽管 Droidspaces 不使用严格受限的"非特权"(用户命名空间)模式,但它仍然应用了多层安全防护: +> - **权能裁剪**:默认情况下,Droidspaces 会剥离高风险权能(如 `CAP_SYS_MODULE`、`CAP_SYS_RAWIO`)。 +> - **挂载加固**:关键的宿主机路径被屏蔽或重新挂载为只读。 +> - **Seccomp 过滤**:默认阻止常见的攻击向量(如 CVE-2026-31431 和恶意内核模块加载)。 + +> [!WARNING] +> +> **容器不等于监狱** +> 如果进程在 Droidspaces 容器中以 **root** 身份运行,它将拥有相当大的权限。 +> +> 恶意的 root 用户可能试图逃逸或操纵宿主机。**Droidspaces 不是为不受信任代码设计的沙箱。** +> +> 我们专注于将完整的 Linux 服务器体验带到你的口袋中,而不是构建一个生产级的堡垒。 + +> [!NOTE] +> +> **我们的安全建议:** +> 1. **日常使用不要使用 root 用户**:就像在常规 Linux PC 上一样,在容器内创建普通用户并使用 `sudo`。 +> 2. **谨慎使用特殊模式**:`--privileged` 和 `--hw-access` 等标志会刻意放宽安全限制,仅在必要时使用。 +> 3. **尊重宿主机**:如果你容器的 root 权限被攻破,你的设备也将被攻破。 + +--- + ## 更多文档 @@ -388,7 +388,7 @@ sudo ./droidspaces check |----------|-------------| | [功能深度解析](./Documentation/zh-CN/Features.md) | 每个主要功能的详细说明。 | | [显示、音频与桌面指南](./Documentation/zh-CN/Graphics-and-Audio.md) | Android 和 Linux 上的 GPU 加速、PulseAudio 音效与桌面环境自动启动。 | -| [你可以做的酷炫事情(Tailscale、Docker 等)](./Documentation/zh-CN/Cool-things-you-can-do.md) | +| [你可以做的酷炫事情(Tailscale、Docker 等)](./Documentation/zh-CN/Cool-things-you-can-do.md) | 在容器内运行 Tailscale、Docker 等工具的实用配置方案。 | | [卸载指南](./Documentation/zh-CN/Uninstallation.md) | 如何从系统中移除 Droidspaces。 | --- From e52bac0c1bb95ab16257088778d792784637e24f Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Mon, 8 Jun 2026 17:02:30 +0530 Subject: [PATCH 034/211] droidspaces: bump v6.3.0 Signed-off-by: ravindu644 --- Android/app/src/main/assets/boot-module/module.prop | 4 ++-- src/include/droidspace.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Android/app/src/main/assets/boot-module/module.prop b/Android/app/src/main/assets/boot-module/module.prop index ea24a2f1..dde6dd51 100644 --- a/Android/app/src/main/assets/boot-module/module.prop +++ b/Android/app/src/main/assets/boot-module/module.prop @@ -1,6 +1,6 @@ id=droidspaces name=Droidspaces: Daemon & Init -version=v6.2.5 -versionCode=6250 +version=v6.3.0 +versionCode=6300 author=ravindu644 description=Automatically starts the Droidspaces daemon if "Daemon Mode" is enabled, and starts containers during boot if "Run-at-Boot" is active. diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 83cf2fb6..7cc0c7e2 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -67,7 +67,7 @@ * ---------------------------------------------------------------------------*/ #define DS_PROJECT_NAME "Droidspaces" -#define DS_VERSION "6.2.5" +#define DS_VERSION "6.3.0" #define DS_MIN_KERNEL_MAJOR 3 #define DS_MIN_KERNEL_MINOR 10 #define DS_RECOMMENDED_KERNEL_MAJOR 4 From cd95a6fba4ad823c6bac94953cdb7ff4756fb420 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 10 Jun 2026 11:50:42 +0200 Subject: [PATCH 035/211] Translated using Weblate (#195) Currently translated at 100.0% (468 of 468 strings) Translated using Weblate (Khmer (Central)) Currently translated at 98.2% (460 of 468 strings) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (468 of 468 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/km/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hant/ Translation: Droidspaces/Strings Co-authored-by: Jung Chi Wang Co-authored-by: LayAn Co-authored-by: seriaTvT <63958697+seriaTvT@users.noreply.github.com> --- Android/app/src/main/res/values-km/strings.xml | 11 +++++++++++ Android/app/src/main/res/values-zh-rCN/strings.xml | 1 + Android/app/src/main/res/values-zh-rTW/strings.xml | 1 + 3 files changed, 13 insertions(+) diff --git a/Android/app/src/main/res/values-km/strings.xml b/Android/app/src/main/res/values-km/strings.xml index 9116562e..30877d27 100644 --- a/Android/app/src/main/res/values-km/strings.xml +++ b/Android/app/src/main/res/values-km/strings.xml @@ -454,4 +454,15 @@ URL មិនអាចទទេបានទេ URL ត្រូវតែចាប់ផ្តើមដោយ https:// មិនទាន់មានការជាវ Repository ផ្ទាល់ខ្លួននៅឡើយទេ។ + តម្រូវការប្រព័ន្ធសម្រាប់ Termux + VirGL + PulseAudio + កំណត់រចនាសម្ព័ន្ធ PulseAudio + បើកដំណើរការមុខងារសំឡេងតាមរយៈ PulseAudio + ប៉ារ៉ាម៉ែត្របន្ថែមសម្រាប់ Termux:X11 + ឧទាហរណ៍៖ -dpi 160 -force-sysvshm + កំណត់រចនាសម្ព័ន្ធមុខងារបង្កើនល្បឿន VirGL 3D + បើកដំណើរការមុខងារបង្កើនល្បឿនផ្នែករឹង 3D តាមរយៈ VirGL + ប៉ារ៉ាម៉ែត្របន្ថែមសម្រាប់ VirGL + ឧទាហរណ៍៖ --multi-clients --angle-vulkan diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index 0608f605..a537d26d 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -467,4 +467,5 @@ 通过 VirGL 启用 3D 硬件加速 VirGL 额外参数 例如: --multi-clients --angle-vulkan + Termux 要求 diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index a1a3c6ea..44ce4ef9 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -462,4 +462,5 @@ 透過 VirGL 啟用 3D 硬體加速 VirGL 額外參數標記 例如: --multi-clients --angle-vulkan + Termux 需求 From d41368e497e1a38b4f0bec50e28e7ca88774f6f3 Mon Sep 17 00:00:00 2001 From: Yoshinobu Date Date: Thu, 11 Jun 2026 00:44:15 +0900 Subject: [PATCH 036/211] Net: Virtual router support (#196) * net: add delegated gateway network mode Add a gateway networking mode that lets containers attach to a bridge-only delegated LAN managed by a designated gateway container. In this mode Droidspaces still owns namespace and veth plumbing, but it does not install its normal NAT, DHCP, DNS, port-forwarding, or firewall policy on the delegated LAN. This allows a gateway container to become the routing authority for attached containers and provide policy such as DHCP, DNS, firewalling, VPN routing, or segmented internal networks. The initial implementation wires application containers to a delegated bridge and lazily attaches the selected gateway container to the same LAN-side network. Existing NAT, host, and none modes remain unchanged. This provides a foundation for router-appliance style deployments while keeping Droidspaces focused on container lifecycle and network plumbing. * main: split usage text into shorter printf calls Split the command usage output into multiple printf calls so the help text does not exceed the maximum string literal length required by ISO C99. Some toolchains warn when a single concatenated string literal grows beyond 4095 characters, and the project builds with warnings treated as errors. Keeping the usage text sectioned avoids that build failure without changing the displayed help output. --- src/boot.c | 2 +- src/config.c | 44 +++++ src/container.c | 26 ++- src/include/droidspace.h | 17 +- src/main.c | 262 ++++++++++++++++++----------- src/monitor.c | 5 + src/net/network.c | 355 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 592 insertions(+), 119 deletions(-) diff --git a/src/boot.c b/src/boot.c index ab1b8748..99e9d07e 100644 --- a/src/boot.c +++ b/src/boot.c @@ -143,7 +143,7 @@ int internal_boot(struct ds_config *cfg) { } /* Configure our side of the veth (or just loopback for DS_NET_NONE) */ - if (cfg->net_mode == DS_NET_NAT) { + if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_GATEWAY) { setup_veth_child_side_named(cfg, hs.peer_name, hs.ip_str); } else { /* DS_NET_NONE: just bring up loopback */ diff --git a/src/config.c b/src/config.c index 7ba72588..d96efba8 100644 --- a/src/config.c +++ b/src/config.c @@ -364,12 +364,34 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { cfg->net_mode = DS_NET_NONE; } else if (strcmp(val, "host") == 0) { cfg->net_mode = DS_NET_HOST; + } else if (strcmp(val, "gateway") == 0 || + strcmp(val, "delegated-gateway") == 0) { + cfg->net_mode = DS_NET_GATEWAY; } else { ds_warn( "Unknown network mode '%s' in config file. Defaulting to 'host'.", val); cfg->net_mode = DS_NET_HOST; } + } else if (strcmp(key, "gateway_container") == 0) { + if (validate_container_name(val)) + safe_strncpy(cfg->gateway_container, val, + sizeof(cfg->gateway_container)); + else + ds_warn("config: ignoring invalid gateway_container '%s'", val); + } else if (strcmp(key, "gateway_net") == 0) { + safe_strncpy(cfg->gateway_net, val, sizeof(cfg->gateway_net)); + } else if (strcmp(key, "gateway_bridge") == 0) { + if (strlen(val) < IFNAMSIZ) + safe_strncpy(cfg->gateway_bridge, val, sizeof(cfg->gateway_bridge)); + else + ds_warn("config: ignoring too-long gateway_bridge '%s'", val); + } else if (strcmp(key, "gateway_lan_ifname") == 0) { + if (strlen(val) < IFNAMSIZ) + safe_strncpy(cfg->gateway_lan_ifname, val, + sizeof(cfg->gateway_lan_ifname)); + else + ds_warn("config: ignoring too-long gateway_lan_ifname '%s'", val); } else if (strcmp(key, "upstream_interfaces") == 0) { /* Comma-separated interface names, e.g. "wlan0,rmnet0,ccmni1" */ char copy[1024]; @@ -688,10 +710,23 @@ static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "net_mode=nat\n"); } else if (cfg->net_mode == DS_NET_NONE) { fprintf(f, "net_mode=none\n"); + } else if (cfg->net_mode == DS_NET_GATEWAY) { + fprintf(f, "net_mode=gateway\n"); } else { fprintf(f, "net_mode=host\n"); } + if (cfg->net_mode == DS_NET_GATEWAY) { + if (cfg->gateway_container[0]) + fprintf(f, "gateway_container=%s\n", cfg->gateway_container); + if (cfg->gateway_net[0]) + fprintf(f, "gateway_net=%s\n", cfg->gateway_net); + if (cfg->gateway_bridge[0]) + fprintf(f, "gateway_bridge=%s\n", cfg->gateway_bridge); + if (cfg->gateway_lan_ifname[0]) + fprintf(f, "gateway_lan_ifname=%s\n", cfg->gateway_lan_ifname); + } + if (cfg->net_mode == DS_NET_NAT && cfg->upstream_iface_count > 0) { fprintf(f, "upstream_interfaces="); for (int i = 0; i < cfg->upstream_iface_count; i++) { @@ -868,6 +903,15 @@ int ds_config_validate(struct ds_config *cfg) { } } + if (cfg->net_mode == DS_NET_GATEWAY) { + if (!cfg->gateway_container[0]) + errors++; + else if (!validate_container_name(cfg->gateway_container)) + errors++; + else if (strcmp(cfg->gateway_container, cfg->container_name) == 0) + errors++; + } + return (errors > 0) ? -1 : 0; } diff --git a/src/container.c b/src/container.c index 63505c2d..7a8b34be 100644 --- a/src/container.c +++ b/src/container.c @@ -264,8 +264,8 @@ void cleanup_container_resources(struct ds_config *cfg, pid_t pid, * if no external lock is active. */ } - /* Network cleanup: remove host veth and iptables rules */ - if (cfg->net_mode == DS_NET_NAT) { + /* Network cleanup: remove host veth and owned network state */ + if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_GATEWAY) { ds_net_cleanup(cfg, pid > 0 ? pid : cfg->container_pid); } @@ -1552,6 +1552,9 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { case DS_NET_NONE: net = "none"; break; + case DS_NET_GATEWAY: + net = "gateway"; + break; default: net = "host"; break; @@ -1572,6 +1575,14 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { } printf("\n"); } + } else if (cfg->net_mode == DS_NET_GATEWAY) { + printf("GATEWAY_CONTAINER=%s\n", cfg->gateway_container); + printf("GATEWAY_NET=%s\n", + cfg->gateway_net[0] ? cfg->gateway_net : "lan"); + if (cfg->gateway_bridge[0]) + printf("GATEWAY_BRIDGE=%s\n", cfg->gateway_bridge); + printf("GATEWAY_IFACE=%s\n", + cfg->gateway_lan_ifname[0] ? cfg->gateway_lan_ifname : "eth1"); } printf("DISABLE_IPV6=%d\n", cfg->disable_ipv6); @@ -1702,6 +1713,9 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { case DS_NET_NONE: net = "none"; break; + case DS_NET_GATEWAY: + net = "gateway"; + break; default: net = "host"; break; @@ -1709,7 +1723,13 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { printf(" Networking: %s\n", net); feat_count++; - /* 2. NAT Configuration (IP, Upstream, Ports) */ + /* 2. NAT/Gateway Configuration */ + if (cfg->net_mode == DS_NET_GATEWAY) { + printf(" Gateway: %s (%s)\n", cfg->gateway_container, + cfg->gateway_net[0] ? cfg->gateway_net : "lan"); + feat_count++; + } + if (cfg->net_mode == DS_NET_NAT) { const char *ip = cfg->static_nat_ip[0] ? cfg->static_nat_ip : cfg->nat_container_ip; diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 7cc0c7e2..9c70e0c2 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -201,9 +201,10 @@ int check_ns(int flag, const char *name); /* Networking modes */ enum ds_net_mode { - DS_NET_HOST = 0, /* share host network namespace (default) */ - DS_NET_NAT, /* isolated netns + bridge + MASQUERADE */ - DS_NET_NONE, /* isolated netns with loopback only */ + DS_NET_HOST = 0, /* share host network namespace (default) */ + DS_NET_NAT, /* isolated netns + bridge + MASQUERADE */ + DS_NET_NONE, /* isolated netns with loopback only */ + DS_NET_GATEWAY, /* isolated netns attached to gateway LAN */ }; /* Opaque RTNETLINK context - defined in ds_netlink.c */ @@ -324,8 +325,12 @@ struct ds_config { char container_name[256]; /* --name= (mandatory) */ char hostname[256]; /* --hostname= or container_name */ char dns_servers[1024]; /* --dns= (comma/space separated) */ - enum ds_net_mode net_mode; /* --net=host|nat|none */ + enum ds_net_mode net_mode; /* --net=host|nat|none|gateway */ char dns_server_content[1024]; /* In-memory DNS config for boot */ + char gateway_container[256]; /* --gateway=NAME for gateway mode */ + char gateway_net[64]; /* --gateway-net=NAME (default: lan) */ + char gateway_bridge[IFNAMSIZ]; /* optional host bridge name */ + char gateway_lan_ifname[IFNAMSIZ]; /* ifname inside gateway (default: eth1) */ /* UUID for PID discovery */ char uuid[DS_UUID_LEN + 1]; @@ -647,6 +652,10 @@ int fix_networking_rootfs(struct ds_config *cfg); /* NAT veth/bridge lifecycle */ int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid); + +/* Gateway LAN lifecycle: bridge-only veth plumbing, no NAT/DHCP/firewall. */ +int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid); + int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, const char *ip_str); /* Populate a ds_net_handshake from a container init PID + resolved config. diff --git a/src/main.c b/src/main.c index 7f2a03a0..0c776b66 100644 --- a/src/main.c +++ b/src/main.c @@ -23,95 +23,99 @@ void print_usage(void) { printf("by " C_CYAN "%s" C_RESET "\n", DS_AUTHOR); printf("\n" C_BLUE "%s" C_RESET "\n", DS_REPO); printf(C_DIM "Built on: %s %s" C_RESET "\n\n", __DATE__, __TIME__); - printf( - "Usage: droidspaces [options] [args]\n\n" C_BOLD - "Commands:" C_RESET "\n" - " start Start a new container\n" - " stop Stop one or more containers\n" - " restart Restart a container\n" - " enter [user] Enter a running container\n" - " run [args] Run a command in a running container\n" - " usage Show container uptime, CPU and RAM usage\n" - " info Show detailed container info\n" - " pid Show the live PID of the container init\n" - " show List all running containers\n" - " scan Scan for untracked containers\n" - " check Check system requirements\n" - " docs Show interactive documentation\n" - " help Show this help message\n" - " version Show version information\n" - " daemon Run daemon mode (use --foreground for " - "foreground execution)\n\n" - - C_BOLD "Options (Container Setup):" C_RESET "\n" - " -r, --rootfs=PATH Path to rootfs directory\n" - " -i, --rootfs-img=PATH Path to rootfs image (.img)\n" - " -n, --name=NAME Container name (mandatory)\n" - " -h, --hostname=NAME Set container hostname\n" - " -C, --conf=PATH Load configuration from file\n\n" - - C_BOLD "Options (Networking):" C_RESET "\n" - " --net=MODE Modes: host (default), nat, none\n" - " --nat-ip=IP Assign a fixed IP in 172.28.*.* range (nat " - "mode)\n" - " --upstream IFACE Upstream interface(s) (supports wildcards, " - "e.g. rmnet*)\n" - " e.g. --upstream wlan0 or --upstream " - "wlan0,rmnet*\n" - " --port [H:]C[/P] Forward ports (supports ranges and " - "symmetric ports)\n" - " e.g. --port 22, 80:80/tcp, " - "1000-2000:1000-2000/udp\n" - " -d, --dns=SERVERS Set custom DNS servers (comma separated)\n" - " e.g. --dns 1.1.1.1,8.8.8.8\n" - " -I, --disable-ipv6 Disable IPv6 inside the container\n\n" - - C_BOLD "Options (Integration & Hardware):" C_RESET "\n" - " -S, --enable-android-storage\n" - " Mount Android internal storage (/sdcard)\n" - " -H, --hw-access Enable direct hardware access (/dev nodes)\n" - " --gpu Enable GPU acceleration nodes\n" - " -X, --termux-x11 Configure Termux-X11 display support\n" - " --tx11-flags=\"FLAGS\" Extra flags passed to termux-x11\n" - " --virgl Configure VirGL 3D acceleration support\n" - " --virgl-flags=\"FLAGS\" Extra flags passed to " - "virgl_test_server_android\n" - " --pulse-audio Configure PulseAudio sound server " - "support\n\n" - - C_BOLD "Options (Security & Boot):" C_RESET "\n" - " -P, --selinux-permissive Set host SELinux to permissive mode\n" - " -V, --volatile Discard changes on exit (OverlayFS)\n" - " --force-cgroupv1 Force legacy cgroup v1 hierarchy\n" - " --block-nested-namespaces\n" - " Manual Deadlock Shield (no nested " - "namespaces)\n" - " --memory=LIMIT Memory limit (e.g. 512M, 2G)\n" - " --cpus=COUNT CPU limit (e.g. 1.5, 2)\n" - " --pids-limit=N Max number of PIDs\n" - " --privileged=TAGS Relax security: nomask, nocaps, noseccomp, " - "shared, unfiltered-dev, full\n\n" - - C_BOLD "Options (Advanced):" C_RESET "\n" - " -f, --foreground Run in foreground (attach console)\n" - " --init=PATH Custom init binary (default: /sbin/init)\n" - " -u, --user=USER Run command as USER (for 'run' command " - "only)\n" - " -E, --env=PATH Load environment variables from file\n" - " -B, --bind=SRC:DEST[:ro] Bind mount host directory into container\n" - " Supports multiple flags or " - "comma-separation\n" - " e.g. -B /data:/data -B /tmp:/tmp\n" - " e.g. -B /data:/data,/tmp:/tmp\n" - " --reset Reset config to defaults (keeps " - "name/rootfs)\n" - " --format Machine-parseable output (KEY=VALUE)\n" - " --help Show this help message\n\n" - - C_BOLD "Examples:" C_RESET "\n" - " droidspaces --name=mycontainer --rootfs=/path/to/rootfs start\n" - " droidspaces --name=mycontainer enter\n" - " droidspaces --name=mycontainer stop\n\n"); + printf("Usage: droidspaces [options] [args]\n\n"); + + printf(C_BOLD "Commands:" C_RESET "\n" + " start Start a new container\n" + " stop Stop one or more containers\n" + " restart Restart a container\n" + " enter [user] Enter a running container\n" + " run [args] Run a command in a running container\n" + " usage Show container uptime, CPU and RAM usage\n" + " info Show detailed container info\n" + " pid Show the live PID of the container init\n" + " show List all running containers\n" + " scan Scan for untracked containers\n" + " check Check system requirements\n" + " docs Show interactive documentation\n" + " help Show this help message\n" + " version Show version information\n" + " daemon Run daemon mode (use --foreground for " + "foreground execution)\n\n"); + + printf(C_BOLD "Options (Container Setup):" C_RESET "\n" + " -r, --rootfs=PATH Path to rootfs directory\n" + " -i, --rootfs-img=PATH Path to rootfs image (.img)\n" + " -n, --name=NAME Container name (mandatory)\n" + " -h, --hostname=NAME Set container hostname\n" + " -C, --conf=PATH Load configuration from file\n\n"); + + printf(C_BOLD "Options (Networking):" C_RESET "\n" + " --net=MODE Modes: host (default), nat, none, gateway\n" + " --gateway=NAME Gateway container for --net=gateway\n" + " --gateway-net=NAME Gateway LAN name/bridge suffix (default: lan)\n" + " --gateway-iface=IFACE Interface name inside gateway (default: eth1)\n" + " --gateway-bridge=BR Host bridge for gateway LAN (default: ds-NAME)\n" + " --nat-ip=IP Assign a fixed IP in 172.28.*.* range (nat " + "mode)\n" + " --upstream IFACE Upstream interface(s) (supports wildcards, " + "e.g. rmnet*)\n" + " e.g. --upstream wlan0 or --upstream " + "wlan0,rmnet*\n" + " --port [H:]C[/P] Forward ports (supports ranges and " + "symmetric ports)\n" + " e.g. --port 22, 80:80/tcp, " + "1000-2000:1000-2000/udp\n" + " -d, --dns=SERVERS Set custom DNS servers (comma separated)\n" + " e.g. --dns 1.1.1.1,8.8.8.8\n" + " -I, --disable-ipv6 Disable IPv6 inside the container\n\n"); + + printf(C_BOLD "Options (Integration & Hardware):" C_RESET "\n" + " -S, --enable-android-storage\n" + " Mount Android internal storage (/sdcard)\n" + " -H, --hw-access Enable direct hardware access (/dev nodes)\n" + " --gpu Enable GPU acceleration nodes\n" + " -X, --termux-x11 Configure Termux-X11 display support\n" + " --tx11-flags=\"FLAGS\" Extra flags passed to termux-x11\n" + " --virgl Configure VirGL 3D acceleration support\n" + " --virgl-flags=\"FLAGS\" Extra flags passed to " + "virgl_test_server_android\n" + " --pulse-audio Configure PulseAudio sound server " + "support\n\n"); + + printf(C_BOLD "Options (Security & Boot):" C_RESET "\n" + " -P, --selinux-permissive Set host SELinux to permissive mode\n" + " -V, --volatile Discard changes on exit (OverlayFS)\n" + " --force-cgroupv1 Force legacy cgroup v1 hierarchy\n" + " --block-nested-namespaces\n" + " Manual Deadlock Shield (no nested " + "namespaces)\n" + " --memory=LIMIT Memory limit (e.g. 512M, 2G)\n" + " --cpus=COUNT CPU limit (e.g. 1.5, 2)\n" + " --pids-limit=N Max number of PIDs\n" + " --privileged=TAGS Relax security: nomask, nocaps, noseccomp, " + "shared, unfiltered-dev, full\n\n"); + + printf(C_BOLD "Options (Advanced):" C_RESET "\n" + " -f, --foreground Run in foreground (attach console)\n" + " --init=PATH Custom init binary (default: /sbin/init)\n" + " -u, --user=USER Run command as USER (for 'run' command " + "only)\n" + " -E, --env=PATH Load environment variables from file\n" + " -B, --bind=SRC:DEST[:ro] Bind mount host directory into container\n" + " Supports multiple flags or " + "comma-separation\n" + " e.g. -B /data:/data -B /tmp:/tmp\n" + " e.g. -B /data:/data,/tmp:/tmp\n" + " --reset Reset config to defaults (keeps " + "name/rootfs)\n" + " --format Machine-parseable output (KEY=VALUE)\n" + " --help Show this help message\n\n"); + + printf(C_BOLD "Examples:" C_RESET "\n" + " droidspaces --name=mycontainer --rootfs=/path/to/rootfs start\n" + " droidspaces --name=mycontainer enter\n" + " droidspaces --name=mycontainer stop\n\n"); } /* --------------------------------------------------------------------------- @@ -201,6 +205,30 @@ static int validate_configuration_cli(struct ds_config *cfg) { } } + if (cfg->net_mode == DS_NET_GATEWAY) { + if (!cfg->gateway_container[0]) { + ds_error("--net=gateway requires --gateway=."); + errors++; + } else if (reject_container_name(cfg->gateway_container) < 0) { + errors++; + } else if (strcmp(cfg->gateway_container, cfg->container_name) == 0) { + ds_error("A container cannot use itself as --gateway."); + errors++; + } + + if (cfg->gateway_bridge[0] && strlen(cfg->gateway_bridge) >= IFNAMSIZ) { + ds_error("--gateway-bridge interface name is too long: %s", + cfg->gateway_bridge); + errors++; + } + if (cfg->gateway_lan_ifname[0] && + strlen(cfg->gateway_lan_ifname) >= IFNAMSIZ) { + ds_error("--gateway-iface interface name is too long: %s", + cfg->gateway_lan_ifname); + errors++; + } + } + return (errors > 0) ? -1 : 0; } @@ -264,20 +292,18 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { "IPv6 is already inactive in NAT mode - --disable-ipv6 has no effect."); } - if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_NONE) { + if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_NONE || + cfg->net_mode == DS_NET_GATEWAY) { if (!check_ns(CLONE_NEWNET, "net")) { printf("\n" C_RED C_BOLD "[ FATAL: NETWORK NAMESPACE UNSUPPORTED ]" C_RESET "\n\n"); ds_error("Kernel does not support CLONE_NEWNET (network namespaces)."); - ds_log("Cannot use --net=nat or --net=none."); + ds_log("Cannot use --net=nat, --net=none, or --net=gateway."); ds_log("Tip: Use --net=host (default) for shared host networking."); exit(EXIT_FAILURE); } } - if (cfg->net_mode != DS_NET_NAT) - return; - /* --upstream and --port are only meaningful with --net=nat */ if (cfg->upstream_iface_count > 0 && cfg->net_mode != DS_NET_NAT) { ds_warn("--upstream is only valid with --net=nat - ignoring"); @@ -288,6 +314,25 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { cfg->port_forward_count = 0; } + if (cfg->net_mode == DS_NET_GATEWAY) { + char reason[512]; + int probe = ds_nl_probe_nat_capability(reason, sizeof(reason)); + if (probe < 0 || probe == 1) { + printf("\n" C_RED C_BOLD + "[ FATAL: GATEWAY NETWORKING UNSUPPORTED ]" C_RESET "\n\n"); + ds_error("--net=gateway requires NET_NS + VETH + BRIDGE support:\n %s", + reason); + ds_log("Tip: use --net=nat/host, or rebuild your kernel with " + "CONFIG_VETH=y and CONFIG_BRIDGE=y."); + exit(1); + } + ds_log("[NET] Kernel capability probe passed for --net=gateway."); + return; + } + + if (cfg->net_mode != DS_NET_NAT) + return; + /* --upstream is mandatory when using --net=nat */ if (cfg->upstream_iface_count == 0) { printf("\n" C_RED C_BOLD "[ FATAL: --upstream REQUIRED ]" C_RESET "\n\n"); @@ -368,6 +413,11 @@ int main(int argc, char **argv) { {"virgl", no_argument, 0, 270}, {"virgl-flags", required_argument, 0, 272}, {"pulse-audio", no_argument, 0, 273}, + {"gateway", required_argument, 0, 274}, + {"gateway-container", required_argument, 0, 274}, + {"gateway-net", required_argument, 0, 275}, + {"gateway-iface", required_argument, 0, 276}, + {"gateway-bridge", required_argument, 0, 277}, {"reset", no_argument, 0, 256}, {"format", no_argument, 0, 265}, {"memory", required_argument, 0, 266}, @@ -440,8 +490,11 @@ int main(int argc, char **argv) { cfg.net_mode = DS_NET_NONE; else if (strcmp(optarg, "host") == 0) cfg.net_mode = DS_NET_HOST; + else if (strcmp(optarg, "gateway") == 0 || + strcmp(optarg, "delegated-gateway") == 0) + cfg.net_mode = DS_NET_GATEWAY; else { - ds_error("Unknown network mode: '%s'. Valid options: host, nat, none", + ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, gateway", optarg); ret = 1; goto cleanup; @@ -623,6 +676,20 @@ int main(int argc, char **argv) { case 273: cfg.pulseaudio = 1; break; + case 274: + safe_strncpy(cfg.gateway_container, optarg, + sizeof(cfg.gateway_container)); + break; + case 275: + safe_strncpy(cfg.gateway_net, optarg, sizeof(cfg.gateway_net)); + break; + case 276: + safe_strncpy(cfg.gateway_lan_ifname, optarg, + sizeof(cfg.gateway_lan_ifname)); + break; + case 277: + safe_strncpy(cfg.gateway_bridge, optarg, sizeof(cfg.gateway_bridge)); + break; case 'I': cfg.disable_ipv6 = 1; break; @@ -690,8 +757,11 @@ int main(int argc, char **argv) { cli_net_mode = DS_NET_NONE; else if (strcmp(optarg, "host") == 0) cli_net_mode = DS_NET_HOST; + else if (strcmp(optarg, "gateway") == 0 || + strcmp(optarg, "delegated-gateway") == 0) + cli_net_mode = DS_NET_GATEWAY; else { - ds_error("Unknown network mode: '%s'. Valid options: host, nat, none", + ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, gateway", optarg); ret = 1; goto cleanup; diff --git a/src/monitor.c b/src/monitor.c index b4ef85f5..e902dd2f 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -394,6 +394,11 @@ reboot_loop:; * switches */ ds_net_start_route_monitor(); } + } else if (cfg->net_mode == DS_NET_GATEWAY) { + if (setup_gateway_veth_side(cfg, netns_pid) < 0) { + ds_warn("[NET] Monitor: setup_gateway_veth_side failed - " + "container will remain isolated"); + } } /* Send handshake to init */ diff --git a/src/net/network.c b/src/net/network.c index 1e1418ac..3d01ec18 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -57,6 +57,119 @@ static void veth_peer_ip(pid_t pid, char *buf, size_t sz) { snprintf(buf, sz, "172.28.%d.%d/%d", octet3, octet4, DS_NAT_PREFIX); } +static uint32_t ds_net_hash_string(const char *s) { + uint32_t h = 5381; + if (!s) + return h; + while (*s) + h = ((h << 5) + h) ^ (unsigned char)*s++; + return h; +} + +static void gateway_hash_key(struct ds_config *cfg, char *buf, size_t sz) { + const char *gw = (cfg && cfg->gateway_container[0]) + ? cfg->gateway_container + : "gateway"; + const char *net = + (cfg && cfg->gateway_net[0]) ? cfg->gateway_net : "lan"; + snprintf(buf, sz, "%s:%s", gw, net); +} + +static void gateway_veth_names(struct ds_config *cfg, char *host, size_t hsz, + char *peer, size_t psz) { + char key[384]; + gateway_hash_key(cfg, key, sizeof(key)); + uint32_t h = ds_net_hash_string(key); + snprintf(host, hsz, "ds-g%08x", h); + snprintf(peer, psz, "ds-h%08x", h); +} + +static int gateway_ifname_component_ok(const char *s) { + if (!s || !s[0]) + return 0; + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + if (!(isalnum(*p) || *p == '_' || *p == '-')) + return 0; + } + return 1; +} + +static void gateway_bridge_name(struct ds_config *cfg, char *buf, size_t sz) { + if (cfg && cfg->gateway_bridge[0]) { + safe_strncpy(buf, cfg->gateway_bridge, sz); + return; + } + + const char *net = (cfg && cfg->gateway_net[0]) ? cfg->gateway_net : "lan"; + char clean[10] = {0}; + size_t j = 0; + for (size_t i = 0; net[i] && j < sizeof(clean) - 1; i++) { + unsigned char c = (unsigned char)net[i]; + if (isalnum(c) || c == '_' || c == '-') + clean[j++] = (char)c; + } + if (j == 0) + safe_strncpy(clean, "lan", sizeof(clean)); + snprintf(buf, sz, "ds-%s", clean); +} + +static const char *gateway_lan_ifname(struct ds_config *cfg) { + if (cfg && cfg->gateway_lan_ifname[0]) + return cfg->gateway_lan_ifname; + return "eth1"; +} + +static int ds_netns_rename_up(const char *netns_path, const char *old_name, + const char *new_name) { + int self_fd = open("/proc/self/ns/net", O_RDONLY | O_CLOEXEC); + if (self_fd < 0) + return -errno; + + int target_fd = open(netns_path, O_RDONLY | O_CLOEXEC); + if (target_fd < 0) { + int e = -errno; + close(self_fd); + return e; + } + + int ret = 0; + if (setns(target_fd, CLONE_NEWNET) < 0) { + ret = -errno; + goto out_restore; + } + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ret = -errno; + goto out_restore; + } + + if (new_name && new_name[0] && ds_nl_link_exists(ctx, new_name)) { + ds_log("[NET] Gateway: interface %s already exists inside gateway", + new_name); + ds_nl_link_up(ctx, new_name); + } else if (old_name && old_name[0] && new_name && new_name[0] && + strcmp(old_name, new_name) != 0) { + if (ds_nl_rename(ctx, old_name, new_name) < 0) { + ds_warn("[NET] Gateway: failed to rename %s to %s", old_name, new_name); + ret = -1; + } else { + ds_nl_link_up(ctx, new_name); + } + } else if (old_name && old_name[0]) { + ds_nl_link_up(ctx, old_name); + } + + ds_nl_close(ctx); + +out_restore: + if (setns(self_fd, CLONE_NEWNET) < 0 && ret == 0) + ret = -errno; + close(target_fd); + close(self_fd); + return ret; +} + /* --------------------------------------------------------------------------- * Upstream routing globals - shared by android routing setup and monitor * ---------------------------------------------------------------------------*/ @@ -171,11 +284,13 @@ void ds_net_derive_handshake(pid_t init_pid, struct ds_config *cfg, * ip_str is informational on the child side (voided in * setup_veth_child_side_named) but the boot.c log line prints it, * so it should reflect the actual IP the DHCP server will offer. */ - if (cfg && cfg->static_nat_ip[0]) + if (cfg && cfg->net_mode == DS_NET_NAT && cfg->static_nat_ip[0]) safe_strncpy(hs->ip_str, cfg->static_nat_ip, sizeof(hs->ip_str)); - else + else if (cfg && cfg->net_mode == DS_NET_NAT) veth_peer_ip(init_pid, hs->ip_str, sizeof(hs->ip_str)); /* last-resort fallback */ + else + hs->ip_str[0] = '\0'; } /* --------------------------------------------------------------------------- @@ -737,6 +852,188 @@ int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid) { return 0; } +/* --------------------------------------------------------------------------- + * setup_gateway_veth_side + * + * Phase-1 OpenWrt gateway mode. Droidspaces owns only the L2 plumbing: + * - create/reuse a bridge-only LAN with no IP address + * - attach one veth peer to the designated gateway container netns + * - attach one veth peer to the application container netns + * + * No NAT, DHCP, DNS, port-forwarding, Android policy routing, or firewall + * policy is installed here. The gateway container is expected to run the + * routing policy engine (OpenWrt netifd/firewall/dnsmasq/etc.). + * ---------------------------------------------------------------------------*/ + +int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { + if (!cfg || !cfg->gateway_container[0]) { + ds_warn("[NET] Gateway: no gateway container configured"); + return -1; + } + + if (strcmp(cfg->gateway_container, cfg->container_name) == 0) { + ds_warn("[NET] Gateway: refusing to attach container to itself"); + return -1; + } + + const char *gw_if = gateway_lan_ifname(cfg); + if (strlen(gw_if) >= IFNAMSIZ || !gateway_ifname_component_ok(gw_if)) { + ds_warn("[NET] Gateway: invalid gateway interface name '%s'", gw_if); + return -1; + } + + struct ds_config gw_cfg; + memset(&gw_cfg, 0, sizeof(gw_cfg)); + gw_cfg.net_ready_pipe[0] = gw_cfg.net_ready_pipe[1] = -1; + gw_cfg.net_done_pipe[0] = gw_cfg.net_done_pipe[1] = -1; + safe_strncpy(gw_cfg.container_name, cfg->gateway_container, + sizeof(gw_cfg.container_name)); + + /* Best effort: loading the config gives UUID fallback discovery, but the + * fast pidfile path still works when the config file is unavailable. */ + (void)ds_config_load_by_name(cfg->gateway_container, &gw_cfg); + + pid_t gw_pid = 0; + if (!is_container_running(&gw_cfg, &gw_pid) || gw_pid <= 0) { + ds_warn("[NET] Gateway: container '%s' is not running", + cfg->gateway_container); + ds_config_free(&gw_cfg); + return -1; + } + + char bridge[IFNAMSIZ]; + char gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; + char app_host[IFNAMSIZ], app_peer[IFNAMSIZ]; + gateway_bridge_name(cfg, bridge, sizeof(bridge)); + gateway_veth_names(cfg, gw_host, sizeof(gw_host), gw_peer, sizeof(gw_peer)); + veth_host_name(child_pid, app_host, sizeof(app_host)); + veth_peer_name(child_pid, app_peer, sizeof(app_peer)); + + ds_log("[NET] Gateway: wiring %s to gateway %s via bridge %s", + cfg->container_name, cfg->gateway_container, bridge); + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ds_warn("[NET] Gateway: failed to open RTNETLINK socket"); + ds_config_free(&gw_cfg); + return -1; + } + + if (!ds_nl_link_exists(ctx, bridge)) { + ds_log("[NET] Gateway: creating delegated LAN bridge %s", bridge); + if (ds_nl_create_bridge(ctx, bridge) < 0) { + ds_warn("[NET] Gateway: failed to create bridge %s", bridge); + ds_nl_close(ctx); + ds_config_free(&gw_cfg); + return -1; + } + } + + if (ds_nl_link_up(ctx, bridge) < 0) + ds_warn("[NET] Gateway: failed to bring up bridge %s", bridge); + + /* Keep the bridge policy-neutral: no address, no DHCP, no NAT. Disable + * bridge netfilter calls so OpenWrt's own firewalling remains the visible + * policy authority inside the delegated network. */ + write_file("/proc/sys/net/bridge/bridge-nf-call-iptables", "0"); + write_file("/proc/sys/net/bridge/bridge-nf-call-ip6tables", "0"); + + /* Lazily attach a LAN-side interface to the gateway if it is not already + * present. This allows the gateway container to boot with its WAN side + * first, then receive its LAN cable when the first client is attached. */ + if (!ds_nl_link_exists(ctx, gw_host)) { + char gw_netns[PATH_MAX]; + snprintf(gw_netns, sizeof(gw_netns), "/proc/%d/ns/net", (int)gw_pid); + + ds_log("[NET] Gateway: creating gateway veth %s <-> %s", gw_host, + gw_peer); + if (ds_nl_create_veth(ctx, gw_host, gw_peer) < 0) { + ds_warn("[NET] Gateway: failed to create gateway veth pair"); + ds_nl_close(ctx); + ds_config_free(&gw_cfg); + return -1; + } + + ds_net_disable_tx_checksum(gw_host); + if (ds_nl_set_master(ctx, gw_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to attach %s to %s", gw_host, bridge); + if (ds_nl_link_up(ctx, gw_host) < 0) + ds_warn("[NET] Gateway: failed to bring up %s", gw_host); + + int gw_netns_fd = open(gw_netns, O_RDONLY | O_CLOEXEC); + if (gw_netns_fd < 0) { + ds_warn("[NET] Gateway: failed to open %s: %s", gw_netns, + strerror(errno)); + ds_nl_close(ctx); + ds_config_free(&gw_cfg); + return -1; + } + + int r = ds_nl_move_to_netns(ctx, gw_peer, gw_netns_fd); + close(gw_netns_fd); + if (r < 0) { + ds_warn("[NET] Gateway: failed to move %s into gateway netns", + gw_peer); + ds_nl_close(ctx); + ds_config_free(&gw_cfg); + return -1; + } + + if (ds_netns_rename_up(gw_netns, gw_peer, gw_if) < 0) + ds_warn("[NET] Gateway: moved %s but could not rename/up it as %s", + gw_peer, gw_if); + } else { + if (ds_nl_set_master(ctx, gw_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to reattach %s to %s", gw_host, bridge); + ds_nl_link_up(ctx, gw_host); + } + + /* Wire the application container into the delegated LAN bridge. */ + ds_nl_del_link(ctx, app_host); + ds_log("[NET] Gateway: creating app veth %s <-> %s", app_host, app_peer); + if (ds_nl_create_veth(ctx, app_host, app_peer) < 0) { + ds_warn("[NET] Gateway: failed to create app veth pair"); + ds_nl_close(ctx); + ds_config_free(&gw_cfg); + return -1; + } + + ds_net_disable_tx_checksum(app_host); + if (ds_nl_set_master(ctx, app_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to attach %s to %s", app_host, bridge); + if (ds_nl_link_up(ctx, app_host) < 0) + ds_warn("[NET] Gateway: failed to bring up %s", app_host); + + char child_netns[PATH_MAX]; + snprintf(child_netns, sizeof(child_netns), "/proc/%d/ns/net", + (int)child_pid); + int child_netns_fd = open(child_netns, O_RDONLY | O_CLOEXEC); + if (child_netns_fd < 0) { + ds_warn("[NET] Gateway: failed to open container netns %s: %s", + child_netns, strerror(errno)); + ds_nl_close(ctx); + ds_config_free(&gw_cfg); + return -1; + } + + int r = ds_nl_move_to_netns(ctx, app_peer, child_netns_fd); + close(child_netns_fd); + if (r < 0) { + ds_warn("[NET] Gateway: failed to move %s into container netns", + app_peer); + ds_nl_close(ctx); + ds_config_free(&gw_cfg); + return -1; + } + + ds_nl_close(ctx); + ds_config_free(&gw_cfg); + + ds_log("[NET] Gateway: delegated LAN ready: %s -> %s -> %s", + cfg->container_name, bridge, cfg->gateway_container); + return 0; +} + /* --------------------------------------------------------------------------- * setup_veth_child_side_named * @@ -771,7 +1068,10 @@ int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, ds_nl_link_up(ctx, "eth0"); ds_nl_close(ctx); - ds_log("[NET] Child: eth0 UP - awaiting DHCP lease from monitor"); + if (cfg && cfg->net_mode == DS_NET_GATEWAY) + ds_log("[NET] Child: eth0 UP - gateway container owns DHCP/routing"); + else + ds_log("[NET] Child: eth0 UP - awaiting DHCP lease from monitor"); return 0; } @@ -798,8 +1098,12 @@ int fix_networking_rootfs(struct ds_config *cfg) { char hosts_content[1024]; const char *hostname = (cfg->hostname[0]) ? cfg->hostname : "localhost"; - /* IPv6 is only enabled in host mode unless explicitly disabled */ - int ipv6_enabled = (cfg->net_mode == DS_NET_HOST && !cfg->disable_ipv6); + /* IPv6 is enabled in host mode and gateway mode unless explicitly disabled. + * Gateway mode is policy-owned by OpenWrt, so IPv6 RA/DHCPv6 should be able + * to operate inside the application container netns. */ + int ipv6_enabled = ((cfg->net_mode == DS_NET_HOST || + cfg->net_mode == DS_NET_GATEWAY) && + !cfg->disable_ipv6); if (ipv6_enabled) { snprintf(hosts_content, sizeof(hosts_content), "127.0.0.1\tlocalhost\n" @@ -817,16 +1121,18 @@ int fix_networking_rootfs(struct ds_config *cfg) { write_file("/etc/hosts", hosts_content); - /* 3. resolv.conf - always use dns_server_content which ds_get_dns_servers() - * already populated with either the user's --dns servers or the compiled-in - * defaults (1.1.1.1 / 8.8.8.8). */ - mkdir("/run/resolvconf", 0755); - write_file("/run/resolvconf/resolv.conf", cfg->dns_server_content); - - /* Link /etc/resolv.conf */ - unlink("/etc/resolv.conf"); - if (symlink("/run/resolvconf/resolv.conf", "/etc/resolv.conf") < 0) { - ds_warn("Failed to link /etc/resolv.conf: %s", strerror(errno)); + /* 3. resolv.conf. In gateway mode, DNS is owned by the gateway + * container's DHCP/DNS service, so avoid overwriting resolver state unless + * the user explicitly supplied --dns. */ + if (cfg->net_mode != DS_NET_GATEWAY || cfg->dns_servers[0]) { + mkdir("/run/resolvconf", 0755); + write_file("/run/resolvconf/resolv.conf", cfg->dns_server_content); + + /* Link /etc/resolv.conf */ + unlink("/etc/resolv.conf"); + if (symlink("/run/resolvconf/resolv.conf", "/etc/resolv.conf") < 0) { + ds_warn("Failed to link /etc/resolv.conf: %s", strerror(errno)); + } } if (!ipv6_enabled) { @@ -1228,6 +1534,25 @@ void ds_net_start_route_monitor(void) { * ---------------------------------------------------------------------------*/ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { + if (cfg->net_mode == DS_NET_GATEWAY) { + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) + return; + + char veth_host[IFNAMSIZ] = {0}; + pid_t effective_pid = container_pid > 0 ? container_pid : cfg->container_pid; + if (effective_pid > 0) { + veth_host_name(effective_pid, veth_host, sizeof(veth_host)); + ds_nl_del_link(ctx, veth_host); + ds_log("[NET] Gateway cleanup: removed %s", veth_host); + } else { + ds_warn("[NET] Gateway cleanup: cannot derive veth name - no valid PID"); + } + + ds_nl_close(ctx); + return; + } + if (cfg->net_mode != DS_NET_NAT) return; From a5bc2cd4ca4a6e811aad2c4a6c9bc93a176bf9c6 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 10 Jun 2026 21:17:27 +0530 Subject: [PATCH 037/211] Makefile: add make format target to automate clang-format on source files Signed-off-by: ravindu644 --- Makefile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8ea48fab..00e6ed61 100644 --- a/Makefile +++ b/Makefile @@ -100,7 +100,7 @@ find-cc = $(shell \ echo ""; \ fi) -.PHONY: all help clean native x86_64 aarch64 armhf x86 riscv64 all-build tarball all-tarball debug-hardened +.PHONY: all help clean native x86_64 aarch64 armhf x86 riscv64 all-build tarball all-tarball debug-hardened format all: help @@ -128,6 +128,7 @@ help: @echo "Other:" @echo " make clean - Remove build artifacts" @echo " make debug-hardened - Build with ASan/UBSan/LSan to find bugs" + @echo " make format - Run clang-format on all .c/.h/.cpp files" $(OUT_DIR): $(Q)mkdir -p $(OUT_DIR) @@ -275,6 +276,11 @@ all-tarball: all-build $(MAKE) --no-print-directory sync-android; \ echo "[+] Created: $$TARBALL ($$(du -h $$TARBALL | cut -f1))" +format: + @command -v clang-format >/dev/null 2>&1 || { echo "Error: clang-format not found"; exit 1; } + @find $(SRC_DIR) -name '*.c' -o -name '*.h' -o -name '*.cpp' | xargs clang-format -i + @echo "[+] Formatted all source files" + clean: @rm -rf $(OUT_DIR) $(BINARY_NAME)-*.tar.gz @echo "[+] Cleaned build artifacts" From 61d400a7742fe1fba94b73e6d810ee6df5e965e1 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 10 Jun 2026 21:17:57 +0530 Subject: [PATCH 038/211] clang-format all the source files Signed-off-by: ravindu644 --- src/daemon.c | 3 +- src/include/droidspace.h | 30 +++--- src/main.c | 192 +++++++++++++++++++++------------------ src/net/network.c | 33 +++---- 4 files changed, 135 insertions(+), 123 deletions(-) diff --git a/src/daemon.c b/src/daemon.c index db9d15b6..a9ee0538 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -42,7 +42,8 @@ #define DS_MAX_ARGC 64 #define DS_MAX_ARG 8192 #define DS_IOBUF 8192 -#define PTY_WBUF_MAX (256 * 1024) /* absolute buffer cap for PTY master input \ +#define PTY_WBUF_MAX \ + (256 * 1024) /* absolute buffer cap for PTY master input \ */ #define PTY_WBUF_HIGH (192 * 1024) /* suspend conn EPOLLIN above this */ #define PTY_WBUF_LOW (64 * 1024) /* resume conn EPOLLIN below this */ diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 9c70e0c2..dd9694c0 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -201,10 +201,10 @@ int check_ns(int flag, const char *name); /* Networking modes */ enum ds_net_mode { - DS_NET_HOST = 0, /* share host network namespace (default) */ - DS_NET_NAT, /* isolated netns + bridge + MASQUERADE */ - DS_NET_NONE, /* isolated netns with loopback only */ - DS_NET_GATEWAY, /* isolated netns attached to gateway LAN */ + DS_NET_HOST = 0, /* share host network namespace (default) */ + DS_NET_NAT, /* isolated netns + bridge + MASQUERADE */ + DS_NET_NONE, /* isolated netns with loopback only */ + DS_NET_GATEWAY, /* isolated netns attached to gateway LAN */ }; /* Opaque RTNETLINK context - defined in ds_netlink.c */ @@ -319,17 +319,17 @@ typedef enum { struct ds_config { /* Paths */ - char rootfs_path[PATH_MAX]; /* --rootfs= */ - char rootfs_img_path[PATH_MAX]; /* --rootfs-img= */ - char pidfile[PATH_MAX]; /* --pidfile= or auto-resolved */ - char container_name[256]; /* --name= (mandatory) */ - char hostname[256]; /* --hostname= or container_name */ - char dns_servers[1024]; /* --dns= (comma/space separated) */ - enum ds_net_mode net_mode; /* --net=host|nat|none|gateway */ - char dns_server_content[1024]; /* In-memory DNS config for boot */ - char gateway_container[256]; /* --gateway=NAME for gateway mode */ - char gateway_net[64]; /* --gateway-net=NAME (default: lan) */ - char gateway_bridge[IFNAMSIZ]; /* optional host bridge name */ + char rootfs_path[PATH_MAX]; /* --rootfs= */ + char rootfs_img_path[PATH_MAX]; /* --rootfs-img= */ + char pidfile[PATH_MAX]; /* --pidfile= or auto-resolved */ + char container_name[256]; /* --name= (mandatory) */ + char hostname[256]; /* --hostname= or container_name */ + char dns_servers[1024]; /* --dns= (comma/space separated) */ + enum ds_net_mode net_mode; /* --net=host|nat|none|gateway */ + char dns_server_content[1024]; /* In-memory DNS config for boot */ + char gateway_container[256]; /* --gateway=NAME for gateway mode */ + char gateway_net[64]; /* --gateway-net=NAME (default: lan) */ + char gateway_bridge[IFNAMSIZ]; /* optional host bridge name */ char gateway_lan_ifname[IFNAMSIZ]; /* ifname inside gateway (default: eth1) */ /* UUID for PID discovery */ diff --git a/src/main.c b/src/main.c index 0c776b66..c6f26183 100644 --- a/src/main.c +++ b/src/main.c @@ -25,94 +25,108 @@ void print_usage(void) { printf(C_DIM "Built on: %s %s" C_RESET "\n\n", __DATE__, __TIME__); printf("Usage: droidspaces [options] [args]\n\n"); - printf(C_BOLD "Commands:" C_RESET "\n" - " start Start a new container\n" - " stop Stop one or more containers\n" - " restart Restart a container\n" - " enter [user] Enter a running container\n" - " run [args] Run a command in a running container\n" - " usage Show container uptime, CPU and RAM usage\n" - " info Show detailed container info\n" - " pid Show the live PID of the container init\n" - " show List all running containers\n" - " scan Scan for untracked containers\n" - " check Check system requirements\n" - " docs Show interactive documentation\n" - " help Show this help message\n" - " version Show version information\n" - " daemon Run daemon mode (use --foreground for " - "foreground execution)\n\n"); + printf( + C_BOLD + "Commands:" C_RESET "\n" + " start Start a new container\n" + " stop Stop one or more containers\n" + " restart Restart a container\n" + " enter [user] Enter a running container\n" + " run [args] Run a command in a running container\n" + " usage Show container uptime, CPU and RAM usage\n" + " info Show detailed container info\n" + " pid Show the live PID of the container init\n" + " show List all running containers\n" + " scan Scan for untracked containers\n" + " check Check system requirements\n" + " docs Show interactive documentation\n" + " help Show this help message\n" + " version Show version information\n" + " daemon Run daemon mode (use --foreground for " + "foreground execution)\n\n"); printf(C_BOLD "Options (Container Setup):" C_RESET "\n" - " -r, --rootfs=PATH Path to rootfs directory\n" - " -i, --rootfs-img=PATH Path to rootfs image (.img)\n" - " -n, --name=NAME Container name (mandatory)\n" - " -h, --hostname=NAME Set container hostname\n" - " -C, --conf=PATH Load configuration from file\n\n"); - - printf(C_BOLD "Options (Networking):" C_RESET "\n" - " --net=MODE Modes: host (default), nat, none, gateway\n" - " --gateway=NAME Gateway container for --net=gateway\n" - " --gateway-net=NAME Gateway LAN name/bridge suffix (default: lan)\n" - " --gateway-iface=IFACE Interface name inside gateway (default: eth1)\n" - " --gateway-bridge=BR Host bridge for gateway LAN (default: ds-NAME)\n" - " --nat-ip=IP Assign a fixed IP in 172.28.*.* range (nat " - "mode)\n" - " --upstream IFACE Upstream interface(s) (supports wildcards, " - "e.g. rmnet*)\n" - " e.g. --upstream wlan0 or --upstream " - "wlan0,rmnet*\n" - " --port [H:]C[/P] Forward ports (supports ranges and " - "symmetric ports)\n" - " e.g. --port 22, 80:80/tcp, " - "1000-2000:1000-2000/udp\n" - " -d, --dns=SERVERS Set custom DNS servers (comma separated)\n" - " e.g. --dns 1.1.1.1,8.8.8.8\n" - " -I, --disable-ipv6 Disable IPv6 inside the container\n\n"); - - printf(C_BOLD "Options (Integration & Hardware):" C_RESET "\n" - " -S, --enable-android-storage\n" - " Mount Android internal storage (/sdcard)\n" - " -H, --hw-access Enable direct hardware access (/dev nodes)\n" - " --gpu Enable GPU acceleration nodes\n" - " -X, --termux-x11 Configure Termux-X11 display support\n" - " --tx11-flags=\"FLAGS\" Extra flags passed to termux-x11\n" - " --virgl Configure VirGL 3D acceleration support\n" - " --virgl-flags=\"FLAGS\" Extra flags passed to " - "virgl_test_server_android\n" - " --pulse-audio Configure PulseAudio sound server " - "support\n\n"); - - printf(C_BOLD "Options (Security & Boot):" C_RESET "\n" - " -P, --selinux-permissive Set host SELinux to permissive mode\n" - " -V, --volatile Discard changes on exit (OverlayFS)\n" - " --force-cgroupv1 Force legacy cgroup v1 hierarchy\n" - " --block-nested-namespaces\n" - " Manual Deadlock Shield (no nested " - "namespaces)\n" - " --memory=LIMIT Memory limit (e.g. 512M, 2G)\n" - " --cpus=COUNT CPU limit (e.g. 1.5, 2)\n" - " --pids-limit=N Max number of PIDs\n" - " --privileged=TAGS Relax security: nomask, nocaps, noseccomp, " - "shared, unfiltered-dev, full\n\n"); - - printf(C_BOLD "Options (Advanced):" C_RESET "\n" - " -f, --foreground Run in foreground (attach console)\n" - " --init=PATH Custom init binary (default: /sbin/init)\n" - " -u, --user=USER Run command as USER (for 'run' command " - "only)\n" - " -E, --env=PATH Load environment variables from file\n" - " -B, --bind=SRC:DEST[:ro] Bind mount host directory into container\n" - " Supports multiple flags or " - "comma-separation\n" - " e.g. -B /data:/data -B /tmp:/tmp\n" - " e.g. -B /data:/data,/tmp:/tmp\n" - " --reset Reset config to defaults (keeps " - "name/rootfs)\n" - " --format Machine-parseable output (KEY=VALUE)\n" - " --help Show this help message\n\n"); - - printf(C_BOLD "Examples:" C_RESET "\n" + " -r, --rootfs=PATH Path to rootfs directory\n" + " -i, --rootfs-img=PATH Path to rootfs image (.img)\n" + " -n, --name=NAME Container name (mandatory)\n" + " -h, --hostname=NAME Set container hostname\n" + " -C, --conf=PATH Load configuration from file\n\n"); + + printf( + C_BOLD + "Options (Networking):" C_RESET "\n" + " --net=MODE Modes: host (default), nat, none, gateway\n" + " --gateway=NAME Gateway container for --net=gateway\n" + " --gateway-net=NAME Gateway LAN name/bridge suffix (default: " + "lan)\n" + " --gateway-iface=IFACE Interface name inside gateway (default: " + "eth1)\n" + " --gateway-bridge=BR Host bridge for gateway LAN (default: " + "ds-NAME)\n" + " --nat-ip=IP Assign a fixed IP in 172.28.*.* range (nat " + "mode)\n" + " --upstream IFACE Upstream interface(s) (supports wildcards, " + "e.g. rmnet*)\n" + " e.g. --upstream wlan0 or --upstream " + "wlan0,rmnet*\n" + " --port [H:]C[/P] Forward ports (supports ranges and " + "symmetric ports)\n" + " e.g. --port 22, 80:80/tcp, " + "1000-2000:1000-2000/udp\n" + " -d, --dns=SERVERS Set custom DNS servers (comma separated)\n" + " e.g. --dns 1.1.1.1,8.8.8.8\n" + " -I, --disable-ipv6 Disable IPv6 inside the container\n\n"); + + printf( + C_BOLD + "Options (Integration & Hardware):" C_RESET "\n" + " -S, --enable-android-storage\n" + " Mount Android internal storage (/sdcard)\n" + " -H, --hw-access Enable direct hardware access (/dev nodes)\n" + " --gpu Enable GPU acceleration nodes\n" + " -X, --termux-x11 Configure Termux-X11 display support\n" + " --tx11-flags=\"FLAGS\" Extra flags passed to termux-x11\n" + " --virgl Configure VirGL 3D acceleration support\n" + " --virgl-flags=\"FLAGS\" Extra flags passed to " + "virgl_test_server_android\n" + " --pulse-audio Configure PulseAudio sound server " + "support\n\n"); + + printf( + C_BOLD + "Options (Security & Boot):" C_RESET "\n" + " -P, --selinux-permissive Set host SELinux to permissive mode\n" + " -V, --volatile Discard changes on exit (OverlayFS)\n" + " --force-cgroupv1 Force legacy cgroup v1 hierarchy\n" + " --block-nested-namespaces\n" + " Manual Deadlock Shield (no nested " + "namespaces)\n" + " --memory=LIMIT Memory limit (e.g. 512M, 2G)\n" + " --cpus=COUNT CPU limit (e.g. 1.5, 2)\n" + " --pids-limit=N Max number of PIDs\n" + " --privileged=TAGS Relax security: nomask, nocaps, noseccomp, " + "shared, unfiltered-dev, full\n\n"); + + printf( + C_BOLD + "Options (Advanced):" C_RESET "\n" + " -f, --foreground Run in foreground (attach console)\n" + " --init=PATH Custom init binary (default: /sbin/init)\n" + " -u, --user=USER Run command as USER (for 'run' command " + "only)\n" + " -E, --env=PATH Load environment variables from file\n" + " -B, --bind=SRC:DEST[:ro] Bind mount host directory into container\n" + " Supports multiple flags or " + "comma-separation\n" + " e.g. -B /data:/data -B /tmp:/tmp\n" + " e.g. -B /data:/data,/tmp:/tmp\n" + " --reset Reset config to defaults (keeps " + "name/rootfs)\n" + " --format Machine-parseable output (KEY=VALUE)\n" + " --help Show this help message\n\n"); + + printf(C_BOLD + "Examples:" C_RESET "\n" " droidspaces --name=mycontainer --rootfs=/path/to/rootfs start\n" " droidspaces --name=mycontainer enter\n" " droidspaces --name=mycontainer stop\n\n"); @@ -494,7 +508,8 @@ int main(int argc, char **argv) { strcmp(optarg, "delegated-gateway") == 0) cfg.net_mode = DS_NET_GATEWAY; else { - ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, gateway", + ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, " + "gateway", optarg); ret = 1; goto cleanup; @@ -761,7 +776,8 @@ int main(int argc, char **argv) { strcmp(optarg, "delegated-gateway") == 0) cli_net_mode = DS_NET_GATEWAY; else { - ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, gateway", + ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, " + "gateway", optarg); ret = 1; goto cleanup; diff --git a/src/net/network.c b/src/net/network.c index 3d01ec18..fa537c00 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -67,11 +67,9 @@ static uint32_t ds_net_hash_string(const char *s) { } static void gateway_hash_key(struct ds_config *cfg, char *buf, size_t sz) { - const char *gw = (cfg && cfg->gateway_container[0]) - ? cfg->gateway_container - : "gateway"; - const char *net = - (cfg && cfg->gateway_net[0]) ? cfg->gateway_net : "lan"; + const char *gw = + (cfg && cfg->gateway_container[0]) ? cfg->gateway_container : "gateway"; + const char *net = (cfg && cfg->gateway_net[0]) ? cfg->gateway_net : "lan"; snprintf(buf, sz, "%s:%s", gw, net); } @@ -945,8 +943,7 @@ int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { char gw_netns[PATH_MAX]; snprintf(gw_netns, sizeof(gw_netns), "/proc/%d/ns/net", (int)gw_pid); - ds_log("[NET] Gateway: creating gateway veth %s <-> %s", gw_host, - gw_peer); + ds_log("[NET] Gateway: creating gateway veth %s <-> %s", gw_host, gw_peer); if (ds_nl_create_veth(ctx, gw_host, gw_peer) < 0) { ds_warn("[NET] Gateway: failed to create gateway veth pair"); ds_nl_close(ctx); @@ -972,8 +969,7 @@ int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { int r = ds_nl_move_to_netns(ctx, gw_peer, gw_netns_fd); close(gw_netns_fd); if (r < 0) { - ds_warn("[NET] Gateway: failed to move %s into gateway netns", - gw_peer); + ds_warn("[NET] Gateway: failed to move %s into gateway netns", gw_peer); ds_nl_close(ctx); ds_config_free(&gw_cfg); return -1; @@ -1005,12 +1001,11 @@ int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { ds_warn("[NET] Gateway: failed to bring up %s", app_host); char child_netns[PATH_MAX]; - snprintf(child_netns, sizeof(child_netns), "/proc/%d/ns/net", - (int)child_pid); + snprintf(child_netns, sizeof(child_netns), "/proc/%d/ns/net", (int)child_pid); int child_netns_fd = open(child_netns, O_RDONLY | O_CLOEXEC); if (child_netns_fd < 0) { - ds_warn("[NET] Gateway: failed to open container netns %s: %s", - child_netns, strerror(errno)); + ds_warn("[NET] Gateway: failed to open container netns %s: %s", child_netns, + strerror(errno)); ds_nl_close(ctx); ds_config_free(&gw_cfg); return -1; @@ -1019,8 +1014,7 @@ int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { int r = ds_nl_move_to_netns(ctx, app_peer, child_netns_fd); close(child_netns_fd); if (r < 0) { - ds_warn("[NET] Gateway: failed to move %s into container netns", - app_peer); + ds_warn("[NET] Gateway: failed to move %s into container netns", app_peer); ds_nl_close(ctx); ds_config_free(&gw_cfg); return -1; @@ -1101,9 +1095,9 @@ int fix_networking_rootfs(struct ds_config *cfg) { /* IPv6 is enabled in host mode and gateway mode unless explicitly disabled. * Gateway mode is policy-owned by OpenWrt, so IPv6 RA/DHCPv6 should be able * to operate inside the application container netns. */ - int ipv6_enabled = ((cfg->net_mode == DS_NET_HOST || - cfg->net_mode == DS_NET_GATEWAY) && - !cfg->disable_ipv6); + int ipv6_enabled = + ((cfg->net_mode == DS_NET_HOST || cfg->net_mode == DS_NET_GATEWAY) && + !cfg->disable_ipv6); if (ipv6_enabled) { snprintf(hosts_content, sizeof(hosts_content), "127.0.0.1\tlocalhost\n" @@ -1540,7 +1534,8 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { return; char veth_host[IFNAMSIZ] = {0}; - pid_t effective_pid = container_pid > 0 ? container_pid : cfg->container_pid; + pid_t effective_pid = + container_pid > 0 ? container_pid : cfg->container_pid; if (effective_pid > 0) { veth_host_name(effective_pid, veth_host, sizeof(veth_host)); ds_nl_del_link(ctx, veth_host); From 6cf898a635730baaf7851b1ddcb44c0e6992810a Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 10 Jun 2026 23:20:52 +0530 Subject: [PATCH 039/211] docs: add Networking-From-Zero conceptual guide for gateway mode --- Documentation/Networking-From-Zero.md | 656 ++++++++++++++++++++++++++ 1 file changed, 656 insertions(+) create mode 100644 Documentation/Networking-From-Zero.md diff --git a/Documentation/Networking-From-Zero.md b/Documentation/Networking-From-Zero.md new file mode 100644 index 00000000..ba17f199 --- /dev/null +++ b/Documentation/Networking-From-Zero.md @@ -0,0 +1,656 @@ + + +# Networking From Zero: Understanding Droidspaces Gateway Mode + +### Quick Navigation + +- [Part 1: The Absolute Basics](#part-1-the-absolute-basics) + - [What is an IP address?](#what-is-an-ip-address) + - [What is a network?](#what-is-a-network) + - [LAN - Local Area Network](#lan---local-area-network) + - [WAN - Wide Area Network](#wan---wide-area-network) + - [Gateway](#gateway) +- [Part 2: How Data Actually Gets Delivered](#part-2-how-data-actually-gets-delivered) + - [MAC address vs IP address](#mac-address-vs-ip-address) + - [What is a packet?](#what-is-a-packet) +- [Part 3: DHCP (How You Get an IP Address)](#part-3-dhcp-how-you-get-an-ip-address) +- [Part 4: DNS (How Names Become Addresses)](#part-4-dns-how-names-become-addresses) +- [Part 5: NAT (The Magic Your Router Does)](#part-5-nat-the-magic-your-router-does) + - [NAT in Droidspaces](#nat-in-droidspaces) +- [Part 6: Bridges and Virtual Cables (Linux Plumbing)](#part-6-bridges-and-virtual-cables-linux-plumbing) + - [What is a network bridge?](#what-is-a-network-bridge) + - [What is a veth pair?](#what-is-a-veth-pair) + - [How NAT mode uses bridges and veths](#how-nat-mode-uses-bridges-and-veths) +- [Part 7: Network Namespaces (How Containers Are Isolated)](#part-7-network-namespaces-how-containers-are-isolated) +- [Part 8: OpenWRT and What It Is](#part-8-openwrt-and-what-it-is) +- [Part 9: The New Gateway Mode - Putting It All Together](#part-9-the-new-gateway-mode---putting-it-all-together) + - [Why does gateway mode exist?](#why-does-gateway-mode-exist) + - [The architecture](#the-architecture) + - [Step by step - what happens when you start a gateway-mode container](#step-by-step---what-happens-when-you-start-a-gateway-mode-container) + - [What "lazy attachment" means](#what-lazy-attachment-means) + - [Why resolv.conf is left alone in gateway mode](#why-resolvconf-is-left-alone-in-gateway-mode) + - [Why bridge-nf-call-iptables is set to 0](#why-bridge-nf-call-iptables-is-set-to-0) + - [The gateway container must be running first](#the-gateway-container-must-be-running-first) + - [What happens when containers stop](#what-happens-when-containers-stop) +- [Part 10: Gateway Mode Flags and Configuration](#part-10-gateway-mode-flags-and-configuration) + - [Required vs optional flags](#required-vs-optional-flags) + - [What --gateway-net does](#what---gateway-net-does) + - [What --gateway-iface does](#what---gateway-iface-does) + - [The flag conflict you must avoid](#the-flag-conflict-you-must-avoid) + - [Validation rules and kernel requirements](#validation-rules-and-kernel-requirements) +- [Part 11: Comparing All Networking Modes](#part-11-comparing-all-networking-modes) +- [Part 12: Real-World Use Cases for Gateway Mode](#part-12-real-world-use-cases-for-gateway-mode) +- [Quick Reference - Terms](#quick-reference---terms) + +--- + +## Part 1: The Absolute Basics + +### What is an IP address? + +Every device that wants to talk over a network needs an address so other devices know where to send data. That address is called an **IP address**. + +Think of it like a house address. If someone wants to mail you a letter, they need your address. Same idea: if your phone wants to send data to Google, it needs to know Google's address, and Google needs to know your phone's address to send the reply back. + +An IP address looks like this: `192.168.1.5` + +It is four numbers (0-255) separated by dots. Each number is called an **octet**. + +### What is a network? + +A **network** is just a group of devices that can talk to each other directly. + +Imagine a room with 5 laptops all connected to the same Wi-Fi router. Those 5 laptops are on the same network. They can send files to each other without going through the internet. + +### LAN - Local Area Network + +**LAN** = the network *inside your home* (or office, or in our case, *inside the container world*). + +It is called "local" because it is physically nearby: your phone, laptop, smart TV, all connected to your home Wi-Fi router. They all live on the same LAN. They can talk to each other directly. + +LAN addresses usually look like: +- `192.168.x.x` +- `10.x.x.x` +- `172.16.x.x` to `172.31.x.x` + +These are called **private IP ranges**. They are reserved for local networks and are never used on the public internet. + +### WAN - Wide Area Network + +**WAN** = the network *outside your home*: the internet itself. + +Your router has two sides: +- The **LAN side** faces your devices at home +- The **WAN side** faces your internet provider (ISP) + +Your ISP gives your router one public IP address for the WAN side. Everything inside your home shares that one public IP to reach the internet. + +``` +[Your Phone]--+ +[Your Laptop]-+--[Router]--[ISP]--[The Internet] +[Your TV]-----+ + (LAN side) (WAN side) +``` + +### Gateway + +A **gateway** is the device that connects two different networks together. + +In your home, the router *is* the gateway. Your phone's IP is `192.168.1.5` (LAN). When your phone wants to reach Google at `142.250.80.46` (WAN/internet), it does not know how to get there directly. So it sends the data to the gateway (router), and the router figures out how to forward it to the internet. + +**Rule:** Every device on a LAN is configured with a "default gateway" - the address they send all traffic to when they do not know where else to send it. + +--- + +## Part 2: How Data Actually Gets Delivered + +### MAC address vs IP address + +There are actually *two* kinds of addresses in networking: + +| Type | Looks like | Purpose | +|---|---|---| +| **IP address** | `192.168.1.5` | Logical address - used for routing across networks | +| **MAC address** | `a4:c3:f0:12:34:56` | Physical address - used for delivery on the *same* network | + +Think of it this way: +- The IP address is the **city and street** - used to navigate across the country +- The MAC address is the **apartment number** - used once you arrive at the building + +When your laptop sends a packet to your router, it uses the router's MAC address (because they are on the same LAN). The router then uses IP addresses to figure out where to send it next. + +### What is a packet? + +Data traveling over a network is broken into small chunks called **packets**. Each packet contains: +- Where it came from (source IP) +- Where it is going (destination IP) +- A small piece of the actual data + +The network reassembles all the packets at the destination. + +--- + +## Part 3: DHCP (How You Get an IP Address) + +### The problem + +Every device needs an IP address to join a network. But you cannot have two devices with the same IP - that would be like two houses with the same postal address. Mail would get lost. + +You *could* manually assign a unique IP to every device, but that is painful. What if you have 50 devices? + +### The solution: DHCP + +**DHCP** = Dynamic Host Configuration Protocol + +It is a system where one device (the **DHCP server**) automatically hands out IP addresses to every new device that joins the network. + +The conversation goes like this: + +``` +New Device: "Hello? Anyone there? I just joined this network and I need an IP address." +DHCP Server: "I heard you. Here, take 192.168.1.42. Also, your gateway is 192.168.1.1, + and for DNS use 1.1.1.1. Your lease lasts 24 hours." +New Device: "Got it, thanks!" +``` + +The new device now has everything it needs to work on the network: +- Its own IP address +- The gateway address (so it knows where to send traffic) +- The DNS address (explained next) + +In your home, the **router runs the DHCP server**. It hands out IPs to every device that connects. + +In **Droidspaces NAT mode**, Droidspaces itself runs a mini DHCP server that hands the container its IP (in the `172.28.x.x` range). The IP is deterministic: it is derived from the container's name, persisted to its config file, and offered again on every boot, so the same container always keeps the same address across restarts. + +--- + +## Part 4: DNS (How Names Become Addresses) + +### The problem + +IP addresses are hard to remember. Nobody types `142.250.80.46` when they want to visit Google. They type `google.com`. + +But computers only understand IP addresses. So there needs to be a system that converts human-readable names to IP addresses. + +### The solution: DNS + +**DNS** = Domain Name System + +It is basically the internet's phone book. You give it a name (`google.com`), it gives you back an IP address (`142.250.80.46`). + +The conversation: +``` +Your Browser: "What is the IP address of google.com?" +DNS Server: "It is 142.250.80.46" +Your Browser: "Thanks." [now connects to 142.250.80.46] +``` + +Every device is configured with a DNS server address. In most home networks, the router *is* the DNS server (it forwards your questions to your ISP's DNS or a public one like `1.1.1.1`). + +In **Droidspaces NAT mode**, Droidspaces writes a `resolv.conf` file inside the container pointing to a DNS server (default `1.1.1.1` and `8.8.8.8`, or whatever you pass via `--dns`). The same DNS servers are also advertised inside the DHCP lease itself. + +--- + +## Part 5: NAT (The Magic Your Router Does) + +### The problem + +Your ISP gives you *one* public IP address. But you have 10 devices at home. How do all 10 devices use the internet at the same time? + +### The solution: NAT + +**NAT** = Network Address Translation + +Your router keeps a secret table. When a device inside your LAN sends a packet to the internet, the router: +1. Rewrites the source IP from the device's private IP (`192.168.1.5`) to the router's public IP +2. Remembers which device sent it +3. When the reply comes back from the internet, rewrites the destination back to the device's private IP and forwards it + +From the internet's perspective, all your home devices appear to be *one device*: the router. + +``` +[Laptop: 192.168.1.5] --sends packet--> [Router] + | + | rewrites source to public IP + v + [Internet] + | + | reply comes back + v + [Router] + | + | rewrites destination back to 192.168.1.5 + v + [Laptop: 192.168.1.5] +``` + +### NAT in Droidspaces + +In Droidspaces NAT mode, Droidspaces acts *exactly like your home router* - but for containers: + +- The container gets a private IP (`172.28.x.x`) +- Droidspaces installs iptables `MASQUERADE` rules (that is the Linux name for the NAT target), plus FORWARD-accept and MSS-clamp rules so traffic actually flows +- The container can reach the internet; the internet sees Android's IP, not the container's IP +- Droidspaces also runs an embedded DHCP server for the container and configures its DNS +- On Android, a background route monitor watches the upstream interfaces and re-points container traffic automatically when the active network changes (for example, Wi-Fi to mobile data handoff) + +--- + +## Part 6: Bridges and Virtual Cables (Linux Plumbing) + +Now we go one level deeper, into how Linux actually connects containers to each other. + +### What is a network bridge? + +A **network bridge** works like a network switch. A physical network switch is a box you plug multiple ethernet cables into; all devices connected to it can talk to each other. + +A Linux **bridge** is a virtual switch, entirely in software. You can create it with a command, and then "plug" virtual network interfaces into it. + +``` +Physical world: Linux world: ++--------------+ +--------------+ +| Switch | | Bridge | (software, no physical box) +| port1 port2 | | port1 port2 | ++--+------+---+ +--+------+---+ + | | | | +[PC1] [PC2] [veth1] [veth2] (virtual cables) +``` + +### What is a veth pair? + +**veth** = virtual ethernet + +A veth pair is a pair of virtual network interfaces that are connected to each other like a pipe. Anything you send into one end comes out the other end. + +Think of it as a virtual ethernet cable with two plugs. You put one plug inside a container, and the other plug stays on the host (or goes into a bridge). + +``` +[Container netns] [Host netns] + eth0 --------------------- ds-veth0 + (plug inside container) (plug on host side) +``` + +### How NAT mode uses bridges and veths + +In Droidspaces NAT mode: + +``` +[Container netns] + eth0 (e.g. 172.28.137.42) + | + | veth pair (virtual cable) + | +[Host side] + ds-v ---- ds-br0 (bridge, has IP 172.28.0.1) + | + iptables MASQUERADE + | + wlan0 / rmnet0 + (Android's real network) +``` + +The bridge `ds-br0` owns the gateway IP `172.28.0.1`, which every NAT container uses as its default gateway. The veth pair is named after the container's init process ID: the host side is `ds-v`, and the container side starts life as `ds-p` before being renamed to `eth0` inside the container. + +Droidspaces runs a small per-container DHCP server listening on the container's host-side veth. The whole `172.28.0.0/16` subnet belongs to Droidspaces (the `172.28.0.x` row is reserved for the gateway itself, so containers always land in `172.28.1.x` through `172.28.254.x`), and everything from it is NATted out through Android's real interface. + +--- + +## Part 7: Network Namespaces (How Containers Are Isolated) + +### What is a namespace? + +Linux has a feature called **namespaces** that lets you create isolated views of system resources. + +A **network namespace** is an isolated copy of the entire networking stack. It has its own: +- Network interfaces +- Routing table +- iptables rules +- Everything networking-related + +When Droidspaces starts a container, it creates a new network namespace for it. The container lives in that namespace. It cannot see the host's network interfaces at all - only what Droidspaces explicitly puts inside its namespace. + +The veth pair is the tunnel between the host's namespace and the container's namespace: +- One end of the veth goes inside the container's network namespace (appears as `eth0`) +- The other end stays in the host's network namespace (Droidspaces connects it to a bridge) + +--- + +## Part 8: OpenWRT and What It Is + +### What is OpenWRT? + +**OpenWRT** is a Linux distribution designed specifically for routers. Normally it runs on physical router hardware, but it can also run on a regular Linux system or inside a container. + +When OpenWRT is running, it provides: +- **netifd** - network interface daemon (manages network interfaces, DHCP client/server, etc.) +- **dnsmasq** - DNS and DHCP server +- **firewall3** or **nftables** - firewall +- **LuCI** - web UI for configuration +- Everything a real router does, in software + +This means you can run OpenWRT inside a Droidspaces container, and it will behave exactly like a real router: managing networks, handing out DHCP leases, doing DNS, applying firewall rules, routing VPN traffic, etc. + +--- + +## Part 9: The New Gateway Mode - Putting It All Together + +### Why does gateway mode exist? + +In NAT mode, Droidspaces is the router. It does everything. This is fine for most cases. + +But what if you want **OpenWRT to be the router** for other containers? You want OpenWRT's firewall rules, OpenWRT's DHCP, OpenWRT's VPN routing, and you want other containers (like a Kali Linux container) to be on OpenWRT's LAN, getting everything from OpenWRT. + +The problem: if Droidspaces also tries to install NAT, DHCP, and DNS for those containers, it will *conflict* with what OpenWRT is trying to do. Two DHCP servers fighting over who gives the IP address. Two firewalls applying contradictory rules. + +**Gateway mode solves this.** Droidspaces steps back. It only does the L2 plumbing (the virtual cables and switch), and lets OpenWRT own all the policy: DHCP, DNS, firewall, routing. + +### The architecture + +``` +Android host kernel +| ++-- wlan0 (Android's real Wi-Fi - WAN) +| ++-- [OpenWRT container - net=nat mode] +| netns: owns eth0 (WAN, gets NAT from Droidspaces) +| eth1 (LAN side - plugged into ds-lan bridge by gateway mode) +| Runs: dnsmasq, netifd, firewall, VPN +| ++-- ds-lan (host bridge - NO IP address, just a switch) +| | +| +-- ds-g[hash] (veth host-side, connected to OpenWRT's netns as eth1) +| +-- ds-v[pid] (veth host-side, connected to Kali's netns as eth0) +| ++-- [Kali container - net=gateway mode] + netns: owns eth0 (LAN side - plugged into ds-lan bridge) + Gets DHCP from OpenWRT's dnsmasq + Routing decisions made by OpenWRT + Firewall rules applied by OpenWRT +``` + +### Step by step - what happens when you start a gateway-mode container + +**Step 1 - You start OpenWRT first (in NAT mode)** + +```bash +droidspaces --name=openwrt --rootfs=/data/openwrt --net=nat --upstream=wlan0 start +``` + +OpenWRT boots. It has: +- `eth0` on the WAN side (Droidspaces manages NAT for this) +- No LAN side yet - OpenWRT is waiting for one + +**Step 2 - You start Kali (in gateway mode)** + +```bash +droidspaces --name=kali --rootfs=/data/kali --net=gateway --gateway=openwrt start +``` + +Droidspaces does the following (only plumbing, no policy): + +1. Finds OpenWRT's running process ID so it can reach its network namespace +2. Creates a bridge called `ds-lan` on the host with no IP address on it +3. Disables `bridge-nf-call-iptables` so Android's host firewall does NOT intercept traffic on this bridge, keeping OpenWRT's firewall as the only authority +4. Creates a veth pair for OpenWRT's LAN side - one end goes into OpenWRT's netns (appears as `eth1`), the other end plugs into the `ds-lan` bridge +5. Creates a veth pair for Kali - one end goes into Kali's netns (appears as `eth0`), the other end plugs into the `ds-lan` bridge +6. Does NOT install NAT, DHCP, DNS, or any firewall rules + +**Step 3 - OpenWRT takes over** + +OpenWRT's `netifd` detects that `eth1` appeared. It configures it as the LAN interface. +OpenWRT's `dnsmasq` starts answering DHCP requests on `eth1`. + +Kali's `eth0` sends a DHCP request and OpenWRT's `dnsmasq` replies with: +- IP address: `192.168.1.100` (or whatever OpenWRT's DHCP range is) +- Gateway: `192.168.1.1` (OpenWRT itself) +- DNS: `192.168.1.1` (OpenWRT's dnsmasq) + +Kali is now fully configured, with OpenWRT as its router. + +**Step 4 - Traffic flows through OpenWRT** + +When Kali tries to reach the internet: + +``` +Kali eth0 --> ds-lan bridge --> OpenWRT eth1 + | + OpenWRT firewall rules applied here + | + OpenWRT routes to eth0 (WAN) + | + Droidspaces NAT (eth0 -> wlan0) + | + Android wlan0 --> Internet +``` + +OpenWRT's firewall sees all of Kali's traffic and can apply any rules: block certain sites, redirect through VPN, shape bandwidth, log connections - exactly like a real router would. + +### What "lazy attachment" means + +The gateway veth is "lazily attached." This means: + +- When you start OpenWRT, it does NOT immediately get an `eth1` +- `eth1` only appears inside OpenWRT **when the first gateway-mode container starts** +- This is intentional - OpenWRT boots with just its WAN side (`eth0`), and its LAN cable (`eth1`) is plugged in later, on demand + +This mimics how you might physically plug a cable into a router's LAN port after the router is already running. + +### Why resolv.conf is left alone in gateway mode + +In NAT mode, Droidspaces writes `/etc/resolv.conf` inside the container, pointing to `1.1.1.1` or `8.8.8.8`. + +In gateway mode, Droidspaces does NOT write `resolv.conf` (unless you explicitly pass `--dns`). This is because OpenWRT's `dnsmasq` hands the DNS server address to the container via the DHCP lease. If Droidspaces also wrote a `resolv.conf`, it would conflict with what dnsmasq is providing - the container would use the wrong DNS and bypass OpenWRT's DNS filtering/caching entirely. + +### Why bridge-nf-call-iptables is set to 0 + +The bridge `ds-lan` carries traffic between OpenWRT and Kali. By default, Linux can pass bridged traffic through the host's iptables. This would mean Android's iptables rules (which might drop or NAT things unexpectedly) would interfere with traffic that OpenWRT is supposed to be managing. + +Setting it to `0` tells Linux: "do not run iptables on bridged traffic." This keeps OpenWRT's firewall as the *only* firewall that sees this traffic, which is exactly what we want. + +### The gateway container must be running first + +Start order matters. When a gateway-mode container starts, Droidspaces looks up the gateway container's live process ID to reach its network namespace. If the gateway container is not running at that moment, the network setup fails with a warning and the client container boots anyway - but with no network at all (only loopback). It does not retry on its own. + +The same logic applies after a gateway restart. A veth pair dies together: when the gateway container stops, the `eth1` end inside it is destroyed, and that destroys the host-side end too. Existing clients stay plugged into the bridge but have no router anymore. The LAN cable is re-plugged the next time **any** gateway-mode container starts on that segment - so after restarting the gateway, restart one client (or start a new one) to bring the segment back to life. + +### What happens when containers stop + +Cleanup in gateway mode is deliberately minimal, matching the "plumbing only" philosophy: + +- **A client stops:** only that client's own veth (`ds-v`) is removed. The bridge and the gateway's `eth1` stay up, so other clients on the segment are untouched. +- **The gateway stops:** the gateway-side veth disappears with its namespace (see above), but the bridge itself stays. +- The delegated bridge (`ds-lan` etc.) is never torn down by Droidspaces. It is harmless when idle - it has no IP and carries no policy - and persists until you delete it manually or reboot the device. + +--- + +## Part 10: Gateway Mode Flags and Configuration + +### Required vs optional flags + +Only **one flag is mandatory** when using `--net=gateway`: + +```bash +--gateway= +``` + +If you omit it, Droidspaces will print an error and refuse to start. Everything else has a working default: + +| Flag | Default | What it controls | +|---|---|---| +| `--gateway=NAME` | *(none - required)* | Which running container is the router | +| `--gateway-net=NAME` | `lan` | The LAN segment name - see below | +| `--gateway-iface=IFACE` | `eth1` | Interface name inside the gateway container | +| `--gateway-bridge=BR` | `ds-{gateway-net}` | Override the host bridge name entirely | + +So the minimal valid command is: + +```bash +droidspaces --name=client --net=gateway --gateway=openwrt start +``` + +That is identical to spelling out all defaults explicitly: + +```bash +droidspaces --name=client --net=gateway --gateway=openwrt \ + --gateway-net=lan \ + --gateway-iface=eth1 \ + start +``` + +### What --gateway-net does + +This flag controls two things at once, both derived from the same name. + +**1. It names the host bridge.** + +The bridge Droidspaces creates on the host is named `ds-{NAME}`: + +``` +--gateway-net=lan -> host bridge: ds-lan +--gateway-net=vpn -> host bridge: ds-vpn +--gateway-net=iot -> host bridge: ds-iot +``` + +**2. It is the segment identifier - which bridge clients land on.** + +The veth names for the gateway's LAN side are generated by hashing the string `{gateway_container}:{gateway_net}` together. The same hash = the same veth = the same bridge segment. This means multiple client containers that share the same `--gateway` and `--gateway-net` all end up on the same bridge, and all get DHCP from the same OpenWRT interface. + +This is the real power of `--gateway-net`: running multiple isolated LAN segments through the same gateway container. + +```bash +# These two land on ds-lan - they see each other, OpenWRT routes them as one LAN +droidspaces --name=kali --net=gateway --gateway=openwrt --gateway-net=lan start +droidspaces --name=ubuntu --net=gateway --gateway=openwrt --gateway-net=lan start + +# This one lands on ds-vpn - a completely separate bridge +# OpenWRT can apply different firewall/VPN rules to this segment +droidspaces --name=torbox --net=gateway --gateway=openwrt --gateway-net=vpn start +``` + +Inside OpenWRT, the `lan` clients come in on `eth1` and the `vpn` clients come in on `eth2` (each segment gets its own veth, because the hash of `openwrt:lan` and `openwrt:vpn` are different). + +### What --gateway-iface does + +This controls **what the LAN interface is called inside the gateway container's network namespace**. + +When Droidspaces creates the gateway veth for a segment, it moves one end into OpenWRT's netns and renames it from its raw hash name (`ds-hXXXXXXXX`) to whatever you pass here (default `eth1`). + +**Why does this matter?** OpenWRT's configuration is built around interface names. If your OpenWRT `/etc/config/network` says: + +``` +config interface 'lan' + option device 'eth1' +``` + +...then the interface that appears inside OpenWRT **must** be named `eth1`, or OpenWRT will not recognize it as its LAN and will not serve DHCP on it. `--gateway-iface=eth1` makes that happen. + +For a second segment you would pass `--gateway-iface=eth2` so OpenWRT sees it as a separate interface and you can add a second UCI network block for it. + +**Important detail:** `--gateway-iface` only has any effect when the gateway veth for that segment is being created for the first time - which is when the very first client container on that segment starts. The gateway veth is shared by all clients on the same `--gateway-net`; it is created once and reused. Every client after the first one skips the gateway veth creation entirely and just wires its own app veth into the existing bridge. + +This means if you start two containers on `--gateway-net=lan` and both pass `--gateway-iface=eth1`, it works perfectly fine: the first container creates the veth and renames it `eth1`, the second container sees the veth already exists and does nothing with `--gateway-iface` at all. + +### The flag conflict you must avoid + +The problem only happens when you use **two different `--gateway-net` segments but the same `--gateway-iface`**: + +```bash +# segment 1 - creates eth1 inside OpenWRT +droidspaces --name=kali --net=gateway --gateway=openwrt --gateway-net=lan --gateway-iface=eth1 start + +# segment 2 - WRONG: also tries to create eth1 inside OpenWRT +droidspaces --name=torbox --net=gateway --gateway=openwrt --gateway-net=vpn --gateway-iface=eth1 start +``` + +When the second command runs, Droidspaces tries to move a new veth peer into OpenWRT and rename it `eth1`. But `eth1` already exists inside OpenWRT from the first segment. Instead of failing loudly, the code detects the conflict and just brings the existing `eth1` up again, leaving the new veth peer with its raw hash name (`ds-hYYYYYYYY`) inside OpenWRT. OpenWRT has no config for `ds-hYYYYYYYY` and silently ignores it. The `vpn` segment gets no gateway-side interface: no DHCP, no routing, containers on it are effectively isolated. + +**The rule:** every `--gateway-net` segment must have a unique `--gateway-iface` name. + +```bash +# Correct: two segments, two interface names +--gateway-net=lan --gateway-iface=eth1 -> eth1 inside OpenWRT (LAN segment) +--gateway-net=vpn --gateway-iface=eth2 -> eth2 inside OpenWRT (VPN segment) +``` + +### Validation rules and kernel requirements + +Droidspaces enforces a few rules at startup and refuses to boot if they are violated: + +- A container cannot use **itself** as its gateway (`--gateway` must name a different container) +- Interface and bridge names must be shorter than 16 characters (the Linux `IFNAMSIZ` limit) and may only contain letters, digits, `_`, and `-` +- The kernel must support network namespaces (`CONFIG_NET_NS`), veth pairs (`CONFIG_VETH`), and bridges (`CONFIG_BRIDGE`). Droidspaces probes for all three before starting and exits with a fatal error if any is missing + +Two more things to know: + +- `--upstream` and `--port` only make sense in NAT mode. In gateway mode they are ignored with a warning - port forwarding and upstream selection are the gateway container's job now. +- When the host bridge name is auto-derived from `--gateway-net`, the name is sanitized (only letters, digits, `_`, `-` survive) and truncated to 9 characters, producing `ds-` plus at most 9 characters. If you need an exact bridge name, set it explicitly with `--gateway-bridge`. + +--- + +## Part 11: Comparing All Networking Modes + +| Feature | NAT Mode | Host Mode | None Mode | Gateway Mode | +|---|---|---|---|---| +| Who assigns IPs? | Droidspaces DHCP | Android (shared) | Nobody (loopback only) | OpenWRT dnsmasq | +| Who does NAT? | Droidspaces iptables | Android | N/A | OpenWRT (via Droidspaces NAT on OpenWRT's WAN) | +| Who manages firewall? | Droidspaces | Android | N/A | OpenWRT | +| Who manages DNS? | Droidspaces | Android | Nobody | OpenWRT dnsmasq | +| Container isolated from host network? | Yes | No | Yes | Yes | +| Internet access? | Yes | Yes | No | Yes (via gateway container) | +| Needs a second container to function? | No | No | No | Yes (the gateway container) | +| Good for | Simple internet access | Maximum performance, zero overhead | Offline / sandboxed workloads | Router appliance, VPN gateway, segmented LANs | + +--- + +## Part 12: Real-World Use Cases for Gateway Mode + +### 1. VPN killswitch for specific containers + +Run OpenWRT with a WireGuard or OpenVPN client. Configure OpenWRT's firewall to drop all traffic that does not go through the VPN tunnel. Any container using gateway mode will be unable to leak traffic outside the VPN - OpenWRT enforces it at the bridge level, not inside the individual containers. + +### 2. Multiple isolated LAN segments + +Use `--gateway-net` to create separate segments on the same OpenWRT. Containers on `--gateway-net=lan` cannot reach containers on `--gateway-net=vpn` unless OpenWRT explicitly routes between them. You get VLAN-style isolation with a single gateway container. + +### 3. Traffic analysis + +Run OpenWRT with `tcpdump` or `nftables` logging enabled. Every packet from every gateway-mode container flows through OpenWRT, so you get a single chokepoint to observe all network activity across multiple containers at once. + +### 4. Custom DNS filtering + +Run OpenWRT with a `dnsmasq` blocklist (or with Adblock installed via opkg). Every container on the gateway LAN gets filtered DNS without touching each container individually. + +### 5. Bandwidth shaping + +OpenWRT's `tc` (traffic control) and `sqm-scripts` can shape bandwidth per container, since OpenWRT sees each container as a separate MAC address arriving on its LAN interface. + +--- + +## Quick Reference - Terms + +| Term | One-line definition | +|---|---| +| **IP address** | The numerical address of a device on a network (e.g. `192.168.1.5`) | +| **MAC address** | The hardware address of a network interface, used for delivery within the same network | +| **LAN** | Local network - devices near each other that can talk directly | +| **WAN** | Wide network - the internet, outside your local network | +| **Gateway** | A device that connects two networks and routes traffic between them | +| **DHCP** | Protocol for automatically assigning IP addresses to devices | +| **DNS** | System that converts human-readable names (`google.com`) to IP addresses | +| **NAT** | Technique for sharing one public IP across many private-IP devices | +| **Bridge** | A virtual (or physical) switch that connects multiple network interfaces | +| **veth pair** | A pair of virtual network interfaces connected like a pipe - what goes in one end comes out the other | +| **Network namespace** | An isolated copy of the Linux networking stack - containers live in their own namespace | +| **OpenWRT** | A Linux distro designed to run as a router/gateway - runs dnsmasq, netifd, firewall | +| **netifd** | OpenWRT's network interface daemon - manages interfaces and DHCP | +| **dnsmasq** | Lightweight DHCP and DNS server used by OpenWRT | +| **MASQUERADE** | The Linux iptables rule that implements NAT (rewrites source IPs) | +| **Delegated LAN** | The bridge network Droidspaces creates in gateway mode - policy owned by the gateway container, not Droidspaces | +| **Segment** | One isolated LAN identified by `--gateway-net` - each segment gets its own bridge and its own interface inside the gateway container | +| **Lazy attachment** | The gateway's LAN-side veth is only created when the first client container starts, not when the gateway container starts | From 90a53f6288aa12a1a82ec0069ba66ba2b8b5ac5c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 10 Jun 2026 23:32:21 +0530 Subject: [PATCH 040/211] submodules: updated socketd & webui Signed-off-by: ravindu644 --- external/socketd | 2 +- external/www | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/external/socketd b/external/socketd index e1f1d94e..ec4d194f 160000 --- a/external/socketd +++ b/external/socketd @@ -1 +1 @@ -Subproject commit e1f1d94ef702ed35cbf0a1959cc8169d8ce7cb7c +Subproject commit ec4d194f83a2bacdb17d9ddfe07f1d433b62fa18 diff --git a/external/www b/external/www index 549d2fcf..c3485496 160000 --- a/external/www +++ b/external/www @@ -1 +1 @@ -Subproject commit 549d2fcf52accd7e571bae7e60ad52df50f83b8d +Subproject commit c34854969e4f46bbd64bd6eb2f873f18eff0af28 From c2a1c09e2c2e0f0c7a396341347730a2df4cf034 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 10 Jun 2026 23:51:25 +0530 Subject: [PATCH 041/211] updated gitignore Signed-off-by: ravindu644 --- .gitignore | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 3e9ec968..8f74aa1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,14 @@ *.o *.tar.gz -droidspaces-* -output -.cursor -old_code_with_android-app/ -logs/ +output/ *.img -*.txt *.patch *.diff *.rej *.orig *.config +.cache +.claude # Keystores *.keystore From 81b02fa07b93a010432b9e5fd303343d75022ad0 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 11 Jun 2026 12:08:59 +0530 Subject: [PATCH 042/211] net: remove --upstream entirely, detect the internet uplink automatically NAT mode no longer needs (or accepts) a user-declared upstream interface list. The active uplink is now detected fully automatically and tracked in real time, the same way the rest of the industry does it: - On standard Linux, the interface-less MASQUERADE rule plus the main routing table already handle everything (the LXC/Docker model) - no detection logic runs at all. - On Android, where netd keeps the main table empty, the runtime reads the kernel's ground truth directly: the netd default-network FIB rule (fwmark 0x0/0xffff iif lo) via a pure-netlink RTM_GETRULE dump, resolved to an interface through the table's default route. This is the same source of truth AOSP's own tethering consumes, and it replaces the old popen("ip rule show") parser - core networking now has zero external binary dependencies. Detection falls back to the main-table default route, then to a built-in whitelist scan (wlan*, eth*, v4-*, rmnet*, *ccmni*) that hard-excludes tethering/downstream and VPN interfaces (swlan*, ap*, usb*, rndis*, ncm*, p2p*, tun*, ppp*). Uid-scoped rules (Android 12+ per-app / work profile networks) and non-table rule actions are skipped so the global default network always wins. CLAT (464xlat) interfaces on IPv6-only SIMs are picked up automatically via table->OIF resolution. The route monitor now subscribes to RTNLGRP_IPV4_RULE and RTMGRP_IPV4_ROUTE in addition to link/addr groups, so wifi <-> mobile data handoffs are detected the instant netd swaps the rule instead of on the next heartbeat; event bursts are coalesced into a single reprobe. The 1.5s heartbeat remains for the ip_forward re-assert and devices with broken netlink delivery. All --upstream surface is removed: CLI flag, config key (legacy upstream_interfaces= lines in old configs are silently dropped), env output, info prints, the Android app's upstream UI (component, screens, viewmodel plumbing, strings in all locales), and documentation (en + zh-CN). ~430 lines of wildcard/priority-list machinery deleted. Tested on 3 devices including wifi <-> mobile data handoffs. Signed-off-by: ravindu644 --- .../app/ui/component/UpstreamInterfaceList.kt | 262 --------- .../ui/navigation/DroidspacesNavigation.kt | 5 +- .../app/ui/screen/ContainerConfigScreen.kt | 36 +- .../app/ui/screen/EditContainerScreen.kt | 30 +- .../ui/screen/InstallationSummaryScreen.kt | 6 +- .../ContainerInstallationViewModel.kt | 7 - .../droidspaces/app/util/ContainerManager.kt | 31 -- .../app/src/main/res/values-de/strings.xml | 6 - .../app/src/main/res/values-in/strings.xml | 6 - .../app/src/main/res/values-it/strings.xml | 6 - .../app/src/main/res/values-ja/strings.xml | 6 - .../app/src/main/res/values-km/strings.xml | 6 - .../app/src/main/res/values-ro/strings.xml | 6 - .../app/src/main/res/values-si/strings.xml | 6 - .../app/src/main/res/values-tr/strings.xml | 6 - .../src/main/res/values-zh-rCN/strings.xml | 6 - .../src/main/res/values-zh-rTW/strings.xml | 6 - Android/app/src/main/res/values/strings.xml | 6 - Documentation/Features.md | 10 +- Documentation/Linux-CLI.md | 2 - Documentation/Networking-From-Zero.md | 6 +- Documentation/Usage-Android-App.md | 11 +- Documentation/zh-CN/Features.md | 10 +- Documentation/zh-CN/Linux-CLI.md | 2 - src/config.c | 42 +- src/container.c | 19 - src/documentation.c | 7 +- src/include/droidspace.h | 10 +- src/main.c | 72 +-- src/net/netlink.c | 241 ++++++++- src/net/network.c | 497 ++++++++---------- 31 files changed, 492 insertions(+), 880 deletions(-) delete mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt deleted file mode 100644 index 60e8323f..00000000 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt +++ /dev/null @@ -1,262 +0,0 @@ -package com.droidspaces.app.ui.component - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -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.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.droidspaces.app.R -import com.droidspaces.app.util.ContainerManager -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun UpstreamInterfaceList( - upstreamInterfaces: List, - onInterfacesChange: (List) -> Unit, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - var showUpstreamDialog by remember { mutableStateOf(false) } - var availableUpstreams by remember { mutableStateOf>(emptyList()) } - - LaunchedEffect(Unit) { - availableUpstreams = ContainerManager.listUpstreamInterfaces() - } - - Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - // Selected interfaces - upstreamInterfaces.forEach { iface -> - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(20.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = iface, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) - IconButton(onClick = { onInterfacesChange(upstreamInterfaces - iface) }) { - Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) - } - } - } - } - - // Add Button - if (upstreamInterfaces.size < 8) { - val addBtnShape = RoundedCornerShape(16.dp) - Surface( - modifier = Modifier.fillMaxWidth().clip(addBtnShape).clickable( - onClick = { showUpstreamDialog = true } - ), - shape = addBtnShape, - color = MaterialTheme.colorScheme.surfaceContainerLow, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - tonalElevation = 0.dp - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(modifier = Modifier.width(8.dp)) - Text( - context.getString(R.string.add_upstream_interface), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - - if (showUpstreamDialog) { - AddUpstreamDialog( - availableUpstreams = availableUpstreams, - selectedInterfaces = upstreamInterfaces, - onDismiss = { showUpstreamDialog = false }, - onRefresh = { - val newOnes = ContainerManager.listUpstreamInterfaces() - availableUpstreams = newOnes - }, - onAdd = { iface -> - if (!upstreamInterfaces.contains(iface)) { - onInterfacesChange(upstreamInterfaces + iface) - } - showUpstreamDialog = false - } - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun AddUpstreamDialog( - availableUpstreams: List, - selectedInterfaces: List, - onDismiss: () -> Unit, - onRefresh: suspend () -> Unit, - onAdd: (String) -> Unit -) { - val context = LocalContext.current - var customIface by remember { mutableStateOf("") } - var isRefreshing by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - val rotation by animateFloatAsState( - targetValue = if (isRefreshing) 360f else 0f, - animationSpec = if (isRefreshing) { - tween(durationMillis = 600, easing = LinearEasing) - } else { - tween(durationMillis = 0, easing = LinearEasing) - }, - label = "refresh_rotation" - ) - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier.fillMaxWidth(0.92f).wrapContentHeight(), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), - tonalElevation = 0.dp - ) { - Column( - modifier = Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = context.getString(R.string.add_upstream_interface), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - IconButton( - onClick = { - if (!isRefreshing) { - isRefreshing = true - scope.launch { - val startTime = System.currentTimeMillis() - onRefresh() - val elapsed = System.currentTimeMillis() - startTime - if (elapsed < 600L) delay(600L - elapsed) - isRefreshing = false - } - } - }, - enabled = !isRefreshing - ) { - Icon( - Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(20.dp).graphicsLayer { rotationZ = rotation } - ) - } - } - - if (availableUpstreams.isNotEmpty()) { - Text(context.getString(R.string.available_system_interfaces), style = MaterialTheme.typography.labelMedium) - Box(modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp)) { - FlowRow( - modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - availableUpstreams.forEach { iface -> - OutlinedButton( - onClick = { onAdd(iface) }, - enabled = !selectedInterfaces.contains(iface), - shape = RoundedCornerShape(12.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) - ) { - Text(iface) - } - } - } - } - } - - Spacer(modifier = Modifier.height(4.dp)) - Text(context.getString(R.string.enter_manually), style = MaterialTheme.typography.labelMedium) - OutlinedTextField( - value = customIface, - onValueChange = { customIface = it }, - label = { Text(context.getString(R.string.interface_name_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - ) - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = customIface.isNotBlank() && selectedInterfaces.size < 8, - onClick = { onAdd(customIface.trim()) } - ), - shape = RoundedCornerShape(14.dp), - color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.add), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } - } - } - } -} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index 6602260a..a7451429 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -321,10 +321,9 @@ fun DroidspacesNavigation( initialBlockNestedNs = viewModel.blockNestedNs, initialPrivileged = viewModel.privileged, initialEnvFileContent = viewModel.envFileContent ?: "", - initialUpstreamInterfaces = viewModel.upstreamInterfaces, initialPortForwards = viewModel.portForwards, - onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, upstreamInterfaces, portForwards -> - viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, upstreamInterfaces, portForwards, forceCgroupv1, blockNestedNs, privileged) + onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, portForwards -> + viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, portForwards, forceCgroupv1, blockNestedNs, privileged) navController.navigate(Screen.SparseImageConfig.route) }, onBack = { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index 2623fc50..0ef41bc0 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.unit.dp import com.droidspaces.app.ui.component.ToggleCard import com.droidspaces.app.ui.component.DsDropdown import androidx.compose.material.icons.filled.Public -import com.droidspaces.app.ui.component.UpstreamInterfaceList import com.droidspaces.app.ui.component.PortForwardingList import androidx.compose.ui.platform.LocalContext import com.droidspaces.app.R @@ -74,7 +73,6 @@ fun ContainerConfigScreen( initialBlockNestedNs: Boolean = false, initialPrivileged: String = "", initialEnvFileContent: String = "", - initialUpstreamInterfaces: List = emptyList(), initialPortForwards: List = emptyList(), onNext: ( netMode: String, @@ -98,7 +96,6 @@ fun ContainerConfigScreen( blockNestedNs: Boolean, privileged: String, envFileContent: String?, - upstreamInterfaces: List, portForwards: List ) -> Unit, onBack: () -> Unit @@ -123,7 +120,6 @@ fun ContainerConfigScreen( var forceCgroupv1 by remember { mutableStateOf(initialForceCgroupv1) } var blockNestedNs by remember { mutableStateOf(initialBlockNestedNs) } var envFileContent by remember { mutableStateOf(initialEnvFileContent) } - var upstreamInterfaces by remember { mutableStateOf(initialUpstreamInterfaces) } var portForwards by remember { mutableStateOf(initialPortForwards) } var privileged by remember { mutableStateOf(initialPrivileged) } val context = LocalContext.current @@ -277,7 +273,6 @@ fun ContainerConfigScreen( }, bottomBar = { val btnShape = RoundedCornerShape(20.dp) - val isUpstreamValid = netMode != "nat" || upstreamInterfaces.isNotEmpty() Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceContainer, @@ -295,15 +290,14 @@ fun ContainerConfigScreen( .navigationBarsPadding() .clip(btnShape) .clickable( - enabled = isUpstreamValid, onClick = { - onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, upstreamInterfaces, portForwards) + onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, portForwards) }, indication = androidx.compose.material.ripple.rememberRipple(bounded = true), interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } ), shape = btnShape, - color = if (isUpstreamValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + color = MaterialTheme.colorScheme.primary, tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { @@ -312,13 +306,13 @@ fun ContainerConfigScreen( Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, modifier = Modifier.size(18.dp), - tint = if (isUpstreamValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + tint = MaterialTheme.colorScheme.onPrimary ) Text( context.getString(R.string.next_storage), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - color = if (isUpstreamValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + color = MaterialTheme.colorScheme.onPrimary ) } } @@ -491,28 +485,6 @@ fun ContainerConfigScreen( ) } - // Upstream Interfaces - val isUpstreamValid = upstreamInterfaces.isNotEmpty() - Text( - text = context.getString(R.string.upstream_interfaces_mandatory), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = if (!isUpstreamValid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface - ) - - if (!isUpstreamValid) { - Text( - text = context.getString(R.string.upstream_interfaces_required_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - UpstreamInterfaceList( - upstreamInterfaces = upstreamInterfaces, - onInterfacesChange = { upstreamInterfaces = it } - ) - // Port Forwards Text( text = context.getString(R.string.port_forwarding), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index a7c9925f..ea4ceefd 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -52,7 +52,6 @@ import com.droidspaces.app.ui.component.PrivilegedModeDialog import com.droidspaces.app.ui.component.HardwareAccessDialog import com.droidspaces.app.ui.component.DsDropdown import androidx.compose.material.icons.filled.Public -import com.droidspaces.app.ui.component.UpstreamInterfaceList import com.droidspaces.app.ui.component.PortForwardingList import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -106,7 +105,6 @@ fun EditContainerScreen( var dnsServers by remember { mutableStateOf(container.dnsServers) } var runAtBoot by remember { mutableStateOf(container.runAtBoot) } var envFileContent by remember { mutableStateOf(container.envFileContent ?: "") } - var upstreamInterfaces by remember { mutableStateOf(container.upstreamInterfaces) } var portForwards by remember { mutableStateOf(container.portForwards) } var forceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } var blockNestedNs by remember { mutableStateOf(container.blockNestedNs) } @@ -132,7 +130,6 @@ fun EditContainerScreen( var savedDnsServers by remember { mutableStateOf(container.dnsServers) } var savedRunAtBoot by remember { mutableStateOf(container.runAtBoot) } var savedEnvFileContent by remember { mutableStateOf(container.envFileContent ?: "") } - var savedUpstreamInterfaces by remember { mutableStateOf(container.upstreamInterfaces) } var savedPortForwards by remember { mutableStateOf(container.portForwards) } var savedForceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } var savedBlockNestedNs by remember { mutableStateOf(container.blockNestedNs) } @@ -170,7 +167,6 @@ fun EditContainerScreen( dnsServers != savedDnsServers || runAtBoot != savedRunAtBoot || envFileContent != savedEnvFileContent || - upstreamInterfaces != savedUpstreamInterfaces || portForwards != savedPortForwards || forceCgroupv1 != savedForceCgroupv1 || blockNestedNs != savedBlockNestedNs || @@ -214,7 +210,6 @@ fun EditContainerScreen( dnsServers = dnsServers, runAtBoot = runAtBoot, envFileContent = if (envFileContent.isBlank()) null else envFileContent, - upstreamInterfaces = upstreamInterfaces, portForwards = portForwards, forceCgroupv1 = forceCgroupv1, blockNestedNs = blockNestedNs, @@ -249,7 +244,6 @@ fun EditContainerScreen( savedDnsServers = dnsServers savedRunAtBoot = runAtBoot savedEnvFileContent = envFileContent - savedUpstreamInterfaces = upstreamInterfaces savedPortForwards = portForwards savedForceCgroupv1 = forceCgroupv1 savedBlockNestedNs = blockNestedNs @@ -428,7 +422,7 @@ fun EditContainerScreen( }, bottomBar = { val btnShape = RoundedCornerShape(20.dp) - val isReadyToSave = !isSaving && !isSaved && hasChanges && (netMode != "nat" || upstreamInterfaces.isNotEmpty()) && hostnameError == null + val isReadyToSave = !isSaving && !isSaved && hasChanges && hostnameError == null val targetBtnColor = when { isSaved -> MaterialTheme.colorScheme.primaryContainer isSaving || isReadyToSave -> MaterialTheme.colorScheme.primary @@ -745,28 +739,6 @@ fun EditContainerScreen( ) } - // Upstream Interfaces - val isUpstreamValid = upstreamInterfaces.isNotEmpty() - Text( - text = context.getString(R.string.upstream_interfaces_mandatory), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = if (!isUpstreamValid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface - ) - - if (!isUpstreamValid) { - Text( - text = context.getString(R.string.upstream_interfaces_required_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - UpstreamInterfaceList( - upstreamInterfaces = upstreamInterfaces, - onInterfacesChange = { upstreamInterfaces = it } - ) - // Port Forwards Text( text = context.getString(R.string.port_forwarding), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index d3937ff0..578ddd70 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -195,10 +195,6 @@ fun InstallationSummaryScreen( } } - if (config.upstreamInterfaces.isNotEmpty()) { - SummaryItem(stringResource(R.string.upstream_interfaces_mandatory), config.upstreamInterfaces.joinToString(", "), Icons.Default.Public) - } - if (config.portForwards.isNotEmpty()) { config.portForwards.forEach { forward -> SummaryItem(stringResource(R.string.port_forwarding), "${forward.hostPort} → ${forward.containerPort ?: forward.hostPort} (${forward.proto})", Icons.AutoMirrored.Filled.ArrowForward) @@ -211,7 +207,7 @@ fun InstallationSummaryScreen( !config.runAtBoot && !config.disableIPv6 && !config.enableTermuxX11 && !config.enableVirgl && !config.enablePulseaudio && !config.forceCgroupv1 && !config.blockNestedNs && - config.upstreamInterfaces.isEmpty() && config.portForwards.isEmpty() && + config.portForwards.isEmpty() && config.envFileContent.isNullOrBlank()) { Text( text = stringResource(R.string.no_options_enabled), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt index 59a5dacc..c78e6560 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt @@ -84,9 +84,6 @@ class ContainerInstallationViewModel : ViewModel() { var sparseImageSizeGB: Int by mutableStateOf(8) private set - var upstreamInterfaces: List by mutableStateOf(emptyList()) - private set - var portForwards: List by mutableStateOf(emptyList()) private set @@ -132,7 +129,6 @@ class ContainerInstallationViewModel : ViewModel() { customInit: String, staticNatIp: String, envFileContent: String?, - upstreamInterfaces: List, portForwards: List, forceCgroupv1: Boolean, blockNestedNs: Boolean, @@ -156,7 +152,6 @@ class ContainerInstallationViewModel : ViewModel() { this.customInit = customInit this.staticNatIp = staticNatIp this.envFileContent = envFileContent - this.upstreamInterfaces = upstreamInterfaces this.portForwards = portForwards this.forceCgroupv1 = forceCgroupv1 this.blockNestedNs = blockNestedNs @@ -196,7 +191,6 @@ class ContainerInstallationViewModel : ViewModel() { status = ContainerStatus.STOPPED, // Default status for new container useSparseImage = useSparseImage, sparseImageSizeGB = if (useSparseImage) sparseImageSizeGB else null, - upstreamInterfaces = upstreamInterfaces, portForwards = portForwards, forceCgroupv1 = forceCgroupv1, blockNestedNs = blockNestedNs, @@ -228,7 +222,6 @@ class ContainerInstallationViewModel : ViewModel() { envFileContent = null useSparseImage = true sparseImageSizeGB = 8 - upstreamInterfaces = emptyList() portForwards = emptyList() forceCgroupv1 = false blockNestedNs = false diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index 267d5127..d97578d6 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -49,7 +49,6 @@ data class ContainerInfo( val useSparseImage: Boolean = false, val sparseImageSizeGB: Int? = null, val envFileContent: String? = null, - val upstreamInterfaces: List = emptyList(), val portForwards: List = emptyList(), val forceCgroupv1: Boolean = false, val blockNestedNs: Boolean = false, @@ -83,9 +82,6 @@ data class ContainerInfo( if (bindMounts.isNotEmpty()) { appendLine("bind_mounts=${bindMounts.joinToString(",") { "${it.src}:${it.dest}${if (it.ro) ":ro" else ""}" }}") } - if (netMode == "nat" && upstreamInterfaces.isNotEmpty()) { - appendLine("upstream_interfaces=${upstreamInterfaces.joinToString(",")}") - } if (netMode == "nat" && portForwards.isNotEmpty()) { appendLine("port_forwards=${portForwards.joinToString(",") { val mapping = if (it.containerPort != null) "${it.hostPort}:${it.containerPort}" else it.hostPort @@ -268,9 +264,6 @@ object ContainerManager { } } ?: emptyList() - // Parse upstream interfaces - val upstreamInterfaces = configMap["upstream_interfaces"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() - // Parse port forwards: 8080:80/tcp, 9090:90/udp, 1000-2000/tcp (shorthand) val portForwards = configMap["port_forwards"]?.split(",")?.mapNotNull { pfStr -> try { @@ -313,7 +306,6 @@ object ContainerManager { useSparseImage = useSparseImage, sparseImageSizeGB = sparseImageSizeGB, envFileContent = loadEnvFileContent(containerName), - upstreamInterfaces = upstreamInterfaces, portForwards = portForwards, forceCgroupv1 = configMap["force_cgroupv1"] == "1", blockNestedNs = configMap["block_nested_ns"] == "1", @@ -397,29 +389,6 @@ object ContainerManager { } } - /** - * List active upstream interfaces by scanning all routing tables. - * - * Uses `table all` instead of the default table so that CLAT/Qualcomm - * devices are correctly detected - on these devices every interface has - * its own per-interface routing table and nothing appears in the main - * table, so `ip route show default` returns empty. - */ - suspend fun listUpstreamInterfaces(): List = withContext(Dispatchers.IO) { - try { - val busybox = Constants.BUSYBOX_BINARY_PATH - val cmd = "ip route show table all | $busybox grep '^default' | $busybox awk '{for(i=1;i<=NF;i++) if(\$i==\"dev\") print \$(i+1)}' | $busybox grep -Ev '^(ds-|dummy)' | $busybox sort -u" - val result = Shell.cmd(cmd).exec() - if (result.isSuccess) { - result.out.map { it.trim() }.filter { it.isNotEmpty() } - } else { - emptyList() - } - } catch (e: Exception) { - emptyList() - } - } - /** * Update container configuration. * Only updates the configurable options (hostname, flags), not name or rootfsPath. diff --git a/Android/app/src/main/res/values-de/strings.xml b/Android/app/src/main/res/values-de/strings.xml index 9c98c4a2..ee85bd2e 100644 --- a/Android/app/src/main/res/values-de/strings.xml +++ b/Android/app/src/main/res/values-de/strings.xml @@ -325,12 +325,6 @@ NAT Ohne NAT Einstellungen - Upstream-Schnittstellen (obligatorisch) - Für den NAT-Modus muss mindestens eine Upstream-Schnittstelle angeben werden. - Upstream-Schnittstelle hinzufügen - Verfügbare Systemschnittstellen: - Oder manuell eingeben (Platzhalter werden ebenfalls unterstützt): - Schnittstellenname (z.B. wlan0, v4-rmnet_data*) Portweiterleitung Portweiterleitung hinzufügen Host-Port (1–65535 oder Bereich 100–200) diff --git a/Android/app/src/main/res/values-in/strings.xml b/Android/app/src/main/res/values-in/strings.xml index 4d332604..03a515be 100644 --- a/Android/app/src/main/res/values-in/strings.xml +++ b/Android/app/src/main/res/values-in/strings.xml @@ -145,12 +145,6 @@ NAT (Default) Tidak ada (terisolasi secara fisik) Pengaturan NAT - Antarmuka Upstream (Wajib) - Anda harus menentukan setidaknya satu antarmuka upstream untuk mode NAT. - Tambahkan Antarmuka Upstream - Antarmuka Sistem yang Tersedia: - Atau masukkan secara manual (karakter pengganti juga didukung): - Nama Antarmuka (misalnya wlan0, v4-rmnet_data*) Tambahkan Port Forward Port Host (1–65535 atau rentang 100–200) Port Kontainer (1–65535 atau rentang) diff --git a/Android/app/src/main/res/values-it/strings.xml b/Android/app/src/main/res/values-it/strings.xml index 4c189944..ec4c1808 100644 --- a/Android/app/src/main/res/values-it/strings.xml +++ b/Android/app/src/main/res/values-it/strings.xml @@ -161,12 +161,6 @@ NAT (Predefinita) Nessuna (Air-gapped) Impostazioni NAT - Interfacce Upstream (Obbligatorie) - Devi specificare almeno un\'interfaccia upstream per la modalità NAT. - Aggiungi interfaccia upstream - Interfacce di sistema disponibili: - O inseriscila manualmente (i wildcard sono supportati): - Nome interfaccia (es. wlan0, v4-rmnet_data*) Inoltro Porte Aggiungi Port Forward Porta Host (1-65535 o intervallo 100-200) diff --git a/Android/app/src/main/res/values-ja/strings.xml b/Android/app/src/main/res/values-ja/strings.xml index 86ce6ed4..cd3da14b 100644 --- a/Android/app/src/main/res/values-ja/strings.xml +++ b/Android/app/src/main/res/values-ja/strings.xml @@ -245,18 +245,12 @@ SELinuxを記録のみ(permissive)に設定する 旧来のCgroup V1階層構造の利用を強制する 手動デッドロック防止措置 - あるいは手動で入力してください(ワイルドカードも利用できます): - インタフェース名 (wlan0、 v4-rmnet_data*など) Androidの為のLXC風コンテナマネージャ 「%1$s」が選択されました。.tar.xz か .tar.gz 形式のファイルを選択してください。 ファイル形式を検出できませんでした。.tar.xz か .tar.gz 形式のファイルを選択してください。 ディストリビューション ネットワークの方式がNATやNoneであるときには、IPv6は常に無効です。 GPUのノードを隔離された/dev にミラーリングする (完全なるハードウェア透過はしない) - 上流インタフェース(必須) - NATモードでは上流インタフェースを少なくとも1つは指定しなければなりません。 - 上流インタフェースの追加 - 利用可能なシステム・インタフェース: 対称割当の場合は空欄にしてください 不正な範囲形式です(開始-終了) ポート番号は1から65535でなければなりません diff --git a/Android/app/src/main/res/values-km/strings.xml b/Android/app/src/main/res/values-km/strings.xml index 30877d27..741afe42 100644 --- a/Android/app/src/main/res/values-km/strings.xml +++ b/Android/app/src/main/res/values-km/strings.xml @@ -183,12 +183,6 @@ NAT គ្មាន ការកំណត់ NAT - ចំណុចប្រទាក់​ខាងលើ (ចាំបាច់) - អ្នកត្រូវតែបញ្ជាក់យ៉ាងហោចណាស់ចំណុចប្រទាក់ខាងលើមួយសម្រាប់របៀប NAT។ - បន្ថែមចំណុចប្រទាក់ខាងលើ - ចំណុចប្រទាក់ប្រព័ន្ធដែលអាចប្រើបាន៖ - ឬបញ្ចូលវាដោយដៃ (អក្សរជំនួសក៏ត្រូវបានគាំទ្រផងដែរ)៖ - ឈ្មោះចំណុចប្រទាក់ (ឧ. wlan0, v4-rmnet_data*) ការបញ្ជូនបន្តច្រក បន្ថែម​ច្រក​បញ្ជូនបន្ត ច្រកម៉ាស៊ីន (1-65535 ឬចន្លោះពី 100-200) diff --git a/Android/app/src/main/res/values-ro/strings.xml b/Android/app/src/main/res/values-ro/strings.xml index bf72d0b7..bccee0ad 100644 --- a/Android/app/src/main/res/values-ro/strings.xml +++ b/Android/app/src/main/res/values-ro/strings.xml @@ -126,12 +126,6 @@ NAT (implicit) Niciunul (fără nicio conexiune la rețea) Setări NAT - Interfețe de uplink (obligatorii) - Trebuie specificată minim o interfață de uplink pentru modul NAT. - Adaugă interfață de uplink - Interfețe de sistem disponibile: - Sau scrie-le manual (de asemenea, wildcard-urile sunt suportate): - Numele interfeței (de exemplu, wlan0, v4-rmnet_data*) Adaugă port forward Portul gazdei (1-65535 sau intervalul 100-200) Portul container-ului (1-65535 sau interval) diff --git a/Android/app/src/main/res/values-si/strings.xml b/Android/app/src/main/res/values-si/strings.xml index 4f378806..f6d39cd3 100644 --- a/Android/app/src/main/res/values-si/strings.xml +++ b/Android/app/src/main/res/values-si/strings.xml @@ -137,12 +137,6 @@ NAT (පෙරනිමිය) කිසිවක් නැත (Air-gapped) NAT සැකසුම් - ප්‍රභව අතුරුමුහුණත් (අනිවාර්ය) - NAT ක්‍රමය සඳහා අවම වශයෙන් එක් ප්‍රභව අතුරුමුහුණතක් වත් සඳහන් කළ යුතුය. - ප්‍රභව අතුරුමුහුණතක් එක් කරන්න - පද්ධතියේ පවතින අතුරුමුහුණත්: - නැතිනම් එය ඔබම ඇතුළත් කරන්න (Wildcards සඳහාද සහාය දක්වයි): - අතුරුමුහුණතේ නම (උදා: wlan0, v4-rmnet_data*) Port Forwarding Port Forward එකක් එක් කරන්න මව් පද්ධතියේ පෝට් එක (1-65535 හෝ පරාසය 100-200) diff --git a/Android/app/src/main/res/values-tr/strings.xml b/Android/app/src/main/res/values-tr/strings.xml index 692461a7..cd247d10 100644 --- a/Android/app/src/main/res/values-tr/strings.xml +++ b/Android/app/src/main/res/values-tr/strings.xml @@ -148,12 +148,6 @@ NAT (Izole) Yok NAT Ayarları - Upstream Arayüzleri (Zorunlu) - NAT modu için en az bir adet upstream arabirim belirtmeniz gerekir. - Upstream Arayuzu Ekle - Kullanılabilir Sistem Arayüzleri: - Ya da manuel olarak girin (joker karakterler de desteklenmektedir): - Arayuz Adı (örn. wlan0, v4-rmnet_data*) Port Yönlendirme Port Yonlendirme Ekle Host Bağlantı Noktası (1-65535 veya 100-200 aralığı) diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index a537d26d..c27644ee 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -136,12 +136,6 @@ NAT(默认) 无(完全隔离) NAT 设置 - 上游网络接口(必填) - 您必须为 NAT 模式指定至少一个上游网络接口。 - 添加上游网络接口 - 可用的系统接口: - 或手动输入(支持通配符): - 接口名称(如:wlan0, v4-rmnet_data*) 端口转发 添加端口转发 主机端口(1-65535 或范围 100-200) diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index 44ce4ef9..f099e7d5 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -19,7 +19,6 @@ 留空以使用預設值 此IP 已指派給“%1$s ” 異常 - 或手動輸入(支援通配符): 後端: 未安裝 無法讀取容器資訊 使用稀疏映像檔 @@ -40,7 +39,6 @@ 守護程式模式 核心 失敗%1$s %2$s - 新增上游網路介面 容器資訊 編輯容器:%1$s 選擇使用者 @@ -165,7 +163,6 @@ 無法取得Root 權限 已存在同名容器 無(完全隔離) - 上游網路介面(必填) 停用 使用系統預設方案 輸入容器路徑 @@ -182,7 +179,6 @@ 正在移除舊模組... 強制使用舊版Cgroup V1 層級結構 Android 版本 - 可用的系統介面: 卸載容器失敗 %1$s 範圍格式無效(起始-結束) 容器連接埠(1-65535 或範圍) @@ -261,7 +257,6 @@ , 未啟用任何附加選項 未找到服務 - 介面名稱(如:wlan0, v4-rmnet_data*) 僅允許數字、點和冒號 添加 模組安裝失敗 @@ -278,7 +273,6 @@ 安裝概要 正在檢查root存取權限... 重新整理用戶 - 您必須為NAT 模式指定至少一個上游網路介面。 屏蔽服務 留空表示對稱映射 必須指定大小 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 04efce50..b72652b1 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -221,12 +221,6 @@ NAT None NAT Settings - Upstream Interfaces (Mandatory) - You must specify at least one upstream interface for NAT mode. - Add Upstream Interface - Available System Interfaces: - Or enter it manually (wildcards are supported too): - Interface Name (e.g. wlan0, v4-rmnet_data*) Port Forwarding Add Port Forward Host Port (1-65535 or range 100-200) diff --git a/Documentation/Features.md b/Documentation/Features.md index ad4426cd..a97cdacf 100644 --- a/Documentation/Features.md +++ b/Documentation/Features.md @@ -33,7 +33,7 @@ Droidspaces supports three networking modes that determine whether a network nam 1. **Host Mode (`--net=host`) - Default**: Droidspaces deliberately does **not** unshare the network namespace. The container shares the host's network stack. This greatly simplifies setup: containers get internet access immediately without virtual bridges, NAT, or firewall rules. On Android, where networking is already complex (cellular, Wi-Fi, VPN), this avoids a whole category of connectivity issues. -2. **NAT Mode (`--net=nat`)**: The container is placed in a private network namespace. It is connected to the host via a virtual bridge or veth pair, providing **Pure Network Isolation** while maintaining internet access through the host's upstream interfaces. Compatible with the vast majority of Android devices. +2. **NAT Mode (`--net=nat`)**: The container is placed in a private network namespace. It is connected to the host via a virtual bridge or veth pair, providing **Pure Network Isolation** while maintaining internet access through the host's active internet uplink, which is detected automatically. Compatible with the vast majority of Android devices. 3. **None Mode (`--net=none`)**: The container is placed in a private, air-gapped network namespace with only the loopback interface enabled for maximum security. @@ -263,10 +263,10 @@ The container is placed in a private network namespace (`CLONE_NEWNET`) and conn - **Deterministic IP**: Each container is assigned a unique IP in the `172.28.0.0/16` range, derived from its PID. - **Embedded DHCP**: Droidspaces includes a minimal, built-in DHCP server to automatically configure the container's `eth0`. - **Pure Isolation**: The container cannot see or interact with the host's network interfaces directly. -- **Mandatory Upstream**: You **must** specify which host interfaces provide internet access via `--upstream` (e.g., `--upstream wlan0,rmnet0`). Wildcards are also supported (e.g., `rmnet*`, `wlan0`, `v4-rmnet_data*`). +- **Automatic Uplink Detection**: No configuration needed. Droidspaces reads the kernel's own ground truth to find the interface that provides internet access - on Android, the policy-routing rule netd installs for the active default network; on standard Linux, the main routing table's default route. CLAT (464xlat) interfaces on IPv6-only mobile networks are handled automatically. > [!IMPORTANT] -> NAT mode is **IPv4 only**. If your upstream interface lacks an IPv4 address (IPv6-only network), internet access will not work. See [IPv4 NAT Quirks](Troubleshooting.md#ipv4-quirks) for a workaround. +> NAT mode is **IPv4 only**. If the host's uplink lacks an IPv4 address (IPv6-only network), internet access will not work. See [IPv4 NAT Quirks](Troubleshooting.md#ipv4-quirks) for a workaround. ### 3. None Mode (`--net=none`) The container gets a private network namespace with only the loopback (`lo`) interface enabled. @@ -291,8 +291,8 @@ In NAT mode, you can expose container services to the host or local network usin ``` -### Upstream Interface Monitoring -On Android, the connection often hops between Wi-Fi and Mobile Data. Droidspaces includes a **Route Monitor** that tracks your declared `--upstream` interfaces. If your active interface changes (e.g., you walk out of Wi-Fi range), the monitor automatically updates the kernel's policy routing to keep the container connected without a restart. +### Real-Time Uplink Monitoring +On Android, the connection often hops between Wi-Fi and Mobile Data. Droidspaces includes a **Route Monitor** that subscribes to kernel routing events (FIB rules, routes, links, addresses). The moment Android switches its default network (e.g., you walk out of Wi-Fi range), the monitor updates the kernel's policy routing to keep the container connected - no configuration, no restart. --- diff --git a/Documentation/Linux-CLI.md b/Documentation/Linux-CLI.md index 8e788b5f..79546692 100644 --- a/Documentation/Linux-CLI.md +++ b/Documentation/Linux-CLI.md @@ -111,7 +111,6 @@ sudo droidspaces --name=web,db,app stop | Option | Short | Description | |--------|-------|-------------| | `--net=MODE` | | Networking mode: `host` (default), `nat`, or `none`. | -| `--upstream IFACE[,..]` | | Upstream internet interface(s) for NAT mode (e.g., `wlan0,rmnet0`). Wildcards are supported (e.g., `rmnet*`, `v4-rmnet_data*`). **Mandatory for NAT**. | | `--port HOST:CONT[/proto]` | | Forward host port to container (NAT mode). Supports TCP/UDP. | | `--dns=SERVERS` | `-d` | Custom DNS servers, comma-separated. Example: `--dns=1.1.1.1,8.8.8.8` | | `--disable-ipv6` | | Disable IPv6 networking support (Host mode only). | @@ -237,7 +236,6 @@ sudo droidspaces \ --name=server \ --rootfs-img=/path/to/rootfs.img \ --net=nat \ - --upstream=wlan0,rmnet0 \ --port=8080:80 \ start ``` diff --git a/Documentation/Networking-From-Zero.md b/Documentation/Networking-From-Zero.md index ba17f199..3c13e273 100644 --- a/Documentation/Networking-From-Zero.md +++ b/Documentation/Networking-From-Zero.md @@ -238,7 +238,7 @@ In Droidspaces NAT mode, Droidspaces acts *exactly like your home router* - but - Droidspaces installs iptables `MASQUERADE` rules (that is the Linux name for the NAT target), plus FORWARD-accept and MSS-clamp rules so traffic actually flows - The container can reach the internet; the internet sees Android's IP, not the container's IP - Droidspaces also runs an embedded DHCP server for the container and configures its DNS -- On Android, a background route monitor watches the upstream interfaces and re-points container traffic automatically when the active network changes (for example, Wi-Fi to mobile data handoff) +- On Android, a background route monitor detects the active internet uplink automatically (by reading the kernel's routing rules) and re-points container traffic the moment the active network changes (for example, Wi-Fi to mobile data handoff) --- @@ -379,7 +379,7 @@ Android host kernel **Step 1 - You start OpenWRT first (in NAT mode)** ```bash -droidspaces --name=openwrt --rootfs=/data/openwrt --net=nat --upstream=wlan0 start +droidspaces --name=openwrt --rootfs=/data/openwrt --net=nat start ``` OpenWRT boots. It has: @@ -588,7 +588,7 @@ Droidspaces enforces a few rules at startup and refuses to boot if they are viol Two more things to know: -- `--upstream` and `--port` only make sense in NAT mode. In gateway mode they are ignored with a warning - port forwarding and upstream selection are the gateway container's job now. +- `--port` only makes sense in NAT mode. In gateway mode it is ignored with a warning - port forwarding and uplink selection are the gateway container's job now. - When the host bridge name is auto-derived from `--gateway-net`, the name is sanitized (only letters, digits, `_`, `-` survive) and truncated to 9 characters, producing `ds-` plus at most 9 characters. If you need an exact bridge name, set it explicitly with `--gateway-bridge`. --- diff --git a/Documentation/Usage-Android-App.md b/Documentation/Usage-Android-App.md index 316c2456..95de8979 100644 --- a/Documentation/Usage-Android-App.md +++ b/Documentation/Usage-Android-App.md @@ -74,15 +74,8 @@ When editing or creating a container, you can choose from three networking modes - **NAT (Isolated)**: Private network namespace with deterministic IP and port forwarding support. - **None**: No network access. -### Configuring Upstream Interfaces (NAT Mode) -If you select **NAT (Isolated)** mode, you **must** specify one or more upstream interfaces for the container to have internet access. The app provides a convenient auto-detection workflow: - -1. **Detect Wi-Fi**: Connect to your Wi-Fi network and press the refresh button in the "Upstream Interfaces" menu. Select the interface (usually `wlan0`) that appears. -2. **Detect Mobile Data**: Disable Wi-Fi and connect to mobile data. Press the refresh button again and select the mobile data interface (e.g., `rmnet0`, `ccmni1`). -3. **Save**: Both interfaces will now be used by the Route Monitor to keep your container connected as you switch networks. - -> [!TIP] -> You can manually enter wildcards in the Upstream Interfaces list (e.g., `rmnet*`) to ensure connectivity even if your carrier cycles through different interface names (like `rmnet_data0` and `rmnet_data1`). +### Internet Uplink (NAT Mode) +If you select **NAT (Isolated)** mode, the internet uplink is detected fully automatically - there is nothing to configure. Droidspaces reads the kernel's own routing state to find the interface Android is currently using for internet (Wi-Fi, mobile data, ethernet) and a background Route Monitor keeps the container connected in real time as you switch networks. > [!NOTE] > NAT mode is IPv4 only. If your carrier only provides IPv6, see the [IPv4 NAT Workaround](Troubleshooting.md#ipv4-quirks). diff --git a/Documentation/zh-CN/Features.md b/Documentation/zh-CN/Features.md index 0682ba6b..eeb711b8 100644 --- a/Documentation/zh-CN/Features.md +++ b/Documentation/zh-CN/Features.md @@ -33,7 +33,7 @@ Droidspaces 支持三种网络模式,决定是否使用网络命名空间 (`CL 1. **Host 模式 (`--net=host`) - 默认**:Droidspaces 有意**不**隔离网络命名空间。容器共享宿主的网络栈。这大大简化了配置:容器无需虚拟网桥、NAT 或防火墙规则即可立即获取互联网访问。在 Android 上,网络本来就复杂(蜂窝网络、Wi-Fi、VPN),这避免了一整类连接问题。 -2. **NAT 模式 (`--net=nat`)**:容器被放置在私有网络命名空间中。通过虚拟网桥或 veth 对连接到宿主,提供**纯网络隔离**,同时通过宿主的上行接口保持互联网访问。兼容绝大多数 Android 设备。 +2. **NAT 模式 (`--net=nat`)**:容器被放置在私有网络命名空间中。通过虚拟网桥或 veth 对连接到宿主,提供**纯网络隔离**,同时通过宿主当前的互联网上行链路(自动检测)保持互联网访问。兼容绝大多数 Android 设备。 3. **None 模式 (`--net=none`)**:容器被放置在一个私有的、网络隔离的命名空间中,仅启用 loopback 接口,以实现最大安全性。 @@ -264,10 +264,10 @@ Droidspaces 提供三种不同的网络模式,以便于在使用便捷性和 - **确定性 IP**:每个容器在 `172.28.0.0/16` 范围内分配唯一 IP,由其 PID 决定。 - **内置 DHCP**:Droidspaces 包含一个最小的内置 DHCP 服务器,用于自动配置容器的 `eth0`。 - **纯净隔离**:容器无法直接看到或与宿主的网络接口交互。 -- **必须指定上行接口**:您**必须**通过 `--upstream` 指定哪些宿主接口提供互联网访问(例如 `--upstream wlan0,rmnet0`)。也支持通配符(例如 `rmnet*`、`wlan0`、`v4-rmnet_data*`)。 +- **自动上行链路检测**:无需任何配置。Droidspaces 直接读取内核的路由状态来确定提供互联网访问的接口——在 Android 上读取 netd 为活跃默认网络安装的策略路由规则;在标准 Linux 上读取主路由表的默认路由。纯 IPv6 移动网络上的 CLAT (464xlat) 接口也会被自动处理。 > [!IMPORTANT] -> NAT 模式仅支持 **IPv4**。如果您的上行接口缺少 IPv4 地址(纯 IPv6 网络),互联网访问将无法正常工作。请参阅[IPv4 NAT 常见问题](./Troubleshooting.md#ipv4-quirks)以获取变通方案。 +> NAT 模式仅支持 **IPv4**。如果宿主的上行链路缺少 IPv4 地址(纯 IPv6 网络),互联网访问将无法正常工作。请参阅[IPv4 NAT 常见问题](./Troubleshooting.md#ipv4-quirks)以获取变通方案。 ### 3. None 模式 (`--net=none`) 容器获得一个私有网络命名空间,仅启用 loopback (`lo`) 接口。 @@ -292,8 +292,8 @@ Droidspaces 提供三种不同的网络模式,以便于在使用便捷性和 ``` -### 上行接口监控 -在 Android 上,连接经常在 WiFi 和移动数据之间切换。Droidspaces 包含一个**路由监控器**,用于跟踪您声明的 `--upstream` 接口。如果您活跃的接口发生更改(例如您走出了 WiFi 覆盖范围),监控器会自动更新内核的策略路由以保持容器连接,无需重启。 +### 实时上行链路监控 +在 Android 上,连接经常在 WiFi 和移动数据之间切换。Droidspaces 包含一个**路由监控器**,订阅内核路由事件(FIB 规则、路由、链路、地址)。当 Android 切换其默认网络时(例如您走出了 WiFi 覆盖范围),监控器会立即更新内核的策略路由以保持容器连接——无需配置,无需重启。 --- diff --git a/Documentation/zh-CN/Linux-CLI.md b/Documentation/zh-CN/Linux-CLI.md index 6193db47..3a10263a 100644 --- a/Documentation/zh-CN/Linux-CLI.md +++ b/Documentation/zh-CN/Linux-CLI.md @@ -111,7 +111,6 @@ sudo droidspaces --name=web,db,app stop | 选项 | 简写 | 说明 | |--------|------|-------------| | `--net=MODE` | | 网络模式:`host`(默认)、`nat` 或 `none`。 | -| `--upstream IFACE[,..]` | | NAT 模式的上游互联网接口(例如 `wlan0,rmnet0`)。支持通配符(例如 `rmnet*`、`v4-rmnet_data*`)。**NAT 模式必填**。 | | `--port HOST:CONT[/proto]` | | 将主机端口转发到容器(NAT 模式)。支持 TCP/UDP。 | | `--dns=SERVERS` | `-d` | 自定义 DNS 服务器,逗号分隔。示例:`--dns=1.1.1.1,8.8.8.8` | | `--disable-ipv6` | | 禁用 IPv6 网络支持(仅限 Host 模式)。 | @@ -237,7 +236,6 @@ sudo droidspaces \ --name=server \ --rootfs-img=/path/to/rootfs.img \ --net=nat \ - --upstream=wlan0,rmnet0 \ --port=8080:80 \ start ``` diff --git a/src/config.c b/src/config.c index d96efba8..49990963 100644 --- a/src/config.c +++ b/src/config.c @@ -393,36 +393,9 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { else ds_warn("config: ignoring too-long gateway_lan_ifname '%s'", val); } else if (strcmp(key, "upstream_interfaces") == 0) { - /* Comma-separated interface names, e.g. "wlan0,rmnet0,ccmni1" */ - char copy[1024]; - safe_strncpy(copy, val, sizeof(copy)); - char *up_saveptr; - char *up_tok = strtok_r(copy, ",", &up_saveptr); - while (up_tok && cfg->upstream_iface_count < DS_MAX_UPSTREAM_IFACES) { - while (*up_tok == ' ' || *up_tok == '\t') - up_tok++; - char *up_end = up_tok + strlen(up_tok) - 1; - while (up_end > up_tok && (*up_end == ' ' || *up_end == '\t')) - *up_end-- = '\0'; - if (up_tok[0] && strlen(up_tok) < IFNAMSIZ) { - int dup = 0; - for (int i = 0; i < cfg->upstream_iface_count; i++) { - if (strcmp(cfg->upstream_ifaces[i], up_tok) == 0) { - dup = 1; - break; - } - } - if (!dup) { - safe_strncpy(cfg->upstream_ifaces[cfg->upstream_iface_count++], - up_tok, IFNAMSIZ); - } - } - up_tok = strtok_r(NULL, ",", &up_saveptr); - } - if (up_tok) - ds_warn("config: too many upstream_interfaces (max %d) - extra entries " - "ignored", - DS_MAX_UPSTREAM_IFACES); + /* Legacy key from pre-auto-detection builds - the active uplink is + * now detected automatically. Silently swallow it so it is neither + * applied nor re-saved as an unknown line. */ } else if (strcmp(key, "port_forwards") == 0) { /* Comma-separated HOST:CONTAINER[/proto], supporting both single ports * and ranges. Accepted formats: @@ -727,15 +700,6 @@ static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "gateway_lan_ifname=%s\n", cfg->gateway_lan_ifname); } - if (cfg->net_mode == DS_NET_NAT && cfg->upstream_iface_count > 0) { - fprintf(f, "upstream_interfaces="); - for (int i = 0; i < cfg->upstream_iface_count; i++) { - fprintf(f, "%s%s", cfg->upstream_ifaces[i], - (i < cfg->upstream_iface_count - 1) ? "," : ""); - } - fprintf(f, "\n"); - } - if (cfg->net_mode == DS_NET_NAT && cfg->port_forward_count > 0) { fprintf(f, "port_forwards="); for (int i = 0; i < cfg->port_forward_count; i++) { diff --git a/src/container.c b/src/container.c index 7a8b34be..09761da0 100644 --- a/src/container.c +++ b/src/container.c @@ -1566,15 +1566,6 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { cfg->static_nat_ip[0] ? cfg->static_nat_ip : cfg->nat_container_ip; if (ip[0]) printf("NAT_IP=%s\n", ip); - - if (cfg->upstream_iface_count > 0) { - printf("UPSTREAM_INTERFACES="); - for (int i = 0; i < cfg->upstream_iface_count; i++) { - printf("%s%s", cfg->upstream_ifaces[i], - (i < cfg->upstream_iface_count - 1) ? "," : ""); - } - printf("\n"); - } } else if (cfg->net_mode == DS_NET_GATEWAY) { printf("GATEWAY_CONTAINER=%s\n", cfg->gateway_container); printf("GATEWAY_NET=%s\n", @@ -1738,16 +1729,6 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { feat_count++; } - if (cfg->upstream_iface_count > 0) { - printf(" Upstream ifaces: "); - for (int i = 0; i < cfg->upstream_iface_count; i++) { - printf("%s%s", cfg->upstream_ifaces[i], - (i < cfg->upstream_iface_count - 1) ? ", " : ""); - } - printf("\n"); - feat_count++; - } - if (cfg->port_forward_count > 0) { printf(" Port forwards: "); for (int i = 0; i < cfg->port_forward_count; i++) { diff --git a/src/documentation.c b/src/documentation.c index 4edd72ab..0c2754b4 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -289,13 +289,10 @@ static void print_page(int page, const char *bin) { printf("%sNAT Mode Configuration:%s\n", bold, reset); printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " - "--upstream=wlan0 " "start\n", bin); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " - "--upstream=rmnet*,wlan0 " - "start\n\n", - bin); + printf(" The internet uplink is detected automatically and tracked " + "in real time.\n\n"); printf("%sPort Forwarding (NAT only):%s\n", bold, reset); printf(" --port=8080:80 Single port\n"); diff --git a/src/include/droidspace.h b/src/include/droidspace.h index dd9694c0..678475ae 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -244,7 +244,7 @@ struct ds_net_handshake { * Guarantees reply traffic to the container always resolves correctly. * * DS_RULE_PRIO_FROM_SUBNET - "from 172.28.0.0/16 lookup " - * Routes container-originated traffic through the active upstream table. + * Routes container-originated traffic through the active uplink table. */ #ifndef DS_RULE_PRIO_TO_SUBNET #define DS_RULE_PRIO_TO_SUBNET 6090 @@ -284,7 +284,6 @@ struct ds_tty_info { * ---------------------------------------------------------------------------*/ #define DS_MAX_PORT_FORWARDS 32 -#define DS_MAX_UPSTREAM_IFACES 32 struct ds_port_forward { uint16_t host_port; /* port on the Android/Linux host */ @@ -409,10 +408,6 @@ struct ds_config { * instead of re-deriving a PID-hash IP. Plain dotted-decimal, no CIDR. */ char static_nat_ip[INET_ADDRSTRLEN]; - /* Upstream interfaces for NAT routing (--upstream wlan0,rmnet0,...) */ - char upstream_ifaces[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; - int upstream_iface_count; - /* Resource limits (0 = unlimited) */ long long memory_limit; /* bytes */ long long cpu_quota; /* us per period */ @@ -692,6 +687,9 @@ int ds_nl_add_route4(ds_nl_ctx_t *ctx, uint32_t dst_be, uint8_t dst_len, uint32_t gw_be, int oif_idx); int ds_nl_move_to_netns(ds_nl_ctx_t *ctx, const char *ifname, int netns_fd); int ds_nl_get_iface_table(ds_nl_ctx_t *ctx, const char *ifname, int *table_out); +int ds_nl_get_table_default_oif(ds_nl_ctx_t *ctx, int table, char *ifname_out); +int ds_nl_get_android_default(ds_nl_ctx_t *ctx, char *ifname_out, + int *table_out); int ds_nl_add_rule4(ds_nl_ctx_t *ctx, uint32_t src_be, uint8_t src_len, uint32_t dst_be, uint8_t dst_len, int table, int priority); int ds_nl_del_rule4(ds_nl_ctx_t *ctx, uint32_t src_be, uint8_t src_len, diff --git a/src/main.c b/src/main.c index c6f26183..da191abd 100644 --- a/src/main.c +++ b/src/main.c @@ -65,10 +65,6 @@ void print_usage(void) { "ds-NAME)\n" " --nat-ip=IP Assign a fixed IP in 172.28.*.* range (nat " "mode)\n" - " --upstream IFACE Upstream interface(s) (supports wildcards, " - "e.g. rmnet*)\n" - " e.g. --upstream wlan0 or --upstream " - "wlan0,rmnet*\n" " --port [H:]C[/P] Forward ports (supports ranges and " "symmetric ports)\n" " e.g. --port 22, 80:80/tcp, " @@ -318,11 +314,7 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { } } - /* --upstream and --port are only meaningful with --net=nat */ - if (cfg->upstream_iface_count > 0 && cfg->net_mode != DS_NET_NAT) { - ds_warn("--upstream is only valid with --net=nat - ignoring"); - cfg->upstream_iface_count = 0; - } + /* --port is only meaningful with --net=nat */ if (cfg->port_forward_count > 0 && cfg->net_mode != DS_NET_NAT) { ds_warn("--port is only valid with --net=nat - ignoring"); cfg->port_forward_count = 0; @@ -347,21 +339,6 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { if (cfg->net_mode != DS_NET_NAT) return; - /* --upstream is mandatory when using --net=nat */ - if (cfg->upstream_iface_count == 0) { - printf("\n" C_RED C_BOLD "[ FATAL: --upstream REQUIRED ]" C_RESET "\n\n"); - ds_error("--net=nat requires --upstream \n" - "\n" - " Specify the host interface(s) that provide internet access.\n" - " The monitor will track whichever is currently active.\n" - "\n" - " Examples:\n" - " --upstream wlan0\n" - " --upstream wlan0,rmnet0\n" - " --upstream wlan0,rmnet0,ccmni1,v4-ccmni1"); - exit(EXIT_FAILURE); - } - char reason[512]; int probe = ds_nl_probe_nat_capability(reason, sizeof(reason)); if (probe < 0) { @@ -418,7 +395,6 @@ int main(int argc, char **argv) { {"user", required_argument, 0, 'u'}, {"net", required_argument, 0, 257}, {"port", required_argument, 0, 258}, - {"upstream", required_argument, 0, 259}, {"force-cgroupv1", no_argument, 0, 260}, {"block-nested-namespaces", no_argument, 0, 261}, {"privileged", required_argument, 0, 264}, @@ -967,52 +943,6 @@ int main(int argc, char **argv) { break; } - case 259: { - /* --upstream wlan0,rmnet0,ccmni1 (comma-separated list) */ - char tmp[256]; - strncpy(tmp, optarg, sizeof(tmp) - 1); - tmp[sizeof(tmp) - 1] = '\0'; - char *saveptr2; - char *tok2 = strtok_r(tmp, ",", &saveptr2); - while (tok2) { - /* Trim leading/trailing whitespace */ - while (*tok2 == ' ' || *tok2 == '\t') - tok2++; - char *end2 = tok2 + strlen(tok2) - 1; - while (end2 > tok2 && (*end2 == ' ' || *end2 == '\t')) - *end2-- = '\0'; - - if (tok2[0] == '\0') { - tok2 = strtok_r(NULL, ",", &saveptr2); - continue; - } - if (cfg.upstream_iface_count >= DS_MAX_UPSTREAM_IFACES) { - ds_error("Too many --upstream interfaces (max %d)", - DS_MAX_UPSTREAM_IFACES); - ret = 1; - goto cleanup; - } - if (strlen(tok2) >= IFNAMSIZ) { - ds_error("Interface name too long: '%s' (max %d chars)", tok2, - IFNAMSIZ - 1); - ret = 1; - goto cleanup; - } - int dup = 0; - for (int i = 0; i < cfg.upstream_iface_count; i++) { - if (strcmp(cfg.upstream_ifaces[i], tok2) == 0) { - dup = 1; - break; - } - } - if (!dup) { - safe_strncpy(cfg.upstream_ifaces[cfg.upstream_iface_count++], tok2, - IFNAMSIZ); - } - tok2 = strtok_r(NULL, ",", &saveptr2); - } - break; - } case 260: /* --force-cgroupv1: escape hatch to legacy hierarchy */ cfg.force_cgroupv1 = 1; diff --git a/src/net/netlink.c b/src/net/netlink.c index 0b2d1390..afbcddbc 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -23,10 +23,18 @@ * Netlink rule attributes (linux/fib_rules.h - not always in Android sysroot) * ---------------------------------------------------------------------------*/ #ifndef FRA_DST -#define FRA_DST 1 /* destination address */ -#define FRA_SRC 2 /* source address */ -#define FRA_PRIORITY 6 /* rule priority / preference */ -#define FRA_TABLE 15 /* extended table id */ +#define FRA_DST 1 /* destination address */ +#define FRA_SRC 2 /* source address */ +#define FRA_IIFNAME 3 /* input interface name */ +#define FRA_PRIORITY 6 /* rule priority / preference */ +#define FRA_FWMARK 10 /* fwmark value */ +#define FRA_TABLE 15 /* extended table id */ +#define FRA_FWMASK 16 /* fwmark mask */ +#define FRA_UID_RANGE 20 /* uid range (kernel 4.10+; Android per-app rules) */ +#endif + +#ifndef FR_ACT_TO_TBL +#define FR_ACT_TO_TBL 1 /* rule action: look up the referenced table */ #endif /* --------------------------------------------------------------------------- @@ -668,8 +676,8 @@ void ds_nl_flush_stale_veths(ds_nl_ctx_t *ctx, const char *prefix) { * * Fills `names` with up to `max` interface name strings. * Returns the number of interfaces found. - * Used by find_active_upstream() in network.c for wildcard pattern matching - * against entries like "*rmnet_data*" or "v4-rmnet_data*". + * Used by the uplink whitelist scan in network.c for pattern matching + * against interface families like "rmnet*" or "*ccmni*". * ---------------------------------------------------------------------------*/ int ds_nl_list_ifaces(ds_nl_ctx_t *ctx, char names[][IFNAMSIZ], int max) { struct { @@ -793,7 +801,7 @@ int ds_nl_count_ifaces_with_prefix(ds_nl_ctx_t *ctx, const char *prefix) { * Per-interface route table lookup * * Finds the routing table that holds the IPv4 default route for a specific - * named interface. This is the core primitive used by the upstream monitor: + * named interface. This is the core primitive used by the uplink monitor: * rather than guessing the active internet table from all routes (which is * ambiguous on Android where multiple interfaces can have simultaneous * default routes in separate per-interface tables), we ask directly: @@ -871,6 +879,225 @@ int ds_nl_get_iface_table(ds_nl_ctx_t *ctx, const char *ifname, return 0; } +/* --------------------------------------------------------------------------- + * Default-route OIF lookup for a specific routing table + * + * Dumps IPv4 routes and returns the interface owning the default route in + * `table`. When several default routes coexist in the table (multi-homed + * hosts), the lowest metric (RTA_PRIORITY) wins - the same tie-break the + * kernel itself applies. + * + * On Android this transparently handles 464xlat: on IPv6-only mobile + * networks the IPv4 default route inside the default network's table points + * at the CLAT interface (v4-rmnet_dataX), which is exactly the interface + * IPv4 forwarding needs. + * + * Returns 0 and fills ifname_out (IFNAMSIZ) on success. + * Returns -ENOENT if the table has no IPv4 default route. + * ---------------------------------------------------------------------------*/ +int ds_nl_get_table_default_oif(ds_nl_ctx_t *ctx, int table, char *ifname_out) { + struct { + struct nlmsghdr n; + struct rtmsg r; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + req.n.nlmsg_type = RTM_GETROUTE; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.r.rtm_family = AF_INET; + req.n.nlmsg_seq = ++ctx->seq; + req.n.nlmsg_pid = (uint32_t)ctx->pid; + + if (send(ctx->fd, &req, req.n.nlmsg_len, 0) < 0) + return -errno; + + uint8_t buf[NL_BUFSIZE]; + int best_oif = 0; + uint32_t best_metric = 0; + int found = 0; + + for (;;) { + ssize_t n = recv(ctx->fd, buf, sizeof(buf), 0); + if (n <= 0) + break; + + struct nlmsghdr *h = (struct nlmsghdr *)buf; + for (; NLMSG_OK(h, (uint32_t)n); h = NLMSG_NEXT(h, n)) { + if (h->nlmsg_type == NLMSG_DONE) + goto table_oif_done; + if (h->nlmsg_type != RTM_NEWROUTE) + continue; + + struct rtmsg *r = NLMSG_DATA(h); + /* Only IPv4 unicast default routes */ + if (r->rtm_family != AF_INET || r->rtm_dst_len != 0) + continue; + if (r->rtm_type != RTN_UNICAST) + continue; + + int r_table = r->rtm_table; + int r_oif = 0; + uint32_t r_metric = 0; + + struct rtattr *rta = RTM_RTA(r); + int rlen = (int)RTM_PAYLOAD(h); + for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { + if (rta->rta_type == RTA_TABLE) + r_table = *(int *)RTA_DATA(rta); + if (rta->rta_type == RTA_OIF) + r_oif = *(int *)RTA_DATA(rta); + if (rta->rta_type == RTA_PRIORITY) + r_metric = *(uint32_t *)RTA_DATA(rta); + } + + if (r_table != table || r_oif <= 0) + continue; + if (!found || r_metric < best_metric) { + best_oif = r_oif; + best_metric = r_metric; + found = 1; + } + } + } + +table_oif_done: + if (!found) + return -ENOENT; + if (ifname_out) { + ifname_out[0] = '\0'; + if (!if_indextoname((unsigned int)best_oif, ifname_out) || !ifname_out[0]) + return -ENODEV; + } + return 0; +} + +/* --------------------------------------------------------------------------- + * Android default-network detection via the kernel FIB rule table + * + * Android's netd installs exactly one IPv4 rule of the form: + * ": from all fwmark 0x0/0xffff iif lo lookup " + * for the active default internet network. It is swapped atomically when + * the default network changes (wifi <-> mobile data handoffs), making it + * the kernel's ground truth. IMS/MMS-only interfaces never appear here - + * they use explicit fwmarks (0xd0064, 0xd0066 etc). + * + * This is the same source of truth AOSP's own tethering (RouteController) + * consumes; we read it directly over RTM_GETRULE instead of shelling out + * to `ip rule show`, so it works even where no `ip` binary exists. + * + * If several matching rules exist, the lowest FRA_PRIORITY (= highest + * precedence) wins, matching kernel rule evaluation order. + * + * Returns 0 and fills ifname_out (IFNAMSIZ) / table_out on success. + * Returns -ENOENT when no such rule exists (non-Android, airplane mode). + * ---------------------------------------------------------------------------*/ +int ds_nl_get_android_default(ds_nl_ctx_t *ctx, char *ifname_out, + int *table_out) { + struct { + struct nlmsghdr n; + struct rtmsg r; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + req.n.nlmsg_type = RTM_GETRULE; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.r.rtm_family = AF_INET; + req.n.nlmsg_seq = ++ctx->seq; + req.n.nlmsg_pid = (uint32_t)ctx->pid; + + if (send(ctx->fd, &req, req.n.nlmsg_len, 0) < 0) + return -errno; + + uint8_t buf[NL_BUFSIZE]; + int best_table = 0; + uint32_t best_prio = 0; + int found = 0; + + for (;;) { + ssize_t n = recv(ctx->fd, buf, sizeof(buf), 0); + if (n <= 0) + break; + + struct nlmsghdr *h = (struct nlmsghdr *)buf; + for (; NLMSG_OK(h, (uint32_t)n); h = NLMSG_NEXT(h, n)) { + if (h->nlmsg_type == NLMSG_DONE) + goto rule_dump_done; + if (h->nlmsg_type != RTM_NEWRULE) + continue; + + struct rtmsg *r = NLMSG_DATA(h); + if (r->rtm_family != AF_INET) + continue; + /* Only table-lookup actions - skips prohibit/unreachable variants */ + if (r->rtm_type != FR_ACT_TO_TBL) + continue; + /* "from all" only - src/dst selectors mean a different kind of rule */ + if (r->rtm_src_len != 0 || r->rtm_dst_len != 0) + continue; + + int r_table = r->rtm_table; + uint32_t r_prio = 0; + uint32_t fwmark = 0, fwmask = 0; + int have_mark = 0, have_mask = 0, have_uidrange = 0; + char iifname[IFNAMSIZ] = {0}; + + struct rtattr *rta = RTM_RTA(r); + int rlen = (int)RTM_PAYLOAD(h); + for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { + switch (rta->rta_type) { + case FRA_TABLE: + r_table = (int)*(uint32_t *)RTA_DATA(rta); + break; + case FRA_PRIORITY: + r_prio = *(uint32_t *)RTA_DATA(rta); + break; + case FRA_FWMARK: + fwmark = *(uint32_t *)RTA_DATA(rta); + have_mark = 1; + break; + case FRA_FWMASK: + fwmask = *(uint32_t *)RTA_DATA(rta); + have_mask = 1; + break; + case FRA_IIFNAME: + safe_strncpy(iifname, RTA_DATA(rta), IFNAMSIZ); + break; + case FRA_UID_RANGE: + have_uidrange = 1; + break; + } + } + + /* The netd default-network signature: fwmark 0x0/0xffff iif lo */ + if (!have_mark || !have_mask || fwmark != 0 || fwmask != 0xffff) + continue; + if (strcmp(iifname, "lo") != 0) + continue; + /* Skip uid-scoped rules (Android 12+ per-app / work-profile network + * preference). They share the default-network signature at higher + * precedence but only apply to specific uids - we want the global + * default network, which is the rule without a uid range. */ + if (have_uidrange) + continue; + if (r_table <= 0) + continue; + + if (!found || r_prio < best_prio) { + best_table = r_table; + best_prio = r_prio; + found = 1; + } + } + } + +rule_dump_done: + if (!found) + return -ENOENT; + if (table_out) + *table_out = best_table; + return ds_nl_get_table_default_oif(ctx, best_table, ifname_out); +} + /* --------------------------------------------------------------------------- * IPv4 policy rule management (RTM_NEWRULE / RTM_DELRULE) * ---------------------------------------------------------------------------*/ diff --git a/src/net/network.c b/src/net/network.c index fa537c00..ad6380b8 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -169,11 +169,9 @@ static int ds_netns_rename_up(const char *netns_path, const char *old_name, } /* --------------------------------------------------------------------------- - * Upstream routing globals - shared by android routing setup and monitor + * Uplink routing globals - shared by android routing setup and monitor * ---------------------------------------------------------------------------*/ -static char g_upstream_ifaces[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; -static int g_upstream_count = 0; static int g_current_gw_table = 0; static pthread_mutex_t g_gw_mutex = PTHREAD_MUTEX_INITIALIZER; static int g_route_monitor_sock = -1; @@ -196,79 +194,39 @@ static int iface_is_running(const char *ifname) { return ret; } -/* Returns 1 if `pattern` contains a glob wildcard character ('*' or '?'). - * Used to decide whether fnmatch() or strcmp() is appropriate. */ -static int is_wildcard_pattern(const char *pattern) { - return strchr(pattern, '*') != NULL || strchr(pattern, '?') != NULL; -} - -/* Returns 1 if `iface` matches `pattern`. - * If pattern is a plain name, falls back to strcmp for speed. */ -static int iface_matches_pattern(const char *pattern, const char *iface) { - if (is_wildcard_pattern(pattern)) - return fnmatch(pattern, iface, 0) == 0; - return strcmp(pattern, iface) == 0; -} - /* --------------------------------------------------------------------------- - * Android default network detection via kernel ip rules - * - * Android's netd inserts exactly one rule of the form: - * ": from all fwmark 0x0/0xffff iif lo lookup " - * for the active default internet network. This rule is the kernel's - * ground truth - it's set atomically when the default network changes - * (wifi ↔ mobile data ↔ handoff). + * Built-in uplink classification (no user configuration) * - * IMS/MMS-only interfaces (rmnet_data1, rmnet_data6 etc) NEVER appear - * in this rule - they use explicit fwmarks (0xd0064, 0xd0066 etc). - * Only the true internet interface that Qualcomm IPA/MTK CCCI has - * hardware reply sessions for will appear here. + * k_uplink_patterns: the only interface families that terminate internet + * access on Android, highest precedence first - Wi-Fi STA, ethernet + * adapters, CLAT (464xlat IPv4-over-IPv6), Qualcomm mobile data, MTK + * mobile data. * - * Returns 1 and fills iface_out (IFNAMSIZ) on success, 0 on failure. + * k_uplink_excludes: interfaces that must NEVER be picked as an uplink. + * swlan, ap, usb, rndis and ncm devices are created by Android to SHARE + * its connectivity downstream (hotspot / USB tethering - Android acts as + * the router, these face clients, not the internet). tun/ppp are VPN + * tunnels which container traffic intentionally bypasses (see the + * DS_RULE_PRIO_* rationale in droidspace.h). The rest are loopback, + * placeholders, and our own bridge/veth devices. * ---------------------------------------------------------------------------*/ -static int ds_net_get_android_default_iface_from_rules(char *iface_out) { - FILE *fp = popen("ip rule show 2>/dev/null", "r"); - if (!fp) - return 0; - - char line[512]; - int found = 0; - - while (fgets(line, sizeof(line), fp) && !found) { - /* Match lines containing both "fwmark 0x0/0xffff" and "iif lo" */ - if (!strstr(line, "fwmark 0x0/0xffff")) - continue; - if (!strstr(line, "iif lo")) - continue; - char *lookup = strstr(line, "lookup "); - if (!lookup) - continue; - lookup += 7; - - /* Copy interface name until whitespace/newline */ - size_t i = 0; - while (lookup[i] && lookup[i] != ' ' && lookup[i] != '\n' && - lookup[i] != '\r' && lookup[i] != '\t' && i < (size_t)(IFNAMSIZ - 1)) - i++; - if (i == 0) - continue; +static const char *const k_uplink_patterns[] = { + "wlan*", "eth*", "v4-*", "rmnet*", "*ccmni*", +}; - char iface[IFNAMSIZ]; - memcpy(iface, lookup, i); - iface[i] = '\0'; - - /* Skip non-real interfaces */ - if (strcmp(iface, "dummy0") == 0 || strcmp(iface, "lo") == 0 || - strncmp(iface, "ds-", 3) == 0) - continue; +static const char *const k_uplink_excludes[] = { + "ds-*", "lo", "dummy*", "swlan*", "ap*", "usb*", + "rndis*", "ncm*", "p2p*", "tun*", "ppp*", "bt-pan", +}; - safe_strncpy(iface_out, iface, IFNAMSIZ); - found = 1; +static int uplink_name_excluded(const char *ifname) { + for (size_t i = 0; + i < sizeof(k_uplink_excludes) / sizeof(k_uplink_excludes[0]); i++) { + if (fnmatch(k_uplink_excludes[i], ifname, 0) == 0) + return 1; } - - pclose(fp); - return found; + return 0; } /* --------------------------------------------------------------------------- @@ -526,40 +484,28 @@ int fix_networking_host(struct ds_config *cfg) { /* --------------------------------------------------------------------------- * Android-specific policy routing * - * Iterates the user-declared upstream interfaces in priority order, finds the - * first one that is RUNNING and has an IPv4 default route, then injects - * low-priority ip rules to direct container traffic through that table. - * - * This replaces the old auto-detection approach which was unreliable on - * Android MTK/Qualcomm because both wlan0 and mobile-data interfaces can - * have simultaneous default routes in per-interface tables; only the policy - * rules distinguish which is active, and parsing those rules was fragile. + * Detects the active uplink (the routing table netd designates as the + * default internet network), then injects low-priority ip rules to direct + * container traffic through that table. Fully automatic - the route + * monitor keeps the rule in sync across wifi <-> mobile-data handoffs. * ---------------------------------------------------------------------------*/ /* Forward declaration - defined later in this file after route monitor globals */ -static int find_active_upstream(ds_nl_ctx_t *ctx, char *iface_out, - int *table_out); - -static void ds_net_setup_android_routing(ds_nl_ctx_t *ctx, - const char ifaces[][IFNAMSIZ], - int iface_count) { - /* Temporarily populate the globals so find_active_upstream() can use them. - * setup_veth_host_side() copies these right after this call anyway. */ - for (int _i = 0; _i < iface_count && _i < DS_MAX_UPSTREAM_IFACES; _i++) - safe_strncpy(g_upstream_ifaces[_i], ifaces[_i], IFNAMSIZ); - g_upstream_count = iface_count; +static int find_active_uplink(ds_nl_ctx_t *ctx, char *iface_out, + int *table_out); +static void ds_net_setup_android_routing(ds_nl_ctx_t *ctx) { char active_iface[IFNAMSIZ] = {0}; int gw_table = 0; - find_active_upstream(ctx, active_iface, &gw_table); + find_active_uplink(ctx, active_iface, &gw_table); uint32_t subnet_be, mask_be; parse_cidr(DS_DEFAULT_SUBNET, &subnet_be, &mask_be); uint8_t prefix = DS_NAT_PREFIX; /* DS_RULE_PRIO_TO_SUBNET (6090): inbound traffic to our subnet always - * resolves via main table. Install this even if no upstream is active + * resolves via main table. Install this even if no uplink is active * yet - the monitor will handle the FROM rule once an interface comes up. * * Priority 6090 is: @@ -573,15 +519,15 @@ static void ds_net_setup_android_routing(ds_nl_ctx_t *ctx, DS_RULE_PRIO_TO_SUBNET); if (!active_iface[0]) { - ds_warn("[NET] Android routing: no upstream interface is active yet - " + ds_warn("[NET] Android routing: no active uplink detected yet - " "route monitor will install rule when one comes up"); return; } - ds_log("[NET] Android routing: active upstream %s → table %d", active_iface, + ds_log("[NET] Android routing: active uplink %s → table %d", active_iface, gw_table); - /* DS_RULE_PRIO_FROM_SUBNET (6100): traffic from our subnet → upstream + /* DS_RULE_PRIO_FROM_SUBNET (6100): traffic from our subnet → uplink * internet table. Also above Android's VPN range so container-originated * traffic always routes through the physical uplink, not through any VPN * tunnel (the container has its own isolation layer). */ @@ -792,19 +738,9 @@ int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid) { } ds_log("[DEBUG] Successfully moved %s to PID %d", veth_peer, (int)child_pid); - /* 7. Android policy routing - uses the user-declared upstream interfaces */ - if (is_android()) { - ds_net_setup_android_routing(ctx, - (const char (*)[IFNAMSIZ])cfg->upstream_ifaces, - cfg->upstream_iface_count); - } - - /* Cache the upstream list so ds_net_start_route_monitor() can access it. - * setup_veth_host_side() always runs before ds_net_start_route_monitor(). */ - for (int _i = 0; - _i < cfg->upstream_iface_count && _i < DS_MAX_UPSTREAM_IFACES; _i++) - safe_strncpy(g_upstream_ifaces[_i], cfg->upstream_ifaces[_i], IFNAMSIZ); - g_upstream_count = cfg->upstream_iface_count; + /* 7. Android policy routing - uplink is detected automatically */ + if (is_android()) + ds_net_setup_android_routing(ctx); ds_nl_close(ctx); @@ -1166,143 +1102,146 @@ int detect_ipv6_in_container(pid_t pid) { } /* --------------------------------------------------------------------------- - * Upstream Route Monitor + * Uplink Route Monitor * - * Watches LINK state and IPv4 address changes on the user-declared upstream - * interfaces. When any change is detected that involves one of those - * interfaces, it re-scans to find which is currently active (RUNNING + has - * a default route) and atomically updates the policy rule. + * Watches FIB rule, route, link, and IPv4 address changes on the host. + * When a relevant change is detected it re-probes which uplink is currently + * active and atomically updates the container policy rule. Fully automatic + * - no user-declared interface list. * * Event triggers: - * RTM_NEWLINK / RTM_DELLINK - interface state change (UP/RUNNING/DOWN) - * RTM_NEWADDR / RTM_DELADDR - IPv4 address assigned or removed + * RTM_NEWRULE / RTM_DELRULE - netd swaps the default-network rule + * (this IS the wifi <-> mobile handoff) + * RTM_NEWROUTE / RTM_DELROUTE - default route moved between tables + * RTM_NEWLINK / RTM_DELLINK - interface state change (UP/RUNNING/DOWN) + * RTM_NEWADDR / RTM_DELADDR - IPv4 address assigned or removed * - * 30-second heartbeat covers devices with broken netlink notifications. + * A 1.5s heartbeat covers devices with broken netlink notifications and + * re-asserts ip_forward, which Android periodically resets. * ---------------------------------------------------------------------------*/ -/* Scan upstream interfaces in priority order; return the first that is - * RUNNING and has an IPv4 default route in any table. - * - * Entries without wildcards are checked directly (fast path). - * Entries containing '*' or '?' are expanded via a full RTM_GETLINK dump - * and matched with fnmatch() - this handles dynamic interface names like - * "*rmnet_data*" or "v4-rmnet_data*" on Qualcomm/CLAT devices where the - * interface number changes on every reboot. - * - * Each wildcard pattern slot remembers the interface it last resolved to. - * A discovery log fires only when the resolved name changes - this handles - * cleanup (dead interfaces are overwritten), prevents log spam on heartbeat - * reprobes, and uses zero dynamic allocation. */ - -/* Per-pattern "last wildcard match" tracker. Index matches g_upstream_ifaces. - * An empty string means "never matched yet". */ -static char g_last_wildcard_match[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; - -/* Last interface returned by the ip rule probe. Suppresses the - * "[NET] Android default network:" log line on every 1.5s heartbeat - - * only log when the interface actually changes. */ -static char g_last_iprule_iface[IFNAMSIZ]; - -/* Set to 1 when we've already warned about ip rule probe failure. - * Cleared when the probe succeeds again, so the next failure after +/* Last detected uplink interface. Suppresses the "[NET] active uplink:" + * log line on every heartbeat - only log when the result changes. */ +static char g_last_uplink_iface[IFNAMSIZ]; + +/* Set to 1 when we've already warned that automatic detection found no + * uplink. Cleared when a probe succeeds again, so the next failure after * a working period logs once more. */ -static int g_iprule_fail_warned; - -static int find_active_upstream(ds_nl_ctx_t *ctx, char *iface_out, - int *table_out) { - /* On Android, use the kernel ip rule "fwmark 0x0/0xffff iif lo lookup - * " as the primary detection method. This rule is set by netd - * for the active default internet network only - IMS/MMS-only interfaces - * (rmnet_data1, rmnet_data6 etc) never appear here. This also ensures - * we pick the interface Qualcomm IPA / MTK CCCI has hardware reply - * sessions for, which is required for NAT'd forwarded traffic to work. - * - * This replaces the old priority-list-first approach which would pick - * the first RUNNING rmnet_data* with a default route - often an IMS - * interface that is RUNNING and has routes but whose IPA sessions only - * serve locally-originated Android traffic, not forwarded container - * packets. */ - if (is_android()) { - char default_iface[IFNAMSIZ] = {0}; - if (ds_net_get_android_default_iface_from_rules(default_iface) && - default_iface[0] && iface_is_running(default_iface)) { - int tbl = 0; - if (ds_nl_get_iface_table(ctx, default_iface, &tbl) == 0) { - if (strcmp(g_last_iprule_iface, default_iface) != 0) { - ds_log("[NET] Android default network: %s (table %d) [ip rule]", - default_iface, tbl); - safe_strncpy(g_last_iprule_iface, default_iface, - sizeof(g_last_iprule_iface)); - } - g_iprule_fail_warned = 0; /* reset so next failure logs once */ - if (iface_out) - safe_strncpy(iface_out, default_iface, IFNAMSIZ); - if (table_out) - *table_out = tbl; - return 0; - } - } - if (!g_iprule_fail_warned) { - ds_warn("[NET] ip rule probe failed - falling back to priority list"); - g_iprule_fail_warned = 1; - } - g_last_iprule_iface[0] = '\0'; - } +static int g_uplink_fail_warned; + +/* Basic sanity for a probe result: a real, carrier-up interface that is + * not loopback, not ours, and not a downstream/tether/VPN device. */ +static int uplink_candidate_ok(const char *ifname) { + if (!ifname[0]) + return 0; + if (uplink_name_excluded(ifname)) + return 0; + return iface_is_running(ifname); +} - /* Non-Android path (or Android fallback): scan the user-declared - * upstream interface list in priority order. */ - for (int i = 0; i < g_upstream_count; i++) { - const char *pattern = g_upstream_ifaces[i]; +static void log_uplink_change(const char *ifname, int table, + const char *method) { + if (strcmp(g_last_uplink_iface, ifname) != 0) { + ds_log("[NET] Active uplink: %s (table %d) [%s]", ifname, table, method); + safe_strncpy(g_last_uplink_iface, ifname, sizeof(g_last_uplink_iface)); + } + g_uplink_fail_warned = 0; /* reset so next failure logs once */ +} - if (!is_wildcard_pattern(pattern)) { - /* Fast path: literal name */ - if (!iface_is_running(pattern)) +/* Tier 3 (last resort): scan all interfaces against the built-in uplink + * whitelist in priority order; return the first that is RUNNING and has + * an IPv4 default route in some table. */ +static int scan_uplink_whitelist(ds_nl_ctx_t *ctx, char *iface_out, + int *table_out) { + char all_ifaces[64][IFNAMSIZ]; + int all_count = ds_nl_list_ifaces(ctx, all_ifaces, 64); + + for (size_t p = 0; + p < sizeof(k_uplink_patterns) / sizeof(k_uplink_patterns[0]); p++) { + for (int j = 0; j < all_count; j++) { + if (fnmatch(k_uplink_patterns[p], all_ifaces[j], 0) != 0) + continue; + if (!uplink_candidate_ok(all_ifaces[j])) continue; int tbl = 0; - if (ds_nl_get_iface_table(ctx, pattern, &tbl) == 0) { - if (iface_out) - safe_strncpy(iface_out, pattern, IFNAMSIZ); - if (table_out) - *table_out = tbl; - return 0; - } - } else { - /* Wildcard path: enumerate all real interfaces and fnmatch */ - char all_ifaces[64][IFNAMSIZ]; - int all_count = ds_nl_list_ifaces(ctx, all_ifaces, 64); - for (int j = 0; j < all_count; j++) { - if (!iface_matches_pattern(pattern, all_ifaces[j])) - continue; - /* Skip our own bridge/veth and loopback */ - if (strncmp(all_ifaces[j], "ds-", 3) == 0) - continue; - if (strcmp(all_ifaces[j], "lo") == 0) - continue; - if (!iface_is_running(all_ifaces[j])) - continue; - int tbl = 0; - if (ds_nl_get_iface_table(ctx, all_ifaces[j], &tbl) == 0) { - /* Log only when the resolved interface changes for this pattern */ - if (strcmp(g_last_wildcard_match[i], all_ifaces[j]) != 0) { - ds_log("[NET] Wildcard '%s' matched active interface '%s' " - "(table %d)", - pattern, all_ifaces[j], tbl); - safe_strncpy(g_last_wildcard_match[i], all_ifaces[j], IFNAMSIZ); - } - if (iface_out) - safe_strncpy(iface_out, all_ifaces[j], IFNAMSIZ); - if (table_out) - *table_out = tbl; - return 0; - } - } + if (ds_nl_get_iface_table(ctx, all_ifaces[j], &tbl) != 0) + continue; + if (iface_out) + safe_strncpy(iface_out, all_ifaces[j], IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; } } return -ENOENT; } -/* Re-probe which upstream is active and update the ip rule if needed. */ -static void do_upstream_reprobe(void) { +/* Find the interface/table currently providing internet access. + * Fully automatic, three tiers, first hit wins: + * + * 1. Android netd default-network FIB rule (fwmark 0x0/0xffff iif lo). + * The kernel's ground truth - swapped atomically on every handoff, + * and never points at IMS/MMS-only interfaces. Also the only + * interface Qualcomm IPA / MTK CCCI has hardware reply sessions + * for, which NAT'd forwarded traffic requires. + * 2. The main routing table's IPv4 default route - the standard Linux + * case (what LXC/Docker implicitly rely on), and ROMs/chroots that + * populate the main table. + * 3. Built-in whitelist scan of known uplink interface families. + * + * Returns 0 and fills iface_out (IFNAMSIZ) / table_out on success. */ +static int find_active_uplink(ds_nl_ctx_t *ctx, char *iface_out, + int *table_out) { + char name[IFNAMSIZ] = {0}; + int tbl = 0; + + /* Tier 1: netd default-network rule (Android only - the rule simply + * does not exist elsewhere, but skip the dump cost off-Android). */ + if (is_android() && ds_nl_get_android_default(ctx, name, &tbl) == 0 && + uplink_candidate_ok(name)) { + log_uplink_change(name, tbl, "netd rule"); + if (iface_out) + safe_strncpy(iface_out, name, IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + + /* Tier 2: main-table default route (standard Linux semantics). */ + name[0] = '\0'; + if (ds_nl_get_table_default_oif(ctx, RT_TABLE_MAIN, name) == 0 && + uplink_candidate_ok(name)) { + log_uplink_change(name, RT_TABLE_MAIN, "main table"); + if (iface_out) + safe_strncpy(iface_out, name, IFNAMSIZ); + if (table_out) + *table_out = RT_TABLE_MAIN; + return 0; + } + + /* Tier 3: built-in whitelist scan. */ + name[0] = '\0'; + tbl = 0; + if (scan_uplink_whitelist(ctx, name, &tbl) == 0) { + log_uplink_change(name, tbl, "whitelist scan"); + if (iface_out) + safe_strncpy(iface_out, name, IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + + if (!g_uplink_fail_warned) { + ds_warn("[NET] Uplink detection: no active internet interface found - " + "will keep probing"); + g_uplink_fail_warned = 1; + } + g_last_uplink_iface[0] = '\0'; + return -ENOENT; +} + +/* Re-probe which uplink is active and update the ip rule if needed. */ +static void do_uplink_reprobe(void) { ds_nl_ctx_t *ctx = ds_nl_open(); if (!ctx) return; @@ -1310,8 +1249,8 @@ static void do_upstream_reprobe(void) { char new_iface[IFNAMSIZ] = {0}; int new_table = 0; - if (find_active_upstream(ctx, new_iface, &new_table) != 0) { - /* No upstream active yet - leave current rule in place */ + if (find_active_uplink(ctx, new_iface, &new_table) != 0) { + /* No uplink active yet - leave current rule in place */ ds_nl_close(ctx); return; } @@ -1325,7 +1264,7 @@ static void do_upstream_reprobe(void) { return; } - ds_log("[NET] Route monitor: upstream switch table %d → %d (%s)", old_table, + ds_log("[NET] Route monitor: uplink switch table %d → %d (%s)", old_table, new_table, new_iface); uint32_t subnet_be, mask_be; @@ -1357,17 +1296,7 @@ static void do_upstream_reprobe(void) { static void *route_monitor_loop(void *arg) { (void)arg; - /* Build a comma-separated list for the log line */ - /* DS_MAX_UPSTREAM_IFACES * (IFNAMSIZ + 1 for comma) + NUL */ - char iface_list[DS_MAX_UPSTREAM_IFACES * (IFNAMSIZ + 1) + 1]; - memset(iface_list, 0, sizeof(iface_list)); - for (int i = 0; i < g_upstream_count; i++) { - if (i > 0) - strncat(iface_list, ",", sizeof(iface_list) - strlen(iface_list) - 1); - strncat(iface_list, g_upstream_ifaces[i], - sizeof(iface_list) - strlen(iface_list) - 1); - } - ds_log("[NET] Upstream route monitor started (interfaces: %s)", iface_list); + ds_log("[NET] Uplink route monitor started (automatic detection)"); int sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); if (sock < 0) { @@ -1379,9 +1308,15 @@ static void *route_monitor_loop(void *arg) { struct sockaddr_nl sa; memset(&sa, 0, sizeof(sa)); sa.nl_family = AF_NETLINK; - /* RTMGRP_LINK - interface state changes (IFF_RUNNING, link up/down) - * RTMGRP_IPV4_IFADDR - IPv4 address add/remove on upstream interfaces */ - sa.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR; + /* RTMGRP_LINK - interface state changes (IFF_RUNNING, up/down) + * RTMGRP_IPV4_IFADDR - IPv4 address add/remove + * RTMGRP_IPV4_ROUTE - default route moved between tables + * RTNLGRP_IPV4_RULE - netd swapping the default-network FIB rule: + * this IS the wifi <-> mobile handoff signal. + * No legacy RTMGRP_ bitmask macro exists for rule + * groups; the bit is 1 << (RTNLGRP_IPV4_RULE - 1). */ + sa.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV4_ROUTE | + (1u << (RTNLGRP_IPV4_RULE - 1)); if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0) { ds_warn("[NET] Route monitor: failed to bind netlink socket: %s", @@ -1424,7 +1359,7 @@ static void *route_monitor_loop(void *arg) { } if (pr == 0) { - do_upstream_reprobe(); + do_uplink_reprobe(); continue; } @@ -1444,35 +1379,49 @@ static void *route_monitor_loop(void *arg) { if (h->nlmsg_type == NLMSG_DONE || h->nlmsg_type == NLMSG_ERROR) break; - if (h->nlmsg_type == RTM_NEWLINK || h->nlmsg_type == RTM_DELLINK) { - /* Filter: care about events on declared upstream interfaces or any - * interface matching a wildcard pattern (e.g. "*rmnet_data*"). - * A new rmnet_dataX popping up mid-session triggers a reprobe so - * the monitor can adopt the newly-active interface immediately. */ + if (h->nlmsg_type == RTM_NEWRULE || h->nlmsg_type == RTM_DELRULE) { + /* FIB rule change - on Android this is netd switching the default + * network. Always reprobe; the reprobe is idempotent so the echo + * of our own rule updates settles in one extra no-op pass. */ + should_reprobe = 1; + } else if (h->nlmsg_type == RTM_NEWLINK || h->nlmsg_type == RTM_DELLINK) { + /* Any real interface changing state may affect the uplink - e.g. + * a new rmnet_dataX popping up mid-session. Only our own veths + * and loopback are noise. */ struct ifinfomsg *ifi = NLMSG_DATA(h); char evname[IFNAMSIZ] = {0}; if_indextoname((unsigned int)ifi->ifi_index, evname); - if (evname[0] && strncmp(evname, "ds-", 3) != 0) { - for (int i = 0; i < g_upstream_count; i++) { - if (iface_matches_pattern(g_upstream_ifaces[i], evname)) { - should_reprobe = 1; - break; - } - } - } + if (evname[0] && strncmp(evname, "ds-", 3) != 0 && + strcmp(evname, "lo") != 0) + should_reprobe = 1; } else if (h->nlmsg_type == RTM_NEWADDR || h->nlmsg_type == RTM_DELADDR) { struct ifaddrmsg *ifa = NLMSG_DATA(h); if (ifa->ifa_family == AF_INET) { char evname[IFNAMSIZ] = {0}; if_indextoname((unsigned int)ifa->ifa_index, evname); - if (evname[0] && strncmp(evname, "ds-", 3) != 0) { - for (int i = 0; i < g_upstream_count; i++) { - if (iface_matches_pattern(g_upstream_ifaces[i], evname)) { - should_reprobe = 1; - break; - } - } + if (evname[0] && strncmp(evname, "ds-", 3) != 0 && + strcmp(evname, "lo") != 0) + should_reprobe = 1; + } + } else if (h->nlmsg_type == RTM_NEWROUTE || + h->nlmsg_type == RTM_DELROUTE) { + /* Default route added/removed in some table. Filter our own + * subnet/bridge routes by output interface. */ + struct rtmsg *rtm = NLMSG_DATA(h); + if (rtm->rtm_family == AF_INET) { + int oif = 0; + struct rtattr *rta = RTM_RTA(rtm); + int rlen = (int)RTM_PAYLOAD(h); + for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { + if (rta->rta_type == RTA_OIF) + oif = *(int *)RTA_DATA(rta); } + char evname[IFNAMSIZ] = {0}; + if (oif > 0) + if_indextoname((unsigned int)oif, evname); + if (!evname[0] || + (strncmp(evname, "ds-", 3) != 0 && strcmp(evname, "lo") != 0)) + should_reprobe = 1; } } @@ -1480,8 +1429,17 @@ static void *route_monitor_loop(void *arg) { break; } - if (should_reprobe) - do_upstream_reprobe(); + if (should_reprobe) { + /* Handoffs emit a burst of rule/route/addr events back-to-back. + * Drain the socket for a short window so the burst collapses into + * a single reprobe instead of one per event. */ + struct pollfd dp = {.fd = sock, .events = POLLIN}; + while (!g_stop_monitor && poll(&dp, 1, 200) > 0) { + if (recv(sock, buf, sizeof(buf), 0) <= 0) + break; + } + do_uplink_reprobe(); + } } pthread_mutex_lock(&g_gw_mutex); @@ -1489,7 +1447,7 @@ static void *route_monitor_loop(void *arg) { g_route_monitor_sock = -1; pthread_mutex_unlock(&g_gw_mutex); - ds_log("[NET] Upstream route monitor stopped"); + ds_log("[NET] Uplink route monitor stopped"); return NULL; } @@ -1505,11 +1463,6 @@ void ds_net_start_route_monitor(void) { if (!is_android()) return; - if (g_upstream_count == 0) { - ds_warn("[NET] Route monitor: no upstream interfaces defined, skipping"); - return; - } - g_stop_monitor = 0; pthread_t tid; From c29524b110c609cf34081989a5fd8ab884ef6c3b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 11 Jun 2026 12:14:40 +0530 Subject: [PATCH 043/211] netlink: fix netd default-network rule never matching in RTM_GETRULE dump The kernel's fib_nl_fill_rule() only emits FRA_FWMARK when the rule's mark is non-zero. The netd default-network rule is 'fwmark 0x0/0xffff iif lo', so the dump carries only FRA_FWMASK and the previous check (which required both attributes) never matched - every device silently fell back to the tier-3 whitelist scan. Treat an absent FRA_FWMARK as mark == 0, exactly what 'ip rule show' assumes when printing 'fwmark 0x0/0xffff'. All other rule shapes (lookup local, explicit-netId 0x1ffff masks, VPN-range 0x20000 masks, oif rules) are still correctly rejected by the mask/iif/uid filters. Verified on-device: uplink detection now logs '[netd rule]' with the correct per-network table. Signed-off-by: ravindu644 --- src/net/netlink.c | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/net/netlink.c b/src/net/netlink.c index afbcddbc..3c02c6af 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -1068,8 +1068,16 @@ int ds_nl_get_android_default(ds_nl_ctx_t *ctx, char *ifname_out, } } - /* The netd default-network signature: fwmark 0x0/0xffff iif lo */ - if (!have_mark || !have_mask || fwmark != 0 || fwmask != 0xffff) + /* The netd default-network signature: fwmark 0x0/0xffff iif lo. + * + * Kernel quirk: fib_nl_fill_rule() only emits FRA_FWMARK when the + * mark is non-zero, so for this rule (mark 0x0) the attribute is + * absent from the dump and only FRA_FWMASK (0xffff) is present. + * An absent FRA_FWMARK therefore means mark == 0 - exactly what + * `ip rule show` assumes when it prints "fwmark 0x0/0xffff". */ + if (!have_mask || fwmask != 0xffff) + continue; + if (have_mark && fwmark != 0) continue; if (strcmp(iifname, "lo") != 0) continue; From 899b1aef497e3dab78aa6aaacf6346ba293ec627 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 11 Jun 2026 12:16:01 +0530 Subject: [PATCH 044/211] updated .gitignore and submodules Signed-off-by: ravindu644 --- .gitignore | 2 ++ external/www | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8f74aa1f..8bdbb956 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ output/ *.config .cache .claude +Android/app/src/main/assets/binaries/ +Android/app/src/main/assets/supported_locales.txt # Keystores *.keystore diff --git a/external/www b/external/www index c3485496..7351ecd3 160000 --- a/external/www +++ b/external/www @@ -1 +1 @@ -Subproject commit c34854969e4f46bbd64bd6eb2f873f18eff0af28 +Subproject commit 7351ecd3a85fe6ef019eb52d8ce7b6575739aa5e From 4184d97dc628c5874beb901595ac4d35c89173af Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 11 Jun 2026 18:07:36 +0530 Subject: [PATCH 045/211] net: fix port forwards being unreachable from hotspot/USB-tether clients Reply packets of DNAT'd port-forward connections are routed before conntrack un-DNATs them (the source rewrite happens in POSTROUTING), so they still carry the container source address at route-lookup time. The 'from 172.28.0.0/16 lookup ' rule (6100) therefore captured replies destined to hotspot/tether clients and leaked them out the WAN interface - verified live: SYN-ACKs to a hotspot client egressing rmnet0 while the client retried forever. Insert one static rule between the two existing ones: 6095: from 172.28.0.0/16 lookup local_network(97) netd keeps the connected route of every downstream interface (swlan*, ap*, rndis*, ncm*, bt-pan) in the local_network table, which has no default route - so the rule only matches tether-bound replies and falls through to 6100 otherwise. Covers WiFi hotspot, USB and Bluetooth tethering with no tracking and no monitor involvement, and also gives container-initiated traffic a path to tether clients. Verified on-device: SSH and Jellyfin from hotspot clients via both the gateway-IP port forward and direct container IP routing. Signed-off-by: ravindu644 --- Documentation/Features.md | 2 ++ Documentation/zh-CN/Features.md | 2 ++ src/include/droidspace.h | 21 +++++++++++++++++++++ src/net/network.c | 12 ++++++++++++ 4 files changed, 37 insertions(+) diff --git a/Documentation/Features.md b/Documentation/Features.md index a97cdacf..1dab0450 100644 --- a/Documentation/Features.md +++ b/Documentation/Features.md @@ -290,6 +290,8 @@ In NAT mode, you can expose container services to the host or local network usin --port 2222:22/tcp --port 5000-5050:5000-5050/udp ``` +Forwarded ports are reachable from any network the host belongs to - including clients connected to the phone's own hotspot or USB tethering on Android. + ### Real-Time Uplink Monitoring On Android, the connection often hops between Wi-Fi and Mobile Data. Droidspaces includes a **Route Monitor** that subscribes to kernel routing events (FIB rules, routes, links, addresses). The moment Android switches its default network (e.g., you walk out of Wi-Fi range), the monitor updates the kernel's policy routing to keep the container connected - no configuration, no restart. diff --git a/Documentation/zh-CN/Features.md b/Documentation/zh-CN/Features.md index eeb711b8..64587f3f 100644 --- a/Documentation/zh-CN/Features.md +++ b/Documentation/zh-CN/Features.md @@ -291,6 +291,8 @@ Droidspaces 提供三种不同的网络模式,以便于在使用便捷性和 --port 2222:22/tcp --port 5000-5050:5000-5050/udp ``` +转发的端口可以从宿主所在的任何网络访问——包括在 Android 上连接到手机自身热点或 USB 网络共享的客户端。 + ### 实时上行链路监控 在 Android 上,连接经常在 WiFi 和移动数据之间切换。Droidspaces 包含一个**路由监控器**,订阅内核路由事件(FIB 规则、路由、链路、地址)。当 Android 切换其默认网络时(例如您走出了 WiFi 覆盖范围),监控器会立即更新内核的策略路由以保持容器连接——无需配置,无需重启。 diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 678475ae..ef828701 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -243,16 +243,37 @@ struct ds_net_handshake { * DS_RULE_PRIO_TO_SUBNET - "to 172.28.0.0/16 lookup main" * Guarantees reply traffic to the container always resolves correctly. * + * DS_RULE_PRIO_TETHER - "from 172.28.0.0/16 lookup local_network(97)" + * Returns container replies to hotspot/USB-tether clients out the tether + * interface. Reply packets of DNAT'd port-forward connections still carry + * the container source address at routing time (conntrack un-DNATs in + * POSTROUTING, after the route lookup), so without this rule they would + * match DS_RULE_PRIO_FROM_SUBNET and leak out the WAN uplink instead of + * reaching the tethered client. netd keeps the connected route of every + * downstream interface (swlan*, ap*, rndis*, ncm*, bt-pan) in the + * local_network table, which has no default route - so this rule only + * matches tether-bound traffic and falls through to FROM_SUBNET otherwise. + * Must sit between TO_SUBNET and FROM_SUBNET. + * * DS_RULE_PRIO_FROM_SUBNET - "from 172.28.0.0/16 lookup " * Routes container-originated traffic through the active uplink table. */ #ifndef DS_RULE_PRIO_TO_SUBNET #define DS_RULE_PRIO_TO_SUBNET 6090 #endif +#ifndef DS_RULE_PRIO_TETHER +#define DS_RULE_PRIO_TETHER 6095 +#endif #ifndef DS_RULE_PRIO_FROM_SUBNET #define DS_RULE_PRIO_FROM_SUBNET 6100 #endif +/* netd's fixed local_network routing table + * (RouteController::ROUTE_TABLE_LOCAL_NETWORK, constant since Android 5.x). */ +#ifndef DS_ANDROID_TABLE_LOCAL_NETWORK +#define DS_ANDROID_TABLE_LOCAL_NETWORK 97 +#endif + /* Bind mount entry */ struct ds_bind_mount { char src[PATH_MAX]; diff --git a/src/net/network.c b/src/net/network.c index ad6380b8..d8e3b455 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -518,6 +518,17 @@ static void ds_net_setup_android_routing(ds_nl_ctx_t *ctx) { ds_warn("[NET] Android routing: failed to add 'to subnet' rule (%d)", DS_RULE_PRIO_TO_SUBNET); + /* DS_RULE_PRIO_TETHER (6095): replies from our subnet to hotspot/USB-tether + * clients must consult netd's local_network table (which holds every + * downstream interface's connected route and no default route) before the + * uplink table grabs them. Installed regardless of uplink state - tether + * clients can reach forwarded ports even with no WAN. */ + ret = ds_nl_add_rule4(ctx, subnet_be, prefix, 0, 0, + DS_ANDROID_TABLE_LOCAL_NETWORK, DS_RULE_PRIO_TETHER); + if (ret < 0) + ds_warn("[NET] Android routing: failed to add tether-return rule (%d)", + DS_RULE_PRIO_TETHER); + if (!active_iface[0]) { ds_warn("[NET] Android routing: no active uplink detected yet - " "route monitor will install rule when one comes up"); @@ -1550,6 +1561,7 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { * cleans up completely. del_rule4 is idempotent (ENOENT → 0). */ int prios[] = { DS_RULE_PRIO_TO_SUBNET, /* 6090 - current */ + DS_RULE_PRIO_TETHER, /* 6095 - current */ DS_RULE_PRIO_FROM_SUBNET, /* 6100 - current */ 90, 100, From db90b9aac5e710b6f93d669bcab6aff158e94e1f Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 13 Jun 2026 16:19:32 +0530 Subject: [PATCH 046/211] feat: add auto-boot priority ordering for gateway networking mode Containers with run_at_boot=1 previously started in arbitrary filesystem order, so a gateway-mode client could boot before its router (e.g. OpenWRT) and come up with no network. Add a user-defined boot order so the gateway starts first, then its clients. App: - ContainerInfo gains run_at_boot_priority; written/parsed in container.config - New "Auto Boot Priority" settings screen (above Requirements) listing every run-at-boot container with a drag handle to reorder; Save assigns 1..N. Reuses the edit-screen "Save Changes" button (greyed until changes) and the containers-tab distro icon styling. Uses sh.calvin.reorderable. Boot scripts (service.sh + droidspaces_autoboot.sh): - Sort by run_at_boot_priority ascending (missing/invalid sort last, then name); gaps and large sentinels handled gracefully. - Dependency-aware: a net_mode=gateway client is skipped if its gateway_container is not up (failed this run, not running, or uninstalled); independent containers always boot and a single failure never blocks others. - Strict POSIX sh; service.sh routes every applet (incl. printf/basename/ dirname) through busybox since some devices ship a broken printf. Signed-off-by: ravindu644 --- Android/app/build.gradle.kts | 4 + .../src/main/assets/boot-module/service.sh | 117 +++++- .../ui/navigation/DroidspacesNavigation.kt | 20 + .../app/ui/screen/AutoBootPriorityScreen.kt | 365 ++++++++++++++++++ .../app/ui/screen/SettingsScreen.kt | 30 +- .../droidspaces/app/util/ContainerManager.kt | 5 + Android/app/src/main/res/values/strings.xml | 5 + .../vendor/bin/droidspaces_autoboot.sh | 109 +++++- 8 files changed, 620 insertions(+), 35 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/screen/AutoBootPriorityScreen.kt diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 8c8af54c..78a2a40c 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -319,6 +319,10 @@ dependencies { // Navigation implementation("androidx.navigation:navigation-compose:2.7.7") + // Drag-and-drop reordering for Compose lists (Auto boot priority screen) + // 2.1.x targets Compose 1.6.x, matching the BOM above. + implementation("sh.calvin.reorderable:reorderable:2.1.1") + // ViewModel implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") diff --git a/Android/app/src/main/assets/boot-module/service.sh b/Android/app/src/main/assets/boot-module/service.sh index 742ca1ac..bb936308 100644 --- a/Android/app/src/main/assets/boot-module/service.sh +++ b/Android/app/src/main/assets/boot-module/service.sh @@ -145,39 +145,118 @@ else log "Daemon mode disabled, skipping" fi -# Auto-boot containers +# Auto-boot containers (priority-ordered, gateway-aware) +# +# Containers boot from the smallest run_at_boot_priority to the largest; missing +# or invalid priorities sort last (best-effort), then alphabetically. A +# net_mode=gateway client is skipped if its gateway_container did not come up, +# so we never boot a router-less client. Independent containers always boot, and +# a single failure never blocks the rest. log "Scanning for containers with run_at_boot=1..." success=0 failed=0 +skipped=0 +TAB=$(${BUSYBOX_BINARY} printf '\t') +BOOT_TABLE="${DROIDSPACE_DIR}/.autoboot_table" +SORTED_FILE="${DROIDSPACE_DIR}/.autoboot_sorted" +STARTED_OK="${DROIDSPACE_DIR}/.autoboot_started" +: > "${BOOT_TABLE}" +: > "${STARTED_OK}" + +# Read a single key=value from a config file +get_cfg_val() { + ${BUSYBOX_BINARY} grep "^$1=" "$2" 2>/dev/null | \ + ${BUSYBOX_BINARY} head -1 | ${BUSYBOX_BINARY} sed 's/^[^=]*=//' | \ + ${BUSYBOX_BINARY} tr -d '\r\n' +} + +# Find the container.config whose name= matches $1 (echoes path, empty if none) +cfg_for_name() { + for c in $(${BUSYBOX_BINARY} find "${CONTAINERS_DIR}" -name "container.config" 2>/dev/null); do + n=$(get_cfg_val name "$c") + [ -n "$n" ] || n=$(${BUSYBOX_BINARY} basename "$(${BUSYBOX_BINARY} dirname "$c")") + if [ "$n" = "$1" ]; then + echo "$c" + return 0 + fi + done + return 1 +} + +# A gateway is available if it started during this run, or is already running +gateway_available() { + if ${BUSYBOX_BINARY} grep -qxF "$1" "${STARTED_OK}" 2>/dev/null; then + return 0 + fi + gcfg=$(cfg_for_name "$1") + [ -n "${gcfg}" ] || return 1 + gpid=$("${DROIDSPACE_BINARY}" --config "${gcfg}" pid 2>/dev/null) + [ "${gpid}" != "NONE" ] && [ -n "${gpid}" ] +} + +# Start one container; returns 0 if it ends up with a live PID +boot_container() { + "${DROIDSPACE_BINARY}" --config "$1" start 2>&1 | \ + ${BUSYBOX_BINARY} sed "s/$(${BUSYBOX_BINARY} printf '\033')\[[0-9;]*[mK]//g" + bpid=$("${DROIDSPACE_BINARY}" --config "$1" pid 2>/dev/null) + [ "${bpid}" != "NONE" ] && [ -n "${bpid}" ] +} + +# Build the boot table: PRIORITY \t NAME \t NET_MODE \t GATEWAY \t CFG for cfg in $(${BUSYBOX_BINARY} find "${CONTAINERS_DIR}" -name "container.config" 2>/dev/null); do [ -f "${cfg}" ] || continue + [ "$(get_cfg_val run_at_boot "${cfg}")" = "1" ] || continue - run_at_boot=$(${BUSYBOX_BINARY} grep "^run_at_boot=" "${cfg}" 2>/dev/null | \ - ${BUSYBOX_BINARY} head -1 | ${BUSYBOX_BINARY} sed 's/^[^=]*=//' | \ - ${BUSYBOX_BINARY} tr -d '\r\n') + name=$(get_cfg_val name "${cfg}") + [ -n "${name}" ] || name=$(${BUSYBOX_BINARY} basename "$(${BUSYBOX_BINARY} dirname "${cfg}")") - [ "${run_at_boot}" = "1" ] || continue + prio=$(get_cfg_val run_at_boot_priority "${cfg}") + case "${prio}" in + ''|*[!0-9]*) prio=99999999 ;; + esac - name=$(${BUSYBOX_BINARY} grep "^name=" "${cfg}" 2>/dev/null | \ - ${BUSYBOX_BINARY} head -1 | ${BUSYBOX_BINARY} sed 's/^[^=]*=//' | \ - ${BUSYBOX_BINARY} tr -d '\r\n') - display="${name:-$(basename "$(dirname "${cfg}")")}" + # '-' placeholder for empty fields: IFS= collapses adjacent tabs + # (tab is IFS whitespace), so empty middle fields would shift the columns. + netmode=$(get_cfg_val net_mode "${cfg}") + [ -n "${netmode}" ] || netmode="-" + gw=$(get_cfg_val gateway_container "${cfg}") + [ -n "${gw}" ] || gw="-" + + ${BUSYBOX_BINARY} printf '%s\t%s\t%s\t%s\t%s\n' "${prio}" "${name}" "${netmode}" "${gw}" "${cfg}" >> "${BOOT_TABLE}" +done - log "Starting container: ${display}" - "${DROIDSPACE_BINARY}" --config "${cfg}" start 2>&1 | \ - ${BUSYBOX_BINARY} sed "s/$(printf '\033')\[[0-9;]*[mK]//g" +# Smallest priority first, then name. Gaps/holes and large sentinels sort cleanly. +${BUSYBOX_BINARY} sort -t"${TAB}" -k1,1n -k2,2 "${BOOT_TABLE}" > "${SORTED_FILE}" - PID=$("${DROIDSPACE_BINARY}" --config "${cfg}" pid 2>/dev/null) - if [ "${PID}" != "NONE" ] && [ -n "${PID}" ]; then - log "SUCCESS: ${display} (PID: ${PID})" +# Reading from a file (not a pipe) keeps the loop in this shell so the counters +# survive each iteration. +while IFS="${TAB}" read -r prio name netmode gw cfg; do + [ -n "${cfg}" ] || continue + [ "${gw}" = "-" ] && gw="" + + if [ "${netmode}" = "gateway" ] && [ -n "${gw}" ]; then + if ! gateway_available "${gw}"; then + log "SKIP: ${name} (gateway '${gw}' not running)" + skipped=$((skipped + 1)) + continue + fi + fi + + log "Starting container: ${name}" + if boot_container "${cfg}"; then + PID=$("${DROIDSPACE_BINARY}" --config "${cfg}" pid 2>/dev/null) + log "SUCCESS: ${name} (PID: ${PID})" + ${BUSYBOX_BINARY} printf '%s\n' "${name}" >> "${STARTED_OK}" success=$((success + 1)) else - log "FAILED: ${display}" + log "FAILED: ${name}" failed=$((failed + 1)) fi -done +done < "${SORTED_FILE}" + +${BUSYBOX_BINARY} rm -f "${BOOT_TABLE}" "${SORTED_FILE}" "${STARTED_OK}" 2>/dev/null stop_dmesg_logger -log "Boot Summary: Daemon: ${DAEMON_STATUS} | ${success} started | ${failed} failed" -update_prop "Daemon: ${DAEMON_STATUS} | Containers: ${success} started, ${failed} failed" +log "Boot Summary: Daemon: ${DAEMON_STATUS} | ${success} started | ${failed} failed | ${skipped} skipped" +update_prop "Daemon: ${DAEMON_STATUS} | Containers: ${success} started, ${failed} failed, ${skipped} skipped" diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index a7451429..0b4c211c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -24,6 +24,7 @@ import com.droidspaces.app.ui.screen.MainTabScreen import com.droidspaces.app.ui.screen.RootCheckScreen import com.droidspaces.app.ui.screen.SettingsScreen import com.droidspaces.app.ui.screen.RequirementsScreen +import com.droidspaces.app.ui.screen.AutoBootPriorityScreen import com.droidspaces.app.ui.screen.WelcomeScreen import com.droidspaces.app.ui.screen.ContainerNameScreen import com.droidspaces.app.ui.screen.SparseImageConfigScreen @@ -68,6 +69,7 @@ sealed class Screen(val route: String) { } data object Settings : Screen("settings") data object Requirements : Screen("requirements") + data object AutoBootPriority : Screen("auto_boot_priority") // Container installation wizard screens data object ContainerName : Screen("container_name/{tarballUri}") { @@ -506,6 +508,24 @@ fun DroidspacesNavigation( }, onNavigateToRequirements = { navController.navigate(Screen.Requirements.route) + }, + onNavigateToAutoBootPriority = { + navController.navigate(Screen.AutoBootPriority.route) + } + ) + } + + composable( + route = Screen.AutoBootPriority.route, + enterTransition = defaultEnterTransition, + exitTransition = defaultExitTransition, + popEnterTransition = defaultEnterTransition, + popExitTransition = defaultExitTransition + ) { + AutoBootPriorityScreen( + containerViewModel = sharedContainerViewModel, + onBack = { + navController.popBackStack() } ) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/AutoBootPriorityScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/AutoBootPriorityScreen.kt new file mode 100644 index 00000000..9b8a4c9c --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/AutoBootPriorityScreen.kt @@ -0,0 +1,365 @@ +package com.droidspaces.app.ui.screen + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +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 com.droidspaces.app.R +import com.droidspaces.app.ui.util.LoadingIndicator +import com.droidspaces.app.ui.viewmodel.ContainerViewModel +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.ContainerManager +import com.droidspaces.app.util.ContainerOSInfoManager +import com.droidspaces.app.util.IconUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun AutoBootPriorityScreen( + containerViewModel: ContainerViewModel, + onBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Snapshot of run-at-boot containers, ordered by saved priority (unset goes + // last, then alphabetical). Built once so user drags are not clobbered. + val initialOrder = remember { + containerViewModel.containerList + .filter { it.runAtBoot } + .sortedWith( + compareBy( + { if (it.runAtBootPriority > 0) it.runAtBootPriority else Int.MAX_VALUE }, + { it.name.lowercase() } + ) + ) + } + var items by remember { mutableStateOf(initialOrder) } + // Baseline order persisted so far; updated after a successful save so the + // button greys out again once there is nothing new to save. + var savedNames by remember { mutableStateOf(initialOrder.map { it.name }) } + val hasChanges by remember { derivedStateOf { items.map { it.name } != savedNames } } + var isSaving by remember { mutableStateOf(false) } + var isSaved by remember { mutableStateOf(false) } + + // Drop the "Saved" state as soon as the user reorders again. + LaunchedEffect(hasChanges) { + if (hasChanges && isSaved) isSaved = false + } + + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> + items = items.toMutableList().apply { add(to.index, removeAt(from.index)) } + } + + fun saveChanges() { + scope.launch { + isSaving = true + isSaved = false + withContext(Dispatchers.IO) { + // Assign 1..N by current order -- collision-free by construction. + items.forEachIndexed { index, info -> + ContainerManager.updateContainerConfig( + context, + info.name, + info.copy(runAtBootPriority = index + 1) + ) + } + } + containerViewModel.refresh() + savedNames = items.map { it.name } + isSaving = false + isSaved = true + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(context.getString(R.string.auto_boot_priority)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = context.getString(R.string.back) + ) + } + } + ) + }, + bottomBar = { + if (items.isNotEmpty()) { + val btnShape = RoundedCornerShape(20.dp) + val isReadyToSave = !isSaving && !isSaved && hasChanges + val targetBtnColor = when { + isSaved -> MaterialTheme.colorScheme.primaryContainer + isSaving || isReadyToSave -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + } + val animatedBtnColor by animateColorAsState( + targetValue = targetBtnColor, + animationSpec = tween(durationMillis = 250), + label = "btn_color" + ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + .navigationBarsPadding() + .clip(btnShape) + .clickable( + enabled = isReadyToSave, + onClick = { saveChanges() }, + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ), + shape = btnShape, + color = animatedBtnColor, + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(vertical = 16.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { + when { + isSaved -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = context.getString(R.string.saved), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + isSaving -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LoadingIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = context.getString(R.string.saving), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + else -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + Text( + text = context.getString(R.string.save_changes), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } + } + } + } + } + } + } + ) { innerPadding -> + if (items.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 32.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.outline + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + context.getString(R.string.auto_boot_priority_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + } + } + } else { + // Hint lives OUTSIDE the LazyColumn so the reorderable item indices map + // 1:1 to `items` (a header item would offset from.index/to.index). + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .padding(horizontal = 16.dp) + ) { + Text( + text = context.getString(R.string.auto_boot_priority_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp) + ) + + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items, key = { it.name }) { container -> + ReorderableItem(reorderableState, key = container.name) { isDragging -> + BootPriorityRow( + index = items.indexOf(container) + 1, + container = container, + isDragging = isDragging, + dragHandle = { + IconButton( + modifier = Modifier.draggableHandle(), + onClick = {} + ) { + Icon( + Icons.Default.DragHandle, + contentDescription = context.getString(R.string.reorder_handle), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } + } + } + } + } + } +} + +@Composable +private fun BootPriorityRow( + index: Int, + container: ContainerInfo, + isDragging: Boolean, + dragHandle: @Composable () -> Unit +) { + val elevation = if (isDragging) 8.dp else 0.dp + Surface( + modifier = Modifier + .fillMaxWidth() + .shadow(elevation, RoundedCornerShape(16.dp), clip = false), + shape = RoundedCornerShape(16.dp), + color = if (isDragging) + MaterialTheme.colorScheme.surfaceVariant + else + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Priority position badge + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + tonalElevation = 0.dp + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = index.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + // Distro icon, sourced + tinted exactly like the containers tab. + val context = LocalContext.current + val cacheVersion by ContainerOSInfoManager.iconCacheVersion + val cachedOsInfo = remember(container.name, cacheVersion) { + ContainerOSInfoManager.getCachedOSInfo(container.name, context) + } + Icon( + painter = IconUtils.getDistroIcon(cachedOsInfo?.prettyName ?: cachedOsInfo?.name), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (container.isRunning) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + + Text( + text = container.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + dragHandle() + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt index 6b694572..83cbfec7 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt @@ -66,7 +66,8 @@ import androidx.compose.runtime.rememberCoroutineScope fun SettingsScreen( onBack: () -> Unit, onNavigateToInstallation: () -> Unit = {}, - onNavigateToRequirements: () -> Unit = {} + onNavigateToRequirements: () -> Unit = {}, + onNavigateToAutoBootPriority: () -> Unit = {} ) { val context = LocalContext.current val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -245,6 +246,33 @@ fun SettingsScreen( } ) + // Auto Boot Priority - clickable to navigate to the boot-order screen + ListItem( + leadingContent = { + Icon( + imageVector = Icons.Default.LowPriority, + contentDescription = null + ) + }, + headlineContent = { + Text( + text = context.getString(R.string.auto_boot_priority), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + }, + supportingContent = { + Text(context.getString(R.string.auto_boot_priority_description)) + }, + trailingContent = { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null + ) + }, + modifier = Modifier.clickable { onNavigateToAutoBootPriority() } + ) + // Requirements Card - clickable to navigate to requirements page RequirementsCard( onNavigateToRequirements = onNavigateToRequirements diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index d97578d6..1e54e1fc 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -44,6 +44,7 @@ data class ContainerInfo( val bindMounts: List = emptyList(), val dnsServers: String = "", val runAtBoot: Boolean = false, + val runAtBootPriority: Int = 0, val status: ContainerStatus = ContainerStatus.STOPPED, val pid: Int? = null, val useSparseImage: Boolean = false, @@ -92,6 +93,9 @@ data class ContainerInfo( appendLine("dns_servers=$dnsServers") } appendLine("run_at_boot=${if (runAtBoot) "1" else "0"}") + if (runAtBoot && runAtBootPriority > 0) { + appendLine("run_at_boot_priority=$runAtBootPriority") + } appendLine("force_cgroupv1=${if (forceCgroupv1) "1" else "0"}") appendLine("block_nested_ns=${if (blockNestedNs) "1" else "0"}") if (netMode == "nat" && staticNatIp.isNotEmpty()) { @@ -302,6 +306,7 @@ object ContainerManager { bindMounts = bindMounts, dnsServers = configMap["dns_servers"] ?: "", runAtBoot = configMap["run_at_boot"] == "1", + runAtBootPriority = configMap["run_at_boot_priority"]?.toIntOrNull() ?: 0, status = ContainerStatus.STOPPED, useSparseImage = useSparseImage, sparseImageSizeGB = sparseImageSizeGB, diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index b72652b1..f8841d8e 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -202,6 +202,11 @@ Set SELinux permissive Run at Boot Start container on boot + Auto Boot Priority + Order in which run-at-boot containers start + Drag to reorder. Containers start top to bottom - put a gateway (e.g. OpenWRT) first so it boots before the containers that route through it. + No containers have Run at Boot enabled. + Drag to reorder Force Cgroup V1 Force use of legacy Cgroup V1 hierarchy Manual Deadlock Shield diff --git a/init/android-service/vendor/bin/droidspaces_autoboot.sh b/init/android-service/vendor/bin/droidspaces_autoboot.sh index db5f8b36..16eabd86 100755 --- a/init/android-service/vendor/bin/droidspaces_autoboot.sh +++ b/init/android-service/vendor/bin/droidspaces_autoboot.sh @@ -63,34 +63,113 @@ fi # Breaking container's networking :) wait_for_network -# Scan and boot containers +# Scan and boot containers (priority-ordered, gateway-aware) +# +# Containers boot from the smallest run_at_boot_priority to the largest; missing +# or invalid priorities sort last (best-effort), then alphabetically. A +# net_mode=gateway client is skipped if its gateway_container did not come up, +# so we never boot a router-less client. Independent containers always boot, and +# a single failure never blocks the rest. log "Scanning for containers with run_at_boot=1..." success=0 failed=0 +skipped=0 + +TAB=$(printf '\t') +BOOT_TABLE="${DROIDSPACE_DIR}/.autoboot_table" +SORTED_FILE="${DROIDSPACE_DIR}/.autoboot_sorted" +STARTED_OK="${DROIDSPACE_DIR}/.autoboot_started" +: > "${BOOT_TABLE}" +: > "${STARTED_OK}" + +# Read a single key=value from a config file +get_cfg_val() { + grep "^$1=" "$2" 2>/dev/null | head -1 | sed 's/^[^=]*=//' | tr -d '\r\n' +} + +# Find the container.config whose name= matches $1 (echoes path, empty if none) +cfg_for_name() { + for c in $(find "${CONTAINERS_DIR}" -name "container.config" 2>/dev/null); do + n=$(get_cfg_val name "$c") + [ -n "$n" ] || n=$(basename "$(dirname "$c")") + if [ "$n" = "$1" ]; then + echo "$c" + return 0 + fi + done + return 1 +} + +# A gateway is available if it started during this run, or is already running +gateway_available() { + if grep -qxF "$1" "${STARTED_OK}" 2>/dev/null; then + return 0 + fi + gcfg=$(cfg_for_name "$1") + [ -n "${gcfg}" ] || return 1 + gpid=$("${DROIDSPACE_BINARY}" --config "${gcfg}" pid 2>/dev/null) + [ "${gpid}" != "NONE" ] && [ -n "${gpid}" ] +} + +# Start one container; returns 0 if it ends up with a live PID +boot_container() { + "${DROIDSPACE_BINARY}" --config "$1" start 2>&1 | strip_colors + bpid=$("${DROIDSPACE_BINARY}" --config "$1" pid 2>/dev/null) + [ "${bpid}" != "NONE" ] && [ -n "${bpid}" ] +} +# Build the boot table: PRIORITY \t NAME \t NET_MODE \t GATEWAY \t CFG for cfg in $(find "${CONTAINERS_DIR}" -name "container.config" 2>/dev/null); do [ -f "${cfg}" ] || continue + [ "$(get_cfg_val run_at_boot "${cfg}")" = "1" ] || continue - run_at_boot=$(grep "^run_at_boot=" "${cfg}" 2>/dev/null | \ - head -1 | sed 's/^[^=]*=//' | tr -d '\r\n') + name=$(get_cfg_val name "${cfg}") + [ -n "${name}" ] || name=$(basename "$(dirname "${cfg}")") - [ "${run_at_boot}" = "1" ] || continue + prio=$(get_cfg_val run_at_boot_priority "${cfg}") + case "${prio}" in + ''|*[!0-9]*) prio=99999999 ;; + esac + + # '-' placeholder for empty fields: IFS= collapses adjacent tabs + # (tab is IFS whitespace), so empty middle fields would shift the columns. + netmode=$(get_cfg_val net_mode "${cfg}") + [ -n "${netmode}" ] || netmode="-" + gw=$(get_cfg_val gateway_container "${cfg}") + [ -n "${gw}" ] || gw="-" + + printf '%s\t%s\t%s\t%s\t%s\n' "${prio}" "${name}" "${netmode}" "${gw}" "${cfg}" >> "${BOOT_TABLE}" +done - name=$(grep "^name=" "${cfg}" 2>/dev/null | \ - head -1 | sed 's/^[^=]*=//' | tr -d '\r\n') - display="${name:-$(basename "$(dirname "${cfg}")")}" +# Smallest priority first, then name. Gaps/holes and large sentinels sort cleanly. +sort -t"${TAB}" -k1,1n -k2,2 "${BOOT_TABLE}" > "${SORTED_FILE}" - log "Starting container: ${display}" - "${DROIDSPACE_BINARY}" --config "${cfg}" start 2>&1 | strip_colors +# Reading from a file (not a pipe) keeps the loop in this shell so the counters +# survive each iteration. +while IFS="${TAB}" read -r prio name netmode gw cfg; do + [ -n "${cfg}" ] || continue + [ "${gw}" = "-" ] && gw="" - PID=$("${DROIDSPACE_BINARY}" --config "${cfg}" pid 2>/dev/null) - if [ "${PID}" != "NONE" ] && [ -n "${PID}" ]; then - log "SUCCESS: ${display} (PID: ${PID})" + if [ "${netmode}" = "gateway" ] && [ -n "${gw}" ]; then + if ! gateway_available "${gw}"; then + log "SKIP: ${name} (gateway '${gw}' not running)" + skipped=$((skipped + 1)) + continue + fi + fi + + log "Starting container: ${name}" + if boot_container "${cfg}"; then + PID=$("${DROIDSPACE_BINARY}" --config "${cfg}" pid 2>/dev/null) + log "SUCCESS: ${name} (PID: ${PID})" + printf '%s\n' "${name}" >> "${STARTED_OK}" success=$((success + 1)) else - log "FAILED: ${display}" + log "FAILED: ${name}" failed=$((failed + 1)) fi -done +done < "${SORTED_FILE}" + +rm -f "${BOOT_TABLE}" "${SORTED_FILE}" "${STARTED_OK}" 2>/dev/null -log "Autoboot complete: ${success} started | ${failed} failed" +log "Autoboot complete: ${success} started | ${failed} failed | ${skipped} skipped" From ef6bcb51af78c6d8635c093495f5d1d3cec49836 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 13 Jun 2026 18:50:31 +0530 Subject: [PATCH 047/211] feat: add gateway networking mode to installer and edit screens Expose the C runtime's net_mode=gateway from the app UI in both the container installer and the edit-container screen. Selecting "Gateway (Virtual Router)" reveals a gateway-container dropdown (lists installed containers except self) plus a "Configure Gateway" row card that opens a dialog for the optional interface / LAN-name / host-bridge overrides, each with an inline explanation. Fields show hints (placeholders), required/ invalid fields red out, and Next/Save are blocked while invalid. Config keys are written exactly as the runtime parses them, notably gateway_lan_ifname (the CLI flag is --gateway-iface), gateway-gated like the existing nat keys so switching modes never leaks stale keys. Collision prevention (Networking-From-Zero.md, Part 10): validation runs against every other gateway container so two segments never share a host bridge and one gateway never reuses an interface name across segments; self-reference and uninstalled-gateway cases are caught too. Also: DsDropdown gains optional isError/supportingText/enabled; SettingsRowCard skips a blank subtitle; ContainerCard labels gateway containers; dividers tidy the NAT and gateway sections above the DNS field. Signed-off-by: ravindu644 --- .../app/ui/component/ContainerCard.kt | 2 +- .../app/ui/component/DsDropdown.kt | 13 +- .../ui/component/GatewaySettingsSection.kt | 300 ++++++++++++++++++ .../app/ui/component/SettingsRowCard.kt | 14 +- .../ui/navigation/DroidspacesNavigation.kt | 10 +- .../app/ui/screen/ContainerConfigScreen.kt | 64 +++- .../app/ui/screen/EditContainerScreen.kt | 60 +++- .../ContainerInstallationViewModel.kt | 30 +- .../droidspaces/app/util/ContainerManager.kt | 16 + .../droidspaces/app/util/ValidationUtils.kt | 110 +++++++ Android/app/src/main/res/values/strings.xml | 27 ++ 11 files changed, 623 insertions(+), 23 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt index bec8a867..bc3f6006 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt @@ -158,7 +158,7 @@ fun ContainerCard( // Info Rows val displayHostname = container.hostname.takeIf { it.isNotEmpty() } ?: container.name val hasSparseImage = container.useSparseImage && container.sparseImageSizeGB != null - val netModeLabel = when (container.netMode) { "nat" -> context.getString(R.string.network_mode_nat_short); "none" -> context.getString(R.string.network_mode_none_short); else -> context.getString(R.string.network_mode_host_short) } + val netModeLabel = when (container.netMode) { "nat" -> context.getString(R.string.network_mode_nat_short); "none" -> context.getString(R.string.network_mode_none_short); "gateway" -> context.getString(R.string.network_mode_gateway_short); else -> context.getString(R.string.network_mode_host_short) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { Icon(Icons.Default.Computer, null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) Text(context.getString(R.string.hostname_label, displayHostname), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt index ba850e43..74adf9b6 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt @@ -22,7 +22,10 @@ fun DsDropdown( displayName: (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier, - leadingIcon: ImageVector? = null + leadingIcon: ImageVector? = null, + isError: Boolean = false, + supportingText: String? = null, + enabled: Boolean = true ) { var expanded by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current @@ -36,8 +39,9 @@ fun DsDropdown( ) ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { + expanded = expanded && enabled, + onExpandedChange = { + if (!enabled) return@ExposedDropdownMenuBox expanded = it if (!it) focusManager.clearFocus() }, @@ -47,7 +51,10 @@ fun DsDropdown( value = displayName(selected), onValueChange = {}, readOnly = true, + enabled = enabled, + isError = isError, label = { Text(label) }, + supportingText = supportingText?.let { { Text(it) } }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, leadingIcon = leadingIcon?.let { icon -> { Icon(icon, contentDescription = null) } }, shape = fieldShape, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt new file mode 100644 index 00000000..f9948314 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt @@ -0,0 +1,300 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +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.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.Router +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droidspaces.app.R +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.GatewayErrors +import com.droidspaces.app.util.ValidationUtils + +/** + * Gateway-mode settings block, shared by the installer and edit-container screens. + * Shows the required gateway-container dropdown plus a "Configure Gateway" button that + * opens a dialog for the optional interface / LAN-name / bridge overrides. All + * validation errors come from [errors]; the caller blocks Save/Next on `errors.isValid`. + */ +@Composable +fun GatewaySettingsSection( + visible: Boolean, + gatewayContainer: String, + onGatewayContainerChange: (String) -> Unit, + gatewayNet: String, + onGatewayNetChange: (String) -> Unit, + gatewayIface: String, + onGatewayIfaceChange: (String) -> Unit, + gatewayBridge: String, + onGatewayBridgeChange: (String) -> Unit, + selfName: String, + installedContainers: List, + errors: GatewayErrors +) { + val context = LocalContext.current + var showDialog by remember { mutableStateOf(false) } + val candidates = remember(installedContainers, selfName) { + installedContainers.map { it.name }.filter { it != selfName } + } + // Error from any of the three advanced (dialog) fields, surfaced under the button. + val advancedError = errors.iface ?: errors.bridge ?: errors.net + + AnimatedVisibility( + visible = visible, + enter = expandVertically( + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top + ) + fadeIn(animationSpec = tween(durationMillis = 300)), + exit = shrinkVertically( + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top + ) + fadeOut(animationSpec = tween(durationMillis = 300)) + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = context.getString(R.string.gateway_settings), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = context.getString(R.string.gateway_settings_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + + // Required: which running container is the router. + val noCandidates = candidates.isEmpty() + DsDropdown( + label = context.getString(R.string.gateway_container), + selected = gatewayContainer, + options = candidates, + displayName = { it }, + onSelect = onGatewayContainerChange, + leadingIcon = Icons.Default.Router, + isError = errors.container != null, + supportingText = if (noCandidates) + context.getString(R.string.error_no_gateway_candidates) + else + errors.container, + enabled = !noCandidates + ) + + // "Configure Gateway" entry — same row-card aesthetics as Privileged Mode, + // showing only title + description. + SettingsRowCard( + title = context.getString(R.string.gateway_configure), + subtitle = "", + description = context.getString(R.string.gateway_configure_intro), + icon = Icons.Default.Tune, + onClick = { showDialog = true } + ) + if (advancedError != null) { + Text( + text = advancedError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 4.dp) + ) + } + + // Divider so the following DNS field doesn't feel disconnected. + HorizontalDivider( + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) + } + } + + if (showDialog) { + GatewayConfigureDialog( + selfName = selfName, + gatewayContainer = gatewayContainer, + installed = installedContainers, + initialNet = gatewayNet, + initialIface = gatewayIface, + initialBridge = gatewayBridge, + onConfirm = { net, iface, bridge -> + onGatewayNetChange(net) + onGatewayIfaceChange(iface) + onGatewayBridgeChange(bridge) + showDialog = false + }, + onDismiss = { showDialog = false } + ) + } +} + +@Composable +private fun GatewayConfigureDialog( + selfName: String, + gatewayContainer: String, + installed: List, + initialNet: String, + initialIface: String, + initialBridge: String, + onConfirm: (net: String, iface: String, bridge: String) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var net by remember { mutableStateOf(initialNet) } + var iface by remember { mutableStateOf(initialIface) } + var bridge by remember { mutableStateOf(initialBridge) } + + // Live-validate the three fields against every other container so collisions show + // before the user closes the dialog. + val errs = ValidationUtils.validateGatewayConfig( + selfName, gatewayContainer, net, iface, bridge, installed, context + ) + val advancedValid = errs.iface == null && errs.net == null && errs.bridge == null + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .imePadding(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + context.getString(R.string.gateway_configure_title), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge + ) + + // 1 — Interface in Gateway (most important). + ExplainedField( + title = context.getString(R.string.gateway_iface), + explanation = context.getString(R.string.gateway_iface_explain), + value = iface, + onChange = { iface = it }, + hint = context.getString(R.string.gateway_iface_hint), + error = errs.iface + ) + // 2 — LAN Name. + ExplainedField( + title = context.getString(R.string.gateway_net), + explanation = context.getString(R.string.gateway_net_explain), + value = net, + onChange = { net = it }, + hint = context.getString(R.string.gateway_net_hint), + error = errs.net + ) + // 3 — Host Bridge. + ExplainedField( + title = context.getString(R.string.gateway_bridge), + explanation = context.getString(R.string.gateway_bridge_explain), + value = bridge, + onChange = { bridge = it }, + hint = context.getString(R.string.gateway_bridge_hint), + error = errs.bridge + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + } + } + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( + enabled = advancedValid, + onClick = { onConfirm(net, iface, bridge) } + ), + shape = RoundedCornerShape(14.dp), + color = if (advancedValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text( + context.getString(R.string.ok), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (advancedValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } + } + } + } +} + +@Composable +private fun ExplainedField( + title: String, + explanation: String, + value: String, + onChange: (String) -> Unit, + hint: String, + error: String? +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Text(explanation, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField( + value = value, + // Only interface-name characters are valid; filter the rest as the user types. + onValueChange = { input -> + onChange(input.filter { it.isLetterOrDigit() || it == '_' || it == '-' }) + }, + placeholder = { Text(hint) }, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt index 42bc476d..2eaaeb65 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt @@ -78,12 +78,14 @@ fun SettingsRowCard( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f * alpha) ) } - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f * alpha), - fontWeight = FontWeight.Bold - ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f * alpha), + fontWeight = FontWeight.Bold + ) + } } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index 0b4c211c..d101b491 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -324,8 +324,14 @@ fun DroidspacesNavigation( initialPrivileged = viewModel.privileged, initialEnvFileContent = viewModel.envFileContent ?: "", initialPortForwards = viewModel.portForwards, - onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, portForwards -> - viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, portForwards, forceCgroupv1, blockNestedNs, privileged) + initialGatewayContainer = viewModel.gatewayContainer, + initialGatewayNet = viewModel.gatewayNet, + initialGatewayIface = viewModel.gatewayIface, + initialGatewayBridge = viewModel.gatewayBridge, + containerName = viewModel.containerName, + installedContainers = sharedContainerViewModel.containerList, + onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge -> + viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, portForwards, forceCgroupv1, blockNestedNs, privileged, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) navController.navigate(Screen.SparseImageConfig.route) }, onBack = { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index 0ef41bc0..76521fe4 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -28,7 +28,9 @@ import com.droidspaces.app.ui.util.ClearFocusOnClickOutside import androidx.compose.ui.text.style.TextOverflow import com.droidspaces.app.util.BindMount import com.droidspaces.app.util.PortForward +import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerManager +import com.droidspaces.app.ui.component.GatewaySettingsSection import kotlinx.coroutines.launch import com.droidspaces.app.ui.component.FilePickerDialog import com.droidspaces.app.ui.component.SettingsRowCard @@ -74,6 +76,12 @@ fun ContainerConfigScreen( initialPrivileged: String = "", initialEnvFileContent: String = "", initialPortForwards: List = emptyList(), + initialGatewayContainer: String = "", + initialGatewayNet: String = "", + initialGatewayIface: String = "", + initialGatewayBridge: String = "", + containerName: String = "", + installedContainers: List = emptyList(), onNext: ( netMode: String, disableIPv6: Boolean, @@ -96,7 +104,11 @@ fun ContainerConfigScreen( blockNestedNs: Boolean, privileged: String, envFileContent: String?, - portForwards: List + portForwards: List, + gatewayContainer: String, + gatewayNet: String, + gatewayIface: String, + gatewayBridge: String ) -> Unit, onBack: () -> Unit ) { @@ -122,8 +134,23 @@ fun ContainerConfigScreen( var envFileContent by remember { mutableStateOf(initialEnvFileContent) } var portForwards by remember { mutableStateOf(initialPortForwards) } var privileged by remember { mutableStateOf(initialPrivileged) } + var gatewayContainer by remember { mutableStateOf(initialGatewayContainer) } + var gatewayNet by remember { mutableStateOf(initialGatewayNet) } + var gatewayIface by remember { mutableStateOf(initialGatewayIface) } + var gatewayBridge by remember { mutableStateOf(initialGatewayBridge) } val context = LocalContext.current + val gatewayErrors = com.droidspaces.app.util.ValidationUtils.validateGatewayConfig( + selfName = containerName, + gatewayContainer = gatewayContainer, + net = gatewayNet, + iface = gatewayIface, + bridge = gatewayBridge, + installed = installedContainers, + context = context + ) + val canProceed = netMode != "gateway" || gatewayErrors.isValid + // Internal UI States var showFilePicker by remember { mutableStateOf(false) } var showDestDialog by remember { mutableStateOf(false) } @@ -290,14 +317,15 @@ fun ContainerConfigScreen( .navigationBarsPadding() .clip(btnShape) .clickable( + enabled = canProceed, onClick = { - onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, portForwards) + onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) }, indication = androidx.compose.material.ripple.rememberRipple(bounded = true), interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } ), shape = btnShape, - color = MaterialTheme.colorScheme.primary, + color = if (canProceed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { @@ -306,13 +334,13 @@ fun ContainerConfigScreen( Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary + tint = if (canProceed) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) Text( context.getString(R.string.next_storage), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary + color = if (canProceed) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } } @@ -353,12 +381,27 @@ fun ContainerConfigScreen( DsDropdown( label = context.getString(R.string.network_mode), selected = netMode, - options = listOf("nat", "host", "none"), - displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; else -> R.string.network_mode_host }) }, + options = listOf("nat", "host", "none", "gateway"), + displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; else -> R.string.network_mode_host }) }, onSelect = { mode -> netMode = mode; if (mode != "host") disableIPv6 = false }, leadingIcon = Icons.Default.Public ) + GatewaySettingsSection( + visible = netMode == "gateway", + gatewayContainer = gatewayContainer, + onGatewayContainerChange = { gatewayContainer = it }, + gatewayNet = gatewayNet, + onGatewayNetChange = { gatewayNet = it }, + gatewayIface = gatewayIface, + onGatewayIfaceChange = { gatewayIface = it }, + gatewayBridge = gatewayBridge, + onGatewayBridgeChange = { gatewayBridge = it }, + selfName = containerName, + installedContainers = installedContainers, + errors = gatewayErrors + ) + androidx.compose.animation.AnimatedVisibility( visible = netMode == "nat", enter = androidx.compose.animation.expandVertically( @@ -497,6 +540,13 @@ fun ContainerConfigScreen( portForwards = portForwards, onPortForwardsChange = { portForwards = it } ) + + // Divider so the following DNS field doesn't feel disconnected. + HorizontalDivider( + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index ea4ceefd..a20b81b3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -51,6 +51,7 @@ import com.droidspaces.app.util.PortForward import com.droidspaces.app.ui.component.PrivilegedModeDialog import com.droidspaces.app.ui.component.HardwareAccessDialog import com.droidspaces.app.ui.component.DsDropdown +import com.droidspaces.app.ui.component.GatewaySettingsSection import androidx.compose.material.icons.filled.Public import com.droidspaces.app.ui.component.PortForwardingList import androidx.compose.ui.window.Dialog @@ -109,9 +110,23 @@ fun EditContainerScreen( var forceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } var blockNestedNs by remember { mutableStateOf(container.blockNestedNs) } var staticNatIp by remember { mutableStateOf(container.staticNatIp) } + var gatewayContainer by remember { mutableStateOf(container.gatewayContainer) } + var gatewayNet by remember { mutableStateOf(container.gatewayNet) } + var gatewayIface by remember { mutableStateOf(container.gatewayIface) } + var gatewayBridge by remember { mutableStateOf(container.gatewayBridge) } var privileged by remember { mutableStateOf(container.privileged) } var customInit by remember { mutableStateOf(container.customInit) } + val gatewayErrors = ValidationUtils.validateGatewayConfig( + selfName = container.name, + gatewayContainer = gatewayContainer, + net = gatewayNet, + iface = gatewayIface, + bridge = gatewayBridge, + installed = containerViewModel.containerList, + context = context + ) + // Track the "saved" baseline values - updated after each successful save var savedHostname by remember { mutableStateOf(container.hostname) } var savedNetMode by remember { mutableStateOf(container.netMode) } @@ -134,6 +149,10 @@ fun EditContainerScreen( var savedForceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } var savedBlockNestedNs by remember { mutableStateOf(container.blockNestedNs) } var savedStaticNatIp by remember { mutableStateOf(container.staticNatIp) } + var savedGatewayContainer by remember { mutableStateOf(container.gatewayContainer) } + var savedGatewayNet by remember { mutableStateOf(container.gatewayNet) } + var savedGatewayIface by remember { mutableStateOf(container.gatewayIface) } + var savedGatewayBridge by remember { mutableStateOf(container.gatewayBridge) } var savedPrivileged by remember { mutableStateOf(container.privileged) } var savedCustomInit by remember { mutableStateOf(container.customInit) } @@ -171,6 +190,10 @@ fun EditContainerScreen( forceCgroupv1 != savedForceCgroupv1 || blockNestedNs != savedBlockNestedNs || staticNatIp != savedStaticNatIp || + gatewayContainer != savedGatewayContainer || + gatewayNet != savedGatewayNet || + gatewayIface != savedGatewayIface || + gatewayBridge != savedGatewayBridge || privileged != savedPrivileged || customInit != savedCustomInit } @@ -214,6 +237,10 @@ fun EditContainerScreen( forceCgroupv1 = forceCgroupv1, blockNestedNs = blockNestedNs, staticNatIp = staticNatIp, + gatewayContainer = gatewayContainer, + gatewayNet = gatewayNet, + gatewayIface = gatewayIface, + gatewayBridge = gatewayBridge, privileged = privileged, customInit = customInit ) @@ -248,6 +275,10 @@ fun EditContainerScreen( savedForceCgroupv1 = forceCgroupv1 savedBlockNestedNs = blockNestedNs savedStaticNatIp = staticNatIp + savedGatewayContainer = gatewayContainer + savedGatewayNet = gatewayNet + savedGatewayIface = gatewayIface + savedGatewayBridge = gatewayBridge savedPrivileged = privileged savedCustomInit = customInit @@ -422,7 +453,8 @@ fun EditContainerScreen( }, bottomBar = { val btnShape = RoundedCornerShape(20.dp) - val isReadyToSave = !isSaving && !isSaved && hasChanges && hostnameError == null + val isReadyToSave = !isSaving && !isSaved && hasChanges && hostnameError == null && + (netMode != "gateway" || gatewayErrors.isValid) val targetBtnColor = when { isSaved -> MaterialTheme.colorScheme.primaryContainer isSaving || isReadyToSave -> MaterialTheme.colorScheme.primary @@ -603,12 +635,27 @@ fun EditContainerScreen( DsDropdown( label = context.getString(R.string.network_mode), selected = netMode, - options = listOf("nat", "host", "none"), - displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; else -> R.string.network_mode_host }) }, + options = listOf("nat", "host", "none", "gateway"), + displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; else -> R.string.network_mode_host }) }, onSelect = { mode -> clearFocus(); netMode = mode; if (mode != "host") disableIPv6 = false }, leadingIcon = Icons.Default.Public ) + GatewaySettingsSection( + visible = netMode == "gateway", + gatewayContainer = gatewayContainer, + onGatewayContainerChange = { clearFocus(); gatewayContainer = it }, + gatewayNet = gatewayNet, + onGatewayNetChange = { gatewayNet = it }, + gatewayIface = gatewayIface, + onGatewayIfaceChange = { gatewayIface = it }, + gatewayBridge = gatewayBridge, + onGatewayBridgeChange = { gatewayBridge = it }, + selfName = container.name, + installedContainers = containerViewModel.containerList, + errors = gatewayErrors + ) + androidx.compose.animation.AnimatedVisibility( visible = netMode == "nat", enter = androidx.compose.animation.expandVertically( @@ -751,6 +798,13 @@ fun EditContainerScreen( portForwards = portForwards, onPortForwardsChange = { portForwards = it } ) + + // Divider so the following DNS field doesn't feel disconnected. + HorizontalDivider( + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt index c78e6560..06fbcdf2 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt @@ -75,6 +75,18 @@ class ContainerInstallationViewModel : ViewModel() { var staticNatIp: String by mutableStateOf("") private set + var gatewayContainer: String by mutableStateOf("") + private set + + var gatewayNet: String by mutableStateOf("") + private set + + var gatewayIface: String by mutableStateOf("") + private set + + var gatewayBridge: String by mutableStateOf("") + private set + var envFileContent: String? by mutableStateOf(null) private set @@ -132,7 +144,11 @@ class ContainerInstallationViewModel : ViewModel() { portForwards: List, forceCgroupv1: Boolean, blockNestedNs: Boolean, - privileged: String + privileged: String, + gatewayContainer: String, + gatewayNet: String, + gatewayIface: String, + gatewayBridge: String ) { this.netMode = netMode this.disableIPv6 = disableIPv6 @@ -156,6 +172,10 @@ class ContainerInstallationViewModel : ViewModel() { this.forceCgroupv1 = forceCgroupv1 this.blockNestedNs = blockNestedNs this.privileged = privileged + this.gatewayContainer = gatewayContainer + this.gatewayNet = gatewayNet + this.gatewayIface = gatewayIface + this.gatewayBridge = gatewayBridge } fun buildConfig(): ContainerInfo? { @@ -187,6 +207,10 @@ class ContainerInstallationViewModel : ViewModel() { runAtBoot = runAtBoot, customInit = customInit, staticNatIp = staticNatIp, + gatewayContainer = gatewayContainer, + gatewayNet = gatewayNet, + gatewayIface = gatewayIface, + gatewayBridge = gatewayBridge, envFileContent = envFileContent, status = ContainerStatus.STOPPED, // Default status for new container useSparseImage = useSparseImage, @@ -219,6 +243,10 @@ class ContainerInstallationViewModel : ViewModel() { runAtBoot = false customInit = "" staticNatIp = "" + gatewayContainer = "" + gatewayNet = "" + gatewayIface = "" + gatewayBridge = "" envFileContent = null useSparseImage = true sparseImageSizeGB = 8 diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index 1e54e1fc..31b89878 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -54,6 +54,10 @@ data class ContainerInfo( val forceCgroupv1: Boolean = false, val blockNestedNs: Boolean = false, val staticNatIp: String = "", + val gatewayContainer: String = "", + val gatewayNet: String = "", + val gatewayIface: String = "", + val gatewayBridge: String = "", val privileged: String = "", val customInit: String = "", val uuid: String = "" @@ -101,6 +105,14 @@ data class ContainerInfo( if (netMode == "nat" && staticNatIp.isNotEmpty()) { appendLine("static_nat_ip=$staticNatIp") } + // Gateway-mode keys (the C runtime parses gateway_lan_ifname, not gateway_iface). + // gateway_container is required in gateway mode; the rest are optional overrides. + if (netMode == "gateway") { + if (gatewayContainer.isNotBlank()) appendLine("gateway_container=$gatewayContainer") + if (gatewayNet.isNotBlank()) appendLine("gateway_net=$gatewayNet") + if (gatewayIface.isNotBlank()) appendLine("gateway_lan_ifname=$gatewayIface") + if (gatewayBridge.isNotBlank()) appendLine("gateway_bridge=$gatewayBridge") + } appendLine("use_sparse_image=${if (useSparseImage) "1" else "0"}") if (sparseImageSizeGB != null) { appendLine("sparse_image_size_gb=$sparseImageSizeGB") @@ -315,6 +327,10 @@ object ContainerManager { forceCgroupv1 = configMap["force_cgroupv1"] == "1", blockNestedNs = configMap["block_nested_ns"] == "1", staticNatIp = configMap["static_nat_ip"] ?: "", + gatewayContainer = configMap["gateway_container"] ?: "", + gatewayNet = configMap["gateway_net"] ?: "", + gatewayIface = configMap["gateway_lan_ifname"] ?: "", + gatewayBridge = configMap["gateway_bridge"] ?: "", privileged = configMap["privileged"] ?: "", customInit = configMap["custom_init"] ?: "", uuid = configMap["uuid"] ?: "" diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt index 403773da..2540bda2 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt @@ -59,6 +59,116 @@ object ValidationUtils { .replace(Regex("[^a-zA-Z0-9-]"), "") .trim('-') } + + // ---- Gateway networking mode -------------------------------------------- + + // Linux IFNAMSIZ is 16 incl. NUL, so interface/bridge names get 15 usable chars. + const val IFNAME_MAX = 15 + private val IFNAME_REGEX = Regex("^[a-zA-Z0-9_-]+$") + + /** Effective LAN segment name (empty -> "lan"), mirrors the C runtime default. */ + fun effGatewayNet(net: String): String = net.ifBlank { "lan" } + + /** Effective interface name inside the gateway (empty -> "eth1"). */ + fun effGatewayIface(iface: String): String = iface.ifBlank { "eth1" } + + /** + * Effective host bridge name. Mirrors gateway_bridge_name() in src/net/network.c: + * explicit bridge wins, else "ds-" + (net filtered to [A-Za-z0-9_-], first 9 chars, + * or "lan" if empty). + */ + fun effGatewayBridge(net: String, bridge: String): String { + if (bridge.isNotBlank()) return bridge + val clean = effGatewayNet(net) + .filter { it.isLetterOrDigit() || it == '_' || it == '-' } + .take(9) + return "ds-" + clean.ifEmpty { "lan" } + } + + private fun ifaceLikeError(value: String, context: Context?): String? = when { + value.isBlank() -> null + value.length > IFNAME_MAX -> + context?.getString(R.string.error_iface_too_long) ?: "Too long — max $IFNAME_MAX characters" + !value.matches(IFNAME_REGEX) -> + context?.getString(R.string.error_gateway_name_chars) ?: "Use only letters, digits, _ or -" + else -> null + } + + /** + * Validates a gateway-mode configuration against every OTHER installed container, + * returning a per-field error set. Enforces the collision rules from + * Documentation/Networking-From-Zero.md (Part 10) so two segments never share a + * host bridge and one gateway never reuses an interface name across segments. + */ + fun validateGatewayConfig( + selfName: String, + gatewayContainer: String, + net: String, + iface: String, + bridge: String, + installed: List, + context: Context? = null + ): GatewayErrors { + // Field-format checks (independent of other containers). + val netErr = if (net.isNotBlank() && !net.matches(IFNAME_REGEX)) + (context?.getString(R.string.error_gateway_name_chars) ?: "Use only letters, digits, _ or -") + else null + var ifaceErr = ifaceLikeError(iface, context) + var bridgeErr = ifaceLikeError(bridge, context) + + // Gateway container must exist, differ from self, and be installed. + val installedNames = installed.map { it.name } + val containerErr = when { + gatewayContainer.isBlank() -> + context?.getString(R.string.error_gateway_required) ?: "Select a gateway container" + gatewayContainer == selfName -> + context?.getString(R.string.error_gateway_self) ?: "A container cannot be its own gateway" + gatewayContainer !in installedNames -> + context?.getString(R.string.error_gateway_not_installed) ?: "Gateway container is not installed" + else -> null + } + + // Cross-container collisions (only when the basics are already sound). + if (containerErr == null && netErr == null && ifaceErr == null && bridgeErr == null) { + val myBridge = effGatewayBridge(net, bridge) + val myNet = effGatewayNet(net) + val myIface = effGatewayIface(iface) + for (o in installed) { + if (o.name == selfName || o.netMode != "gateway" || o.gatewayContainer.isBlank()) continue + val oBridge = effGatewayBridge(o.gatewayNet, o.gatewayBridge) + val oNet = effGatewayNet(o.gatewayNet) + val oIface = effGatewayIface(o.gatewayIface) + // Same host bridge but a different gateway, or a different segment of the + // same gateway -> two routers / two segments on one switch. + if (oBridge == myBridge && (o.gatewayContainer != gatewayContainer || oNet != myNet)) { + bridgeErr = context?.getString(R.string.error_gateway_bridge_taken, o.name) + ?: "Bridge $myBridge is already used by '${o.name}'. Use a different LAN name." + break + } + // Same gateway, different segment, but the same interface name reused. + if (o.gatewayContainer == gatewayContainer && oBridge != myBridge && oIface == myIface) { + ifaceErr = context?.getString(R.string.error_gateway_iface_taken, o.name) + ?: "Interface $myIface is already used by another segment ('${o.name}'). Use a different one." + break + } + } + } + + return GatewayErrors(containerErr, netErr, ifaceErr, bridgeErr) + } +} + +/** + * Per-field validation result for gateway mode. A null field means "no error". + */ +data class GatewayErrors( + val container: String? = null, + val net: String? = null, + val iface: String? = null, + val bridge: String? = null +) { + val isValid: Boolean + get() = container == null && net == null && iface == null && bridge == null } /** diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index f8841d8e..49e4c261 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -225,6 +225,33 @@ Host NAT None + Gateway (Virtual Router) + Gateway + + + Gateway Settings + Route this container through another container acting as a router (e.g. OpenWRT). That gateway must be running first. + Gateway Container + LAN Name + lan + Interface in Gateway + eth1 + Host Bridge + ds-lan + Configure Gateway + Configure Gateway + These control how this container plugs into the gateway\'s virtual LAN. The defaults work for a single LAN behind one gateway - change them only when running multiple LAN segments or more than one gateway. + The interface name this connection shows up as INSIDE the gateway container (e.g. eth1 in OpenWRT). It must match the interface your gateway is set to serve DHCP on. Each separate segment on the same gateway needs its own unique name. Default: eth1. + A label for this LAN segment. Containers that share the same gateway and LAN name join the same virtual switch and can see each other. Use a different name to spin up an isolated second segment. Default: lan. + Name of the virtual switch created on the Android host. Leave empty to auto-name it from the LAN name (ds-<lan>). Only set this if you need an exact bridge name. Max 15 characters. + Select a gateway container + A container cannot be its own gateway + This gateway container is not installed + Install another container to act as the gateway + Use only letters, digits, _ or - + Too long - max 15 characters + Bridge already used by \'%1$s\'. Use a different LAN name. + Interface already used by another segment (\'%1$s\'). Use a different one. NAT Settings Port Forwarding Add Port Forward From f0c2bf5d3d61ddb20ea386955c8c0a7045868018 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 13 Jun 2026 20:02:51 +0530 Subject: [PATCH 048/211] fix: smooth network-mode switching, single static divider above DNS Switching NAT <-> gateway animated two stacked sections in opposite size directions at once (gateway expanding while NAT collapsed), tugging the divider and DNS field both ways and looking glitchy. Render the NAT and gateway sub-sections instantly with a plain if() instead of expand/shrink AnimatedVisibility, and move the section divider out of the animated blocks into one static HorizontalDivider just above the DNS field. Signed-off-by: ravindu644 --- .../ui/component/GatewaySettingsSection.kt | 28 ++----------------- .../app/ui/screen/ContainerConfigScreen.kt | 28 +++++++------------ .../app/ui/screen/EditContainerScreen.kt | 28 +++++++------------ 3 files changed, 23 insertions(+), 61 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt index f9948314..0b42eeac 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt @@ -1,12 +1,5 @@ package com.droidspaces.app.ui.component -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -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.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -60,17 +53,9 @@ fun GatewaySettingsSection( // Error from any of the three advanced (dialog) fields, surfaced under the button. val advancedError = errors.iface ?: errors.bridge ?: errors.net - AnimatedVisibility( - visible = visible, - enter = expandVertically( - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), - expandFrom = Alignment.Top - ) + fadeIn(animationSpec = tween(durationMillis = 300)), - exit = shrinkVertically( - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), - shrinkTowards = Alignment.Top - ) + fadeOut(animationSpec = tween(durationMillis = 300)) - ) { + // Instant show/hide (no expand/shrink) so switching network modes stays smooth + // instead of fighting the NAT section's animation in the opposite direction. + if (visible) { Column( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp) @@ -120,13 +105,6 @@ fun GatewaySettingsSection( modifier = Modifier.padding(start = 4.dp) ) } - - // Divider so the following DNS field doesn't feel disconnected. - HorizontalDivider( - modifier = Modifier.padding(top = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index 76521fe4..fd97deb9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -402,17 +402,9 @@ fun ContainerConfigScreen( errors = gatewayErrors ) - androidx.compose.animation.AnimatedVisibility( - visible = netMode == "nat", - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300, easing = androidx.compose.animation.core.FastOutSlowInEasing), - expandFrom = Alignment.Top - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300, easing = androidx.compose.animation.core.FastOutSlowInEasing), - shrinkTowards = Alignment.Top - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { + // Instant show/hide (no expand/shrink) so switching modes doesn't fight + // the gateway section's animation — see the gateway/NAT divider note. + if (netMode == "nat") { Column( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -540,16 +532,16 @@ fun ContainerConfigScreen( portForwards = portForwards, onPortForwardsChange = { portForwards = it } ) - - // Divider so the following DNS field doesn't feel disconnected. - HorizontalDivider( - modifier = Modifier.padding(top = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) } } + // Static divider (outside the animated blocks so mode switches stay + // smooth) separating the networking sub-settings from the DNS field. + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) + // DNS Servers input val isDnsError = remember(dnsServers) { dnsServers.isNotEmpty() && !dnsServers.all { it.isDigit() || it == '.' || it == ':' || it == ',' } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index a20b81b3..5380574b 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -656,17 +656,9 @@ fun EditContainerScreen( errors = gatewayErrors ) - androidx.compose.animation.AnimatedVisibility( - visible = netMode == "nat", - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300, easing = androidx.compose.animation.core.FastOutSlowInEasing), - expandFrom = Alignment.Top - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300, easing = androidx.compose.animation.core.FastOutSlowInEasing), - shrinkTowards = Alignment.Top - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { + // Instant show/hide (no expand/shrink) so switching modes doesn't fight + // the gateway section's animation in the opposite direction. + if (netMode == "nat") { Column( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -798,16 +790,16 @@ fun EditContainerScreen( portForwards = portForwards, onPortForwardsChange = { portForwards = it } ) - - // Divider so the following DNS field doesn't feel disconnected. - HorizontalDivider( - modifier = Modifier.padding(top = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) } } + // Static divider (outside the animated blocks so mode switches stay + // smooth) separating the networking sub-settings from the DNS field. + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) + // DNS Servers input val isDnsError = remember(dnsServers) { dnsServers.isNotEmpty() && !dnsServers.all { it.isDigit() || it == '.' || it == ':' || it == ',' } From bd6579952e8fd842a3fc6436d0eff328f84392ba Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 13 Jun 2026 20:57:28 +0530 Subject: [PATCH 049/211] net: tear down idle gateway bridge and veth on last client exit Gateway-mode cleanup only removed the client's own ds-v veth, leaving the delegated bridge (ds-lan) and the gateway veth (ds-g) behind forever. Since a NAT-mode router (OpenWRT) usually runs persistently, the segment lingered idle after every client exited, and a stale ds-g on a stop->start cycle triggered the non-fatal "failed to reattach" warning. Add ds_nl_count_bridge_members_with_prefix() (counts links enslaved to a specific bridge by name prefix, via IFLA_MASTER) and use it in the gateway branch of ds_net_cleanup(): after deleting our app veth, if no ds-v* client remains on that bridge, delete the gateway veth and the bridge too. The gateway's LAN interface is re-plugged lazily on the next client attach. Lives in ds_net_cleanup so it fires on every path - manual stop, ds-monitor internal-poweroff cleanup, and force/failed-boot. Per-bridge scoping keeps multiple segments (ds-lan, ds-vpn) independent. Signed-off-by: ravindu644 --- src/include/droidspace.h | 4 +++ src/net/netlink.c | 63 ++++++++++++++++++++++++++++++++++++++++ src/net/network.c | 21 ++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/src/include/droidspace.h b/src/include/droidspace.h index ef828701..83f98fdb 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -717,6 +717,10 @@ int ds_nl_del_rule4(ds_nl_ctx_t *ctx, uint32_t src_be, uint8_t src_len, uint32_t dst_be, uint8_t dst_len, int table, int priority); void ds_nl_flush_stale_veths(ds_nl_ctx_t *ctx, const char *prefix); int ds_nl_count_ifaces_with_prefix(ds_nl_ctx_t *ctx, const char *prefix); +/* Count links enslaved to `bridge` whose name starts with `prefix`. + * Returns 0 if the bridge does not exist. */ +int ds_nl_count_bridge_members_with_prefix(ds_nl_ctx_t *ctx, const char *bridge, + const char *prefix); int ds_nl_list_ifaces(ds_nl_ctx_t *ctx, char names[][IFNAMSIZ], int max); /* Kernel capability probe - call before any NAT setup */ int ds_nl_probe_nat_capability(char *reason, size_t rsz); diff --git a/src/net/netlink.c b/src/net/netlink.c index 3c02c6af..d29da508 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -782,6 +782,69 @@ int ds_nl_count_ifaces_with_prefix(ds_nl_ctx_t *ctx, const char *prefix) { return count; } +/* --------------------------------------------------------------------------- + * Count links enslaved to `bridge` whose name starts with `prefix`. + * + * Used by gateway-mode cleanup to refcount clients on a specific delegated + * bridge (the global ds-v* count cannot tell ds-br0 from ds-lan clients). + * Returns 0 if the bridge does not exist. + * ---------------------------------------------------------------------------*/ +int ds_nl_count_bridge_members_with_prefix(ds_nl_ctx_t *ctx, const char *bridge, + const char *prefix) { + unsigned int br_idx = if_nametoindex(bridge); + if (br_idx == 0) + return 0; + + struct { + struct nlmsghdr n; + struct ifinfomsg i; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.n.nlmsg_type = RTM_GETLINK; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.i.ifi_family = AF_UNSPEC; + req.n.nlmsg_seq = ++ctx->seq; + req.n.nlmsg_pid = (uint32_t)ctx->pid; + + if (send(ctx->fd, &req, req.n.nlmsg_len, 0) < 0) + return 0; + + int count = 0; + size_t prefix_len = strlen(prefix); + uint8_t buf[NL_BUFSIZE]; + + for (;;) { + ssize_t n = recv(ctx->fd, buf, sizeof(buf), 0); + if (n <= 0) + break; + struct nlmsghdr *h = (struct nlmsghdr *)buf; + for (; NLMSG_OK(h, (uint32_t)n); h = NLMSG_NEXT(h, n)) { + if (h->nlmsg_type == NLMSG_DONE) + goto member_done; + if (h->nlmsg_type != RTM_NEWLINK) + continue; + struct ifinfomsg *ifi = NLMSG_DATA(h); + struct rtattr *rta = IFLA_RTA(ifi); + int rlen = (int)IFLA_PAYLOAD(h); + char ifname[IFNAMSIZ] = {0}; + int master = 0; + for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { + if (rta->rta_type == IFLA_IFNAME) + safe_strncpy(ifname, RTA_DATA(rta), IFNAMSIZ); + else if (rta->rta_type == IFLA_MASTER) + master = *(int *)RTA_DATA(rta); + } + if (master == (int)br_idx && ifname[0] && + strncmp(ifname, prefix, prefix_len) == 0) + count++; + } + } + +member_done: + return count; +} + /* --------------------------------------------------------------------------- * Find the default-route table used for internet connectivity * diff --git a/src/net/network.c b/src/net/network.c index d8e3b455..f316214b 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -1504,6 +1504,27 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { veth_host_name(effective_pid, veth_host, sizeof(veth_host)); ds_nl_del_link(ctx, veth_host); ds_log("[NET] Gateway cleanup: removed %s", veth_host); + + /* Refcount the delegated bridge: once the last client veth has left, + * tear down the gateway veth and the bridge so idle segments don't + * linger. The gateway's LAN interface is re-plugged lazily the next + * time any client attaches to this segment (setup_gateway_veth_side). */ + char bridge[IFNAMSIZ], gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; + gateway_bridge_name(cfg, bridge, sizeof(bridge)); + gateway_veth_names(cfg, gw_host, sizeof(gw_host), gw_peer, + sizeof(gw_peer)); + int clients = ds_nl_count_bridge_members_with_prefix(ctx, bridge, "ds-v"); + if (clients > 0) { + ds_log("[NET] Gateway cleanup: %d client(s) still on %s - keeping " + "delegated LAN", + clients, bridge); + } else { + ds_nl_del_link(ctx, gw_host); + ds_nl_del_link(ctx, bridge); + ds_log("[NET] Gateway cleanup: torn down idle delegated LAN %s " + "(removed %s)", + bridge, gw_host); + } } else { ds_warn("[NET] Gateway cleanup: cannot derive veth name - no valid PID"); } From fc2dbda80555bf2e679ee1c955457b458face35e Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 13 Jun 2026 21:21:14 +0530 Subject: [PATCH 050/211] config: load running container's booted snapshot, not the host copy ds_config_load_by_name now reads /proc//root/run/droidspaces/ container.config (the immutable copy the container booted with) when the container is running, falling back to /Containers// container.config otherwise. This keeps stop/restart/info/pid and the gateway bridge/veth teardown consistent even when the host-side container.config is edited mid-run. config_file stays pointed at the workspace copy so later saves still target the host file. The monitor's internal-poweroff cleanup is unaffected: it already runs off its in-memory booted cfg and never re-reads disk. Also, reload workspace config on restart so edits apply to new boot Signed-off-by: ravindu644 --- src/config.c | 17 +++++++++++++++++ src/container.c | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/src/config.c b/src/config.c index 49990963..f77785ad 100644 --- a/src/config.c +++ b/src/config.c @@ -911,6 +911,23 @@ int ds_config_load_by_name(const char *name, struct ds_config *cfg) { snprintf(config_path, sizeof(config_path), "%s/Containers/%s/container.config", get_workspace_dir(), safe_name); + /* Single source of truth: if the container is running, load the immutable + * snapshot it actually booted with (/run/droidspaces/container.config inside + * its mount ns) so host-side edits to container.config while it runs never + * desync stop/cleanup/info. Keep config_file pointing at the workspace copy + * so later saves still target the host file. Fall back to the workspace copy + * when not running or the snapshot is unreadable. */ + pid_t pid = find_container_by_name(name); + if (pid > 0) { + char run_path[PATH_MAX]; + if (build_proc_root_path(pid, "/run/droidspaces/container.config", run_path, + sizeof(run_path)) == 0 && + access(run_path, F_OK) == 0 && ds_config_load(run_path, cfg) == 0) { + safe_strncpy(cfg->config_file, config_path, sizeof(cfg->config_file)); + return 0; + } + } + return ds_config_load(config_path, cfg); } diff --git a/src/container.c b/src/container.c index 09761da0..7c0be418 100644 --- a/src/container.c +++ b/src/container.c @@ -1933,6 +1933,14 @@ int restart_rootfs_with_timeout(struct ds_config *cfg, int timeout_seconds) { if (stop_rootfs_with_timeout(cfg, 1, timeout_seconds) < 0) { return -1; } + /* The stop above tore down using the booted snapshot (loaded while the + * container was alive). It is gone now, so reloading by name returns the + * workspace copy - reload it so host-side container.config edits made while + * it ran take effect on this restart. start_rootfs re-derives the preserved + * mount from the on-disk .mount sidecar, so losing the snapshot paths is + * fine. */ + free_config_binds(cfg); + ds_config_load_by_name(cfg->container_name, cfg); putchar('\n'); print_ds_banner(); return start_rootfs(cfg); From 0ebd06ffbdacb637d62c76f58bee9559a1a83ee8 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 13 Jun 2026 23:21:59 +0530 Subject: [PATCH 051/211] net: unify /etc/resolv.conf wiring, fix gateway-mode DNS The old code hard-pointed /etc/resolv.conf at an invented /run/resolvconf/resolv.conf in nat/host modes, clobbering the distro's systemd-resolved setup. In gateway mode it skipped that block but left the stale symlink behind, so a gateway client booted with a dangling /etc/resolv.conf and no DNS while the correct lease-provided DNS sat unused at /run/systemd/resolve/resolv.conf. Replace it with a single setup_resolv_conf(): - gateway + no custom --dns + systemd container: symlink /etc/resolv.conf -> /run/systemd/resolve/resolv.conf so systemd-resolved (fed by the gateway's DHCP) owns DNS. - everything else (nat/host/none, any custom --dns, or a non-systemd gateway): write our content to /run/droidspaces/resolv.conf and symlink it. Also renames the /run/resolvconf duct-tape to /run/droidspaces (referenced nowhere else, and /run/resolvconf collided conceptually with the unrelated resolvconf package). Signed-off-by: ravindu644 --- src/net/network.c | 52 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/net/network.c b/src/net/network.c index f316214b..c74ebc71 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -1018,6 +1018,43 @@ int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, /* Compatibility wrapper */ +/* --------------------------------------------------------------------------- + * /etc/resolv.conf wiring (inside container, after pivot_root) + * + * Single source of truth for the container's resolver. Two cases: + * + * 1. gateway mode + no custom --dns + systemd container: + * DNS is owned by the gateway's DHCP/DNS (e.g. OpenWrt). systemd-resolved + * (fed by the lease) publishes it at /run/systemd/resolve/resolv.conf, so + * we only point the symlink there - resolved owns the file. (A non-systemd + * gateway has no resolved, so it falls through to case 2 with default + * DNS.) + * + * 2. everything else (nat/host/none, or any mode with custom --dns): + * Droidspaces owns DNS. Write our content (custom or default) to + * /run/droidspaces/resolv.conf and symlink it. + * + * This replaces the old /run/resolvconf duct-tape, which clobbered the distro's + * resolver and left a dangling symlink in gateway mode. + * ---------------------------------------------------------------------------*/ +static void setup_resolv_conf(struct ds_config *cfg) { + const char *target; + + if (cfg->net_mode == DS_NET_GATEWAY && !cfg->dns_servers[0] && + is_systemd_rootfs("/")) { + target = "/run/systemd/resolve/resolv.conf"; + } else { + mkdir("/run/droidspaces", 0755); + write_file("/run/droidspaces/resolv.conf", cfg->dns_server_content); + target = "/run/droidspaces/resolv.conf"; + } + + unlink("/etc/resolv.conf"); + if (symlink(target, "/etc/resolv.conf") < 0) + ds_warn("Failed to link /etc/resolv.conf -> %s: %s", target, + strerror(errno)); +} + /* --------------------------------------------------------------------------- * Rootfs-side networking setup (inside container, after pivot_root) * ---------------------------------------------------------------------------*/ @@ -1062,19 +1099,8 @@ int fix_networking_rootfs(struct ds_config *cfg) { write_file("/etc/hosts", hosts_content); - /* 3. resolv.conf. In gateway mode, DNS is owned by the gateway - * container's DHCP/DNS service, so avoid overwriting resolver state unless - * the user explicitly supplied --dns. */ - if (cfg->net_mode != DS_NET_GATEWAY || cfg->dns_servers[0]) { - mkdir("/run/resolvconf", 0755); - write_file("/run/resolvconf/resolv.conf", cfg->dns_server_content); - - /* Link /etc/resolv.conf */ - unlink("/etc/resolv.conf"); - if (symlink("/run/resolvconf/resolv.conf", "/etc/resolv.conf") < 0) { - ds_warn("Failed to link /etc/resolv.conf: %s", strerror(errno)); - } - } + /* 3. resolv.conf (unified resolver wiring - see setup_resolv_conf). */ + setup_resolv_conf(cfg); if (!ipv6_enabled) { if (cfg->net_mode == DS_NET_HOST) { From 20c6dbc9048b61f3476b47245eceebd9c2371bfb Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 13 Jun 2026 23:22:30 +0530 Subject: [PATCH 052/211] post_extract_fixes: allow network services to start in gateway mode The net-service guard only matched net_mode=nat, so systemd-networkd / systemd-resolved / NetworkManager / dhcpcd were skipped in gateway mode and the container booted with no DHCP client and therefore no IP. A gateway-mode client needs an in-container DHCP client just like NAT - the lease comes from the gateway container (e.g. OpenWrt) instead of from Droidspaces' NAT DHCP. Widen both guards (systemd ExecCondition and the Alpine/OpenRC dhcpcd start_pre) to grep -qE 'net_mode=(nat|gateway)'. host/none modes still skip them to avoid cellular network breakage. Signed-off-by: ravindu644 --- .../app/src/main/assets/post_extract_fixes.sh | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Android/app/src/main/assets/post_extract_fixes.sh b/Android/app/src/main/assets/post_extract_fixes.sh index be73e6e1..2d9f65d0 100755 --- a/Android/app/src/main/assets/post_extract_fixes.sh +++ b/Android/app/src/main/assets/post_extract_fixes.sh @@ -184,16 +184,18 @@ EOF $PRINTF "[Unit]\nConditionPathIsReadWrite=\n" > "$ROOTFS_PATH/etc/systemd/system/${unit}.d/99-readonly-fix.conf" done - # 06. Limit specific network services to only start in NAT mode - # Prevents cellular network breakage when running in host network mode - log "Applying NAT mode guards to network services..." + # 06. Limit specific network services to NAT and gateway modes only + # Both need an in-container DHCP client (NAT: lease from Droidspaces; gateway: + # lease from the gateway container, e.g. OpenWRT). Host/none modes still skip + # them to prevent cellular network breakage. + log "Applying NAT/gateway mode guards to network services..." for unit in NetworkManager.service dhcpcd.service systemd-resolved.service systemd-networkd.service; do if $TEST -f "$ROOTFS_PATH/$GUEST_SYSTEMD_PATH/$unit" || $TEST -f "$ROOTFS_PATH/etc/systemd/system/multi-user.target.wants/$unit"; then $MKDIR -p "$ROOTFS_PATH/etc/systemd/system/${unit}.d" $CAT > "$ROOTFS_PATH/etc/systemd/system/${unit}.d/99-netmode-limit.conf" << 'EOF' [Service] ExecCondition= -ExecCondition=/bin/sh -c "grep -q 'net_mode=nat' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "grep -qE 'net_mode=(nat|gateway)' /run/droidspaces/container.config" EOF fi done @@ -221,12 +223,13 @@ fi # --- 3. Alpine/OpenRC & dhcpcd-Specific Fixes --- -# Replace dhcpcd init script to only start in NAT network mode +# Replace dhcpcd init script to only start in NAT or gateway network mode # This is the OpenRC equivalent of systemd's ExecCondition - if the container # is running in host network mode, dhcpcd is cleanly skipped at boot to prevent -# cellular network breakage and kernel panics on Android interfaces. +# cellular network breakage and kernel panics on Android interfaces. Gateway +# mode needs it too: the DHCP lease comes from the gateway container. if $TEST -f "$ROOTFS_PATH/etc/init.d/dhcpcd"; then - log "Alpine/OpenRC dhcpcd service detected, applying NAT mode limitation..." + log "Alpine/OpenRC dhcpcd service detected, applying NAT/gateway mode limitation..." $CAT > "$ROOTFS_PATH/etc/init.d/dhcpcd" << 'INITEOF' #!/sbin/openrc-run @@ -246,9 +249,9 @@ depend() { } start_pre() { - # Only start in NAT mode - prevents cellular network breakage in host network mode - if ! grep -q 'net_mode=nat' /run/droidspaces/container.config 2>/dev/null; then - einfo "Skipping dhcpcd: not in NAT network mode" + # Only start in NAT or gateway mode - prevents cellular network breakage in host network mode + if ! grep -qE 'net_mode=(nat|gateway)' /run/droidspaces/container.config 2>/dev/null; then + einfo "Skipping dhcpcd: not in NAT or gateway network mode" return 1 fi checkpath -d /run/dhcpcd From 4457dc420c3f25718e40b131b217210e2fde6b0e Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 14 Jun 2026 01:23:43 +0530 Subject: [PATCH 053/211] net: keep gateway veth alive on last client exit, reap only the bridge The idle-segment teardown deleted both the bridge (ds-) and the gateway veth (ds-g) once the last client left. But ds-g's peer is the gateway container's live LAN interface (OpenWrt eth1), and deleting either end of a veth destroys the pair - so stopping the sole client ripped eth1 out of a still-running gateway. On the next client start a brand-new eth1 reappeared under OpenWrt, but netifd does not re-initialize a device that vanished and came back, so its LAN/DHCP stayed dead ("failed to initialize interface" in LuCI) until the gateway was rebooted. Reap only the empty bridge now; never delete the gateway veth. Leaving ds-g masterless keeps eth1 continuously alive (netifd never sees it flap); the next client recreates the bridge and reattaches ds-g via the existing else-branch. When the gateway container itself stops, its netns dies and ds-g vanishes with its peer - leaving zero residue, which also fixes the empty-bridge leak the old path left behind after the gateway exited. Signed-off-by: ravindu644 --- src/net/network.c | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/net/network.c b/src/net/network.c index c74ebc71..565217eb 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -1532,9 +1532,7 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { ds_log("[NET] Gateway cleanup: removed %s", veth_host); /* Refcount the delegated bridge: once the last client veth has left, - * tear down the gateway veth and the bridge so idle segments don't - * linger. The gateway's LAN interface is re-plugged lazily the next - * time any client attaches to this segment (setup_gateway_veth_side). */ + * reap the now-empty bridge so idle segments don't linger. */ char bridge[IFNAMSIZ], gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; gateway_bridge_name(cfg, bridge, sizeof(bridge)); gateway_veth_names(cfg, gw_host, sizeof(gw_host), gw_peer, @@ -1545,10 +1543,19 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { "delegated LAN", clients, bridge); } else { - ds_nl_del_link(ctx, gw_host); + /* Reap the empty bridge, but NEVER delete the gateway veth (gw_host): + * its peer is the gateway container's live LAN interface (e.g. eth1), + * and deleting either end destroys the pair - ripping the cable out of + * a still-running gateway. OpenWrt's netifd will not re-init a brand + * new eth1 that reappears under it, so its DHCP/LAN stays dead until + * the gateway is rebooted. Leaving gw_host masterless keeps eth1 + * alive; the next client recreates the bridge and reattaches gw_host + * (setup_gateway_veth_side else-branch). When the gateway container + * itself stops, its netns dies and gw_host vanishes with its peer, + * leaving zero residue. */ ds_nl_del_link(ctx, bridge); - ds_log("[NET] Gateway cleanup: torn down idle delegated LAN %s " - "(removed %s)", + ds_log("[NET] Gateway cleanup: reaped idle delegated LAN bridge %s " + "(kept gateway veth %s)", bridge, gw_host); } } else { From c09e941563089b01ce7b76f109878eb396c812cf Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 14 Jun 2026 01:33:10 +0530 Subject: [PATCH 054/211] net: pin container eth0 to a deterministic MAC from its name Every container start created a fresh veth, so the kernel handed eth0 a new random MAC each boot. Behind a gateway (OpenWrt) that meant a brand-new host every restart - LuCI's associated-devices and DHCP lease lists piled up dozens of stale entries for what is really one container. Derive a stable, locally-administered unicast MAC (0x02 prefix) from the container name via the existing djb2 hash (two salted rounds for 40 bits of spread) and pin it on eth0 in setup_veth_child_side_named, after the rename and while the link is still down, before the container's own DHCP client runs. The same name now always presents the same MAC, so the gateway sees a single persistent host and one lease across restarts. Covers NAT and gateway clients alike, since both funnel through this child-side path. Add ds_nl_set_mac() (RTM_NEWLINK + IFLA_ADDRESS) to back it. Signed-off-by: ravindu644 --- src/include/droidspace.h | 1 + src/net/netlink.c | 27 +++++++++++++++++++++++++++ src/net/network.c | 30 ++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 83f98fdb..1050af9a 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -702,6 +702,7 @@ int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_link_down(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_del_link(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_rename(ds_nl_ctx_t *ctx, const char *ifname, const char *newname); +int ds_nl_set_mac(ds_nl_ctx_t *ctx, const char *ifname, const uint8_t mac[6]); int ds_nl_add_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t ip_be, uint8_t prefix); int ds_nl_add_route4(ds_nl_ctx_t *ctx, uint32_t dst_be, uint8_t dst_len, diff --git a/src/net/netlink.c b/src/net/netlink.c index d29da508..2c885c66 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -507,6 +507,33 @@ int ds_nl_rename(ds_nl_ctx_t *ctx, const char *ifname, const char *newname) { return ds_nl_talk(ctx, &req.n); } +/* --------------------------------------------------------------------------- + * Set an interface's L2 hardware address (IFLA_ADDRESS). + * mac points to 6 bytes. The link should be DOWN to avoid EBUSY on some + * drivers; veth tolerates it either way, but callers set it before bringing + * the interface up. + * ---------------------------------------------------------------------------*/ + +int ds_nl_set_mac(ds_nl_ctx_t *ctx, const char *ifname, const uint8_t mac[6]) { + int idx = ds_nl_get_ifindex(ctx, ifname); + if (idx <= 0) + return -ENODEV; + + struct { + struct nlmsghdr n; + struct ifinfomsg i; + char buf[256]; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.n.nlmsg_type = RTM_NEWLINK; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + req.i.ifi_family = AF_UNSPEC; + req.i.ifi_index = idx; + nl_addattr(&req.n, (int)sizeof(req), IFLA_ADDRESS, mac, 6); + return ds_nl_talk(ctx, &req.n); +} + /* --------------------------------------------------------------------------- * Add an IPv4 address to an interface * ip_be and bcast_be are in network byte order. diff --git a/src/net/network.c b/src/net/network.c index 565217eb..ef09a980 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -66,6 +66,23 @@ static uint32_t ds_net_hash_string(const char *s) { return h; } +/* Derive a stable, locally-administered unicast MAC from the container name. + * The same name always yields the same address, so a gateway (e.g. OpenWrt) + * sees one persistent host across restarts instead of a fresh random MAC - + * and therefore one DHCP lease / LuCI entry - every boot. */ +static void ds_container_mac(const char *name, uint8_t mac[6]) { + uint32_t h1 = ds_net_hash_string(name); + char salted[300]; + snprintf(salted, sizeof(salted), "ds-mac:%s", name ? name : ""); + uint32_t h2 = ds_net_hash_string(salted); + mac[0] = 0x02; /* locally administered (bit1), unicast (bit0 clear) */ + mac[1] = (uint8_t)(h1 >> 24); + mac[2] = (uint8_t)(h1 >> 16); + mac[3] = (uint8_t)(h1 >> 8); + mac[4] = (uint8_t)(h1); + mac[5] = (uint8_t)(h2); +} + static void gateway_hash_key(struct ds_config *cfg, char *buf, size_t sz) { const char *gw = (cfg && cfg->gateway_container[0]) ? cfg->gateway_container : "gateway"; @@ -1002,6 +1019,19 @@ int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, ds_warn("[DEBUG] Failed to rename %s to eth0.", peer_name); } + /* 0b. Pin eth0 to a deterministic MAC derived from the container name, so + * an upstream gateway sees one stable host across restarts (no LuCI/lease + * churn) instead of a new random MAC each boot. Set while down, pre-up. */ + if (cfg && cfg->container_name[0]) { + uint8_t mac[6]; + ds_container_mac(cfg->container_name, mac); + if (ds_nl_set_mac(ctx, "eth0", mac) < 0) + ds_warn("[NET] Child: failed to set deterministic MAC on eth0"); + else + ds_log("[NET] Child: eth0 MAC pinned to %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } + /* 1. Loopback */ ds_nl_link_up(ctx, "lo"); From 977f0e84dc2bebe00b4e3c21dd828b7c6cc0c470 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 13 Jun 2026 22:20:54 +0200 Subject: [PATCH 055/211] Translated using Weblate (#200) Currently translated at 33.3% (1 of 3 strings) Translated using Weblate (German) Currently translated at 100.0% (468 of 468 strings) Translated using Weblate (German) Currently translated at 100.0% (468 of 468 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/es/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/de/ Translation: Droidspaces/Plurals Translation: Droidspaces/Strings Co-authored-by: Libre <6n0n1m0s@proton.me> Co-authored-by: Sandmann79 --- .../app/src/main/res/values-de/strings.xml | 19 +++++++++++++++---- .../app/src/main/res/values-es/plurals.xml | 1 + 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Android/app/src/main/res/values-de/strings.xml b/Android/app/src/main/res/values-de/strings.xml index ee85bd2e..828284bd 100644 --- a/Android/app/src/main/res/values-de/strings.xml +++ b/Android/app/src/main/res/values-de/strings.xml @@ -178,8 +178,8 @@ Dienste durchsuchen... Aktiviert Deaktiviert - Abnormal - Anormal + Statisch + Abnormal Maskiert Alle Unmaskiert @@ -195,8 +195,8 @@ Keine laufenden Dienste Keine aktivierten Dienste Keine deaktivierten Dienste - Keine abnormalen Dienste - Keine abnormalen Dienste + Keine statischen Dienste + Keine abnormale Dienste Keine maskierten Dienste Kernel @@ -478,4 +478,15 @@ URL muss mit https:// anfangen Keine benutzerdefinierten Repositories abonniert. Schreibgeschützt + Termux-Anforderungen + VirGL + PulseAudio + PulseAudio konfigurieren + Aktiviert Audiounterstützung mittels PulseAudio + Termux:X11 Parameter + z.B. -dpi 160 -force-sysvshm + VirGL 3D-Beschleunigung konfigurieren + Aktiviert 3D-Hardwarebeschleunigung mittels VirGL + VirGL Parameter + z.B. --multi-clients --angle-vulkan diff --git a/Android/app/src/main/res/values-es/plurals.xml b/Android/app/src/main/res/values-es/plurals.xml index 57ffb2de..0dc20dea 100644 --- a/Android/app/src/main/res/values-es/plurals.xml +++ b/Android/app/src/main/res/values-es/plurals.xml @@ -2,6 +2,7 @@ %d contenedor + %d contenedores %d contenedores From a72fec313d03cca0c303afc4765586806b949cc0 Mon Sep 17 00:00:00 2001 From: Subham Nayak <81096401+subhu2008@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:49:05 +0530 Subject: [PATCH 056/211] Add Droidspaces support for Redmi note 7 pro (violet) (#202) --- Documentation/community-supported-devices.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 126e9b58..9a6a8316 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -32,6 +32,7 @@ This document is a community-maintained compatibility list for Android devices k | **Redmi K20 Pro (China)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | Working | [@Alhkxsj](https://github.com/Alhkxsj) | Kernel source is stock YAAP kernel; Droidspaces configs applied via automated build workflow | | **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | none | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | Working | [@JackA1ltman](https://github.com/JackA1ltman) | Custom ROM only. Supported NTSYNC. | | **OnePlus 7 Pro** | GM1911 | LineageOS 17.1 - Android 10 | `QQ3A.200805.001` | `4.14.117` | Magisk | [Source](https://github.com/charan-gn/guacamole-droidspaces-kernel) | [Download](https://github.com/charan-gn/guacamole-droidspaces-kernel/releases/tag/v1.0) | Both | Turnip & Virgl | Working | [@charan-gn](https://github.com/charan-gn) | AnyKernel3 flashable on any ROM. | +| **Redmi note 7 pro (violet)** | M1901F7S | LineagOS 23.2 - Android 16 | `.c2-00029-SM6150_GEN_PACK-1.352094.1.355031.2` | `4.14.357` | Magisk | [source](https://github.com/subhu2008/android_kernel_xiaomi_sm6150) | [Download](https://github.com/subhu2008/android_kernel_xiaomi_sm6150/releases/tag/stable) | Both | Turnip & Virgl | Working | [@subhu2008](https://github.com/subhu2008) | LineageOS 23.2 only. If it works on other AOSP ROM's too, then good. | ## GKI Devices (Kernel 5.4 and up) From 7b7213bb07fb5d052b85b618ed1c6dae4b06b6a6 Mon Sep 17 00:00:00 2001 From: Yoshinobu Date Date: Mon, 15 Jun 2026 01:49:26 +0900 Subject: [PATCH 057/211] android: add OpenWrt procd service management (#203) * android: detect OpenWrt procd init system * android: add OpenWrt procd service management --- Android/app/src/main/assets/chkprocd.sh | 86 ++++ .../ui/navigation/DroidspacesNavigation.kt | 21 + .../app/ui/screen/ContainerDetailsScreen.kt | 10 +- .../droidspaces/app/ui/screen/ProcdScreen.kt | 463 ++++++++++++++++++ .../app/util/ContainerProcdManager.kt | 225 +++++++++ Android/app/src/main/res/values/strings.xml | 13 +- 6 files changed, 812 insertions(+), 6 deletions(-) create mode 100644 Android/app/src/main/assets/chkprocd.sh create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt create mode 100644 Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt diff --git a/Android/app/src/main/assets/chkprocd.sh b/Android/app/src/main/assets/chkprocd.sh new file mode 100644 index 00000000..1a71a784 --- /dev/null +++ b/Android/app/src/main/assets/chkprocd.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# chkprocd.sh - OpenWrt/procd service query tool +# Output format for --all: +# name|description|enabled|running +# where enabled is enabled/disabled and running is running/stopped/unknown. + +INITD_DIR="/etc/init.d" + +_is_safe_name() { + echo "$1" | grep -Eq '^[A-Za-z0-9_.+@-]+$' +} + +_all_services() { + [ -d "$INITD_DIR" ] || return 0 + for path in "$INITD_DIR"/*; do + [ -f "$path" ] || continue + [ -x "$path" ] || continue + name="${path##*/}" + _is_safe_name "$name" || continue + case "$name" in + .*) continue ;; + esac + echo "$name" + done | sort -u +} + +_get_desc() { + svc="$1" + script="$INITD_DIR/$svc" + + # Some init scripts define DESCRIPTION/description. Most OpenWrt scripts do + # not, so empty description is a valid result. + # Keep this parser deliberately conservative to avoid shell-quoting + # complexity on BusyBox ash. Descriptions are optional in the UI. + grep -m1 -E '^(DESCRIPTION|description)=' "$script" 2>/dev/null \ + | sed 's/^[^=]*=//;s/^"//;s/"$//' \ + | head -n 1 +} + +_is_enabled() { + "$INITD_DIR/$1" enabled >/dev/null 2>&1 +} + +_running_state() { + svc="$1" + + if "$INITD_DIR/$svc" running >/dev/null 2>&1; then + echo running + return 0 + fi + + # procd init scripts generally implement status; one-shot/boot scripts may + # not. Treat unsupported status as unknown rather than pretending stopped. + output=$("$INITD_DIR/$svc" status 2>&1) + rc=$? + if [ $rc -eq 0 ]; then + echo running + return 0 + fi + + echo "$output" | grep -Eiq 'inactive|not running|stopped|disabled|dead' && { + echo stopped + return 0 + } + + echo unknown +} + +case "$1" in + --all) + for svc in $(_all_services); do + desc=$(_get_desc "$svc") + if _is_enabled "$svc"; then + enabled="enabled" + else + enabled="disabled" + fi + running=$(_running_state "$svc") + echo "${svc}|${desc}|${enabled}|${running}" + done + ;; + *) + echo "usage: $0 --all" >&2 + exit 2 + ;; +esac diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index d101b491..b55bc7c1 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -34,6 +34,7 @@ import com.droidspaces.app.ui.screen.InstallationProgressScreen import com.droidspaces.app.ui.screen.EditContainerScreen import com.droidspaces.app.ui.screen.ContainerDetailsScreen import com.droidspaces.app.ui.screen.SystemdScreen +import com.droidspaces.app.ui.screen.ProcdScreen import com.droidspaces.app.ui.screen.OpenRCScreen import com.droidspaces.app.ui.screen.ContainerTerminalScreen import com.droidspaces.app.ui.viewmodel.ContainerInstallationViewModel @@ -92,6 +93,9 @@ sealed class Screen(val route: String) { data object Systemd : Screen("systemd/{containerName}") { fun createRoute(containerName: String) = "systemd/${Uri.encode(containerName)}" } + data object Procd : Screen("procd/{containerName}") { + fun createRoute(containerName: String) = "procd/${Uri.encode(containerName)}" + } data object OpenRC : Screen("openrc/{containerName}") { fun createRoute(containerName: String) = "openrc/${Uri.encode(containerName)}" } @@ -574,6 +578,8 @@ fun DroidspacesNavigation( when (initSystem) { com.droidspaces.app.ui.screen.InitSystem.SYSTEMD -> navController.navigate(Screen.Systemd.createRoute(containerName)) + com.droidspaces.app.ui.screen.InitSystem.PROCD -> + navController.navigate(Screen.Procd.createRoute(containerName)) com.droidspaces.app.ui.screen.InitSystem.OPENRC -> navController.navigate(Screen.OpenRC.createRoute(containerName)) } @@ -602,6 +608,21 @@ fun DroidspacesNavigation( ) } + composable( + route = Screen.Procd.route, + arguments = listOf( + navArgument("containerName") { type = NavType.StringType } + ), + enterTransition = defaultEnterTransition, + exitTransition = defaultExitTransition + ) { backStackEntry -> + val containerName = backStackEntry.arguments?.getString("containerName") ?: "" + ProcdScreen( + containerName = containerName, + onNavigateBack = { navController.popBackStack() } + ) + } + composable( route = Screen.OpenRC.route, arguments = listOf( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt index 7e62116d..1184172d 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt @@ -24,6 +24,7 @@ import com.droidspaces.app.ui.component.ContainerUsersCard import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerOSInfoManager import com.droidspaces.app.util.ContainerSystemdManager +import com.droidspaces.app.util.ContainerProcdManager import com.droidspaces.app.util.ContainerOpenRCManager import com.droidspaces.app.util.ContainerManager import kotlinx.coroutines.Dispatchers @@ -72,13 +73,15 @@ fun ContainerDetailsScreen( mutableStateOf(ContainerOSInfoManager.getCachedOSInfo(container.name, context)) } - // Init system state - detect systemd first, then OpenRC + // Init system state - detect systemd first, then OpenWrt/procd, then OpenRC var initSystemState by remember { mutableStateOf(InitSystemCardState.Checking) } LaunchedEffect(container.name) { initSystemState = when { ContainerSystemdManager.isSystemdAvailable(container.name) -> InitSystemCardState.Available(InitSystem.SYSTEMD) + ContainerProcdManager.isProcdAvailable(container.name) -> + InitSystemCardState.Available(InitSystem.PROCD) ContainerOpenRCManager.isOpenRCAvailable(container.name) -> InitSystemCardState.Available(InitSystem.OPENRC) else -> @@ -244,7 +247,7 @@ fun ContainerDetailsScreen( /** * Which init system is available in the container. */ -enum class InitSystem { SYSTEMD, OPENRC } +enum class InitSystem { SYSTEMD, PROCD, OPENRC } /** * Init system card state - sealed for type safety and stability @@ -437,7 +440,7 @@ private fun TerminalCard( } /** - * PREMIUM INIT SYSTEM CARD - handles Systemd, OpenRC, and unavailable states. + * PREMIUM INIT SYSTEM CARD - handles Systemd, OpenWrt/procd, OpenRC, and unavailable states. */ @Composable private fun PremiumInitSystemCard( @@ -458,6 +461,7 @@ private fun PremiumInitSystemCard( val cardTitle = when (state) { is InitSystemCardState.Available -> when (state.initSystem) { InitSystem.SYSTEMD -> context.getString(R.string.systemd) + InitSystem.PROCD -> context.getString(R.string.openwrt) InitSystem.OPENRC -> context.getString(R.string.openrc) } else -> context.getString(R.string.init_system) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt new file mode 100644 index 00000000..2d5be973 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt @@ -0,0 +1,463 @@ +package com.droidspaces.app.ui.screen + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droidspaces.app.R +import com.droidspaces.app.ui.util.* +import com.droidspaces.app.util.AnimationUtils +import com.droidspaces.app.util.ContainerProcdManager +import com.droidspaces.app.util.ContainerProcdManager.ServiceFilter +import com.droidspaces.app.util.ContainerProcdManager.ServiceInfo +import com.droidspaces.app.util.ContainerProcdManager.ServiceStatus +import kotlinx.coroutines.launch + +private val JetBrainsMono = FontFamily( + Font(R.font.jetbrains_mono_regular, FontWeight.Normal), + Font(R.font.jetbrains_mono_bold, FontWeight.Bold) +) + +private sealed class ProcdScreenState { + data object Loading : ProcdScreenState() + data object ProcdNotAvailable : ProcdScreenState() + data class Ready(val services: List) : ProcdScreenState() +} + +private sealed class ProcdActionState { + data object Idle : ProcdActionState() + data class InProgress(val serviceName: String, val actionName: String) : ProcdActionState() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProcdScreen( + containerName: String, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { ContainerProcdManager.initialize(context) } + + var screenState by remember { mutableStateOf(ProcdScreenState.Loading) } + var actionState by remember { mutableStateOf(ProcdActionState.Idle) } + var selectedFilter by remember { mutableStateOf(ServiceFilter.RUNNING) } + var logsDialogContent by remember { mutableStateOf?>(null) } + var searchQuery by remember { mutableStateOf("") } + + var fetchJob by remember { mutableStateOf(null) } + var actionJob by remember { mutableStateOf(null) } + + fun fetchServices() { + fetchJob?.cancel() + screenState = ProcdScreenState.Loading + fetchJob = scope.launch { + try { + val available = ContainerProcdManager.isProcdAvailable(containerName) + if (!available) { + screenState = ProcdScreenState.ProcdNotAvailable + return@launch + } + val services = ContainerProcdManager.getAllServices(containerName) + screenState = ProcdScreenState.Ready(services) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + screenState = ProcdScreenState.ProcdNotAvailable + } + } + } + + fun executeAction(serviceName: String, actionName: String, action: suspend () -> ContainerProcdManager.CommandResult) { + actionJob?.cancel() + fetchJob?.cancel() + actionState = ProcdActionState.InProgress(serviceName, actionName) + actionJob = scope.launch { + try { + val result = action() + actionState = ProcdActionState.Idle + if (result.isSuccess) { + screenState = ProcdScreenState.Loading + scope.showSuccess(snackbarHostState, context.getString(R.string.action_successful, actionName, serviceName)) + fetchServices() + } else { + val allLogs = result.output + result.error + if (allLogs.isNotEmpty()) logsDialogContent = allLogs + else scope.showError(snackbarHostState, context.getString(R.string.failed_to_action, actionName, serviceName)) + } + } catch (e: Exception) { + actionState = ProcdActionState.Idle + if (e is kotlinx.coroutines.CancellationException) throw e + scope.showError(snackbarHostState, context.getString(R.string.error_unknown, e.message ?: context.getString(R.string.unknown))) + } + } + } + + LaunchedEffect(containerName) { fetchServices() } + + val allServices = (screenState as? ProcdScreenState.Ready)?.services ?: emptyList() + val filteredServices = remember(allServices, selectedFilter, searchQuery) { + if (searchQuery.isBlank()) ContainerProcdManager.filterServices(allServices, selectedFilter) + else allServices.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + + val serviceCounts = remember(allServices) { + mapOf( + ServiceFilter.RUNNING to allServices.count { it.isRunning && it.isEnabled }, + ServiceFilter.ENABLED to allServices.count { it.isEnabled && !it.isRunning && it.status != ServiceStatus.UNKNOWN }, + ServiceFilter.DISABLED to allServices.count { !it.isEnabled && !it.isRunning && it.status != ServiceStatus.UNKNOWN }, + ServiceFilter.ABNORMAL to allServices.count { it.isRunning && !it.isEnabled }, + ServiceFilter.UNKNOWN to allServices.count { it.status == ServiceStatus.UNKNOWN }, + ServiceFilter.ALL to allServices.size + ) + } + + val clearFocus = rememberClearFocus() + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(context.getString(R.string.openwrt_services), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) }, + navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.back)) } }, + actions = { IconButton(onClick = { fetchServices() }, enabled = screenState !is ProcdScreenState.Loading && actionState is ProcdActionState.Idle) { Icon(Icons.Default.Refresh, context.getString(R.string.refresh)) } }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent + ) { padding -> + ClearFocusOnClickOutside(modifier = Modifier.padding(padding).fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + when (screenState) { + is ProcdScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) + is ProcdScreenState.ProcdNotAvailable -> ProcdNotAvailable() + is ProcdScreenState.Ready -> { + Column(modifier = Modifier.fillMaxSize()) { + ProcdSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) + ProcdFilterChipsRow(selectedFilter = selectedFilter, serviceCounts = serviceCounts, onFilterSelected = { selectedFilter = it; clearFocus() }) + if (filteredServices.isEmpty()) { + ProcdEmptyServicesState(filter = selectedFilter, modifier = Modifier.weight(1f)) + } else { + LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(filteredServices) { service -> + ProcdServiceCard(service = service, containerName = containerName, onAction = { name, act -> executeAction(service.name, name, act) }) + } + } + } + } + } + } + } + } + } + } + + (actionState as? ProcdActionState.InProgress)?.let { state -> ProgressDialog(message = context.getString(R.string.actioning_service, state.actionName, state.serviceName)) } + logsDialogContent?.let { logs -> ErrorLogsDialog(logs = logs, onDismiss = { logsDialogContent = null }) } +} + +@Composable +private fun ProcdSearchBar(query: String, onQueryChange: (String) -> Unit) { + val context = LocalContext.current + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor by animateColorAsState( + targetValue = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), + animationSpec = AnimationUtils.fastSpec() + ) + + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, borderColor) + ) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + interactionSource = interactionSource, + placeholder = { Text(context.getString(R.string.search_services), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, + leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, + trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } } }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary + ), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + keyboardOptions = FocusUtils.searchKeyboardOptions, + keyboardActions = FocusUtils.clearFocusKeyboardActions() + ) + } +} + +@Composable +private fun ProcdFilterChipsRow( + selectedFilter: ServiceFilter, + serviceCounts: Map, + onFilterSelected: (ServiceFilter) -> Unit +) { + val context = LocalContext.current + // procd has no masked/static concepts; UNKNOWN covers scripts without reliable status. + val filters = listOf( + Triple(ServiceFilter.RUNNING, R.string.running, Color(0xFF4CAF50)), + Triple(ServiceFilter.ENABLED, R.string.enabled_legend, Color(0xFFFFCA28)), + Triple(ServiceFilter.DISABLED, R.string.disabled_legend, Color(0xFFEF5350)), + Triple(ServiceFilter.ABNORMAL, R.string.abnormal_legend, Color(0xFFFF7043)), + Triple(ServiceFilter.UNKNOWN, R.string.unknown_legend, Color(0xFF90A4AE)), + Triple(ServiceFilter.ALL, R.string.all_legend, null) + ) + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + filters.forEach { (filter, labelRes, dotColor) -> + val count = serviceCounts[filter] ?: 0 + val isSelected = selectedFilter == filter + + FilterChip( + selected = isSelected, + onClick = { onFilterSelected(filter) }, + label = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (dotColor != null) { + Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = dotColor) {} + } + Text("${context.getString(labelRes)} ($count)", style = MaterialTheme.typography.labelLarge) + } + }, + shape = RoundedCornerShape(12.dp), + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer), + border = FilterChipDefaults.filterChipBorder(selected = isSelected, enabled = true, + borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + selectedBorderColor = MaterialTheme.colorScheme.primary) + ) + } + } +} + +@Composable +private fun ProcdServiceCard( + service: ServiceInfo, + containerName: String, + onAction: (String, suspend () -> ContainerProcdManager.CommandResult) -> Unit +) { + val context = LocalContext.current + var showMenu by remember { mutableStateOf(false) } + + val statusColor = when (service.status) { + ServiceStatus.ENABLED_RUNNING -> Color(0xFF4CAF50) + ServiceStatus.ENABLED_STOPPED -> Color(0xFFFFCA28) + ServiceStatus.DISABLED_STOPPED -> Color(0xFFEF5350) + ServiceStatus.ABNORMAL -> Color(0xFFFF7043) + ServiceStatus.UNKNOWN -> Color(0xFF90A4AE) + } + + Surface( + modifier = Modifier.fillMaxWidth().animateContentSize(AnimationUtils.mediumSpec()), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp).height(32.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = service.name, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = JetBrainsMono, + fontSize = if (service.name.length > 25) 13.sp else 16.sp + ), + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + + Surface( + color = statusColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} + Text( + text = when (service.status) { + ServiceStatus.ENABLED_RUNNING -> context.getString(R.string.running) + ServiceStatus.ENABLED_STOPPED -> context.getString(R.string.enabled_legend) + ServiceStatus.DISABLED_STOPPED -> context.getString(R.string.disabled_legend) + ServiceStatus.ABNORMAL -> context.getString(R.string.abnormal_legend) + ServiceStatus.UNKNOWN -> context.getString(R.string.unknown_legend) + }.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Black, + letterSpacing = 0.5.sp, + color = statusColor + ) + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (service.description.isNotEmpty()) { + Text( + text = service.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + val btnColor = if (service.isRunning) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + val accentColor = if (service.isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + + // Start / Stop + Surface( + onClick = { + if (service.isRunning) onAction(context.getString(R.string.stop)) { ContainerProcdManager.stopService(containerName, service.name) } + else onAction(context.getString(R.string.start)) { ContainerProcdManager.startService(containerName, service.name) } + }, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + color = btnColor, + border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f)) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(if (service.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, null, Modifier.size(18.dp), tint = accentColor) + Text(if (service.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = accentColor) + } + } + } + + // Enable / Disable + Surface( + onClick = { + if (service.isEnabled) onAction(context.getString(R.string.disable_service)) { ContainerProcdManager.disableService(containerName, service.name) } + else onAction(context.getString(R.string.enable_service)) { ContainerProcdManager.enableService(containerName, service.name) } + }, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(if (service.isEnabled) Icons.Default.Block else Icons.Default.CheckCircle, null, Modifier.size(18.dp)) + Text(if (service.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + } + } + } + + // More (Restart / Reload) + Box { + Surface( + onClick = { showMenu = true }, + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + } + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text(context.getString(R.string.restart_service)) }, + leadingIcon = { Icon(Icons.Default.Refresh, null) }, + onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerProcdManager.restartService(containerName, service.name) } } + ) + DropdownMenuItem( + text = { Text(context.getString(R.string.reload_service)) }, + leadingIcon = { Icon(Icons.Default.Refresh, null) }, + onClick = { showMenu = false; onAction(context.getString(R.string.reload_service)) { ContainerProcdManager.reloadService(containerName, service.name) } } + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ProcdNotAvailable() { + val context = LocalContext.current + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(32.dp)) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f))) { + Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Warning, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) } + } + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(context.getString(R.string.init_system_not_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(context.getString(R.string.init_system_not_available_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun ProcdEmptyServicesState(filter: ServiceFilter, modifier: Modifier = Modifier) { + val context = LocalContext.current + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(Icons.Default.SearchOff, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) + Text( + text = when (filter) { + ServiceFilter.RUNNING -> context.getString(R.string.no_running_services) + ServiceFilter.ENABLED -> context.getString(R.string.no_enabled_services) + ServiceFilter.DISABLED -> context.getString(R.string.no_disabled_services) + ServiceFilter.ABNORMAL -> context.getString(R.string.no_abnormal_services) + ServiceFilter.UNKNOWN -> context.getString(R.string.no_unknown_services) + ServiceFilter.ALL -> context.getString(R.string.no_services_found) + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt new file mode 100644 index 00000000..65ba3b83 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt @@ -0,0 +1,225 @@ +package com.droidspaces.app.util + +import android.content.Context +import android.util.Base64 +import android.util.Log +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * OpenWrt/procd service manager for containers. + * + * Uses a base64-encoded chkprocd.sh helper for service discovery and + * /etc/init.d/ for procd-compatible service actions. + */ +object ContainerProcdManager { + private const val TAG = "ContainerProcdManager" + private const val COMMAND_TIMEOUT_MS = 15_000L + + private val safeServiceName = Regex("^[A-Za-z0-9_.+@-]+$") + private val safeActions = setOf("start", "stop", "restart", "reload", "enable", "disable", "status") + + private var scriptBase64: String? = null + + /** + * procd does not expose systemd's masked/static concepts. Runtime state can + * also be unavailable for some init scripts, so UNKNOWN is explicit. + */ + enum class ServiceStatus { + ENABLED_RUNNING, + ENABLED_STOPPED, + DISABLED_STOPPED, + ABNORMAL, + UNKNOWN + } + + data class ServiceInfo( + val name: String, + val description: String, + val status: ServiceStatus, + val isEnabled: Boolean, + val isRunning: Boolean + ) + + enum class ServiceFilter { + RUNNING, + ENABLED, + DISABLED, + ABNORMAL, + UNKNOWN, + ALL + } + + data class CommandResult( + val exitCode: Int, + val output: List, + val error: List + ) { + val isSuccess: Boolean get() = exitCode == 0 + } + + fun initialize(context: Context) { + if (scriptBase64 == null) { + try { + val script = context.assets.open("chkprocd.sh").bufferedReader().readText() + scriptBase64 = Base64.encodeToString(script.toByteArray(), Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(TAG, "Failed to load chkprocd.sh from assets", e) + } + } + } + + private suspend fun runScript(containerName: String, flag: String): Pair> = + withContext(Dispatchers.IO) { + val b64 = scriptBase64 ?: return@withContext Pair(false, emptyList()) + try { + val cmd = "${Constants.DROIDSPACES_BINARY_PATH} --name=${ContainerCommandBuilder.quote(containerName)} run 'echo $b64 | base64 -d | sh -s -- $flag'" + val result = Shell.cmd(cmd).exec() + Pair(result.isSuccess, result.out) + } catch (e: Exception) { + Log.e(TAG, "Error running procd query script", e) + Pair(false, emptyList()) + } + } + + /** + * Check if the container looks like an OpenWrt/procd system. + * + * Do not use /etc/init.d alone: OpenRC and SysV-style systems also expose + * that directory. /etc/openwrt_release is the distro-level discriminator; + * procd/ubus/service binaries confirm that the OpenWrt service stack exists. + */ + suspend fun isProcdAvailable(containerName: String): Boolean = withContext(Dispatchers.IO) { + try { + val cmd = "${Constants.DROIDSPACES_BINARY_PATH} --name=${ContainerCommandBuilder.quote(containerName)} run '[ -f /etc/openwrt_release ] && { [ -x /sbin/procd ] || [ -x /sbin/ubusd ] || command -v ubus >/dev/null 2>&1 || command -v service >/dev/null 2>&1; }'" + Shell.cmd(cmd).exec().isSuccess + } catch (e: Exception) { + Log.e(TAG, "Error checking procd availability", e) + false + } + } + + suspend fun getAllServices(containerName: String): List = withContext(Dispatchers.IO) { + try { + val (success, lines) = runScript(containerName, "--all") + if (!success) return@withContext emptyList() + + lines.mapNotNull { line -> + val parts = line.split("|", limit = 4) + if (parts.size < 4) return@mapNotNull null + + val name = parts[0].trim() + if (!isSafeServiceName(name)) return@mapNotNull null + + val description = parts[1].trim() + val enabled = parts[2].trim() == "enabled" + val runningState = parts[3].trim() + val running = runningState == "running" + + val status = when { + runningState == "unknown" -> ServiceStatus.UNKNOWN + enabled && running -> ServiceStatus.ENABLED_RUNNING + enabled && !running -> ServiceStatus.ENABLED_STOPPED + !enabled && running -> ServiceStatus.ABNORMAL + else -> ServiceStatus.DISABLED_STOPPED + } + + ServiceInfo( + name = name, + description = description, + status = status, + isEnabled = enabled, + isRunning = running + ) + }.sortedBy { it.name } + } catch (e: Exception) { + Log.e(TAG, "Error fetching procd services", e) + emptyList() + } + } + + fun filterServices(services: List, filter: ServiceFilter): List { + return when (filter) { + ServiceFilter.ALL -> services + ServiceFilter.RUNNING -> services.filter { it.isRunning && it.isEnabled } + ServiceFilter.ENABLED -> services.filter { it.isEnabled && !it.isRunning && it.status != ServiceStatus.UNKNOWN } + ServiceFilter.DISABLED -> services.filter { !it.isEnabled && !it.isRunning && it.status != ServiceStatus.UNKNOWN } + ServiceFilter.ABNORMAL -> services.filter { it.isRunning && !it.isEnabled } + ServiceFilter.UNKNOWN -> services.filter { it.status == ServiceStatus.UNKNOWN } + }.sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) + } + + private fun isSafeServiceName(serviceName: String): Boolean = safeServiceName.matches(serviceName) + + private fun validateServiceAction(serviceName: String, action: String): CommandResult? { + if (!isSafeServiceName(serviceName)) { + return CommandResult( + exitCode = 2, + output = emptyList(), + error = listOf("Invalid OpenWrt service name: $serviceName") + ) + } + if (action !in safeActions) { + return CommandResult( + exitCode = 2, + output = emptyList(), + error = listOf("Unsupported OpenWrt service action: $action") + ) + } + return null + } + + private suspend fun executeProcdCommand( + containerName: String, + serviceName: String, + action: String + ): CommandResult = withContext(Dispatchers.IO) { + validateServiceAction(serviceName, action)?.let { return@withContext it } + + val command = "/etc/init.d/$serviceName $action" + val fullCmd = "${Constants.DROIDSPACES_BINARY_PATH} --name=${ContainerCommandBuilder.quote(containerName)} run '$command 2>&1'" + val executor = Executors.newSingleThreadExecutor() + try { + val future = executor.submit { Shell.cmd(fullCmd).exec() } + try { + val result = future.get(COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS) + CommandResult(exitCode = result.code, output = result.out, error = result.err) + } catch (e: TimeoutException) { + future.cancel(true) + Log.e(TAG, "Command timed out: $command") + CommandResult( + exitCode = 124, + output = listOf("Command timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds"), + error = emptyList() + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error executing: $command", e) + CommandResult(exitCode = 1, output = emptyList(), error = listOf(e.message ?: "Unknown error")) + } finally { + executor.shutdownNow() + } + } + + suspend fun startService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "start") + + suspend fun stopService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "stop") + + suspend fun restartService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "restart") + + suspend fun reloadService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "reload") + + suspend fun enableService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "enable") + + suspend fun disableService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "disable") +} diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 49e4c261..01092b7f 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ Talks to the host Linux kernel directly. No hypervisor, no virtual machine layer. Unkillable by design Supports native init.rc integration and 2-layer userspace daemons. Completely unkillable. - systemd / OpenRC as PID 1 + systemd / OpenWrt procd / OpenRC as PID 1 Full init system support. Real service management, not a simulated environment. Complete isolation Private PID tree, mount table, hostname, IPC, and cgroup hierarchy per container. @@ -376,11 +376,15 @@ Systemd Services + OpenWrt Services + OpenWrt + OpenWrt/procd detected + Service management for %1$s will be added in the next patch. OpenRC Services OpenRC Init System - Systemd/OpenRC Not Available - This container does not have systemd or OpenRC installed + Systemd/OpenWrt/OpenRC Not Available + This container does not have systemd, OpenWrt/procd, or OpenRC installed Fetching services... Search services... @@ -388,12 +392,14 @@ Disabled Static Abnormal + Unknown Masked All Unmask Enable Disable Restart service + Reload service Mask service %1$s %2$s successful Failed to %1$s %2$s @@ -406,6 +412,7 @@ No disabled services No static services No abnormal services + No services with unknown state No masked services From 75ed0b50ad742189f7722bcd9f6754cc3c76d675 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 15 Jun 2026 10:52:22 +0200 Subject: [PATCH 058/211] Translated using Weblate (Chinese (Traditional Han script)) (#204) Currently translated at 95.5% (470 of 492 strings) Translated using Weblate (German) Currently translated at 100.0% (492 of 492 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (492 of 492 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/de/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hant/ Translation: Droidspaces/Strings Co-authored-by: Jung Chi Wang Co-authored-by: MengHanLOVE <2193438288@qq.com> Co-authored-by: Sandmann79 --- .../app/src/main/res/values-de/strings.xml | 32 ++++++++++++++++++- .../src/main/res/values-zh-rCN/strings.xml | 30 +++++++++++++++++ .../src/main/res/values-zh-rTW/strings.xml | 8 +++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/Android/app/src/main/res/values-de/strings.xml b/Android/app/src/main/res/values-de/strings.xml index 828284bd..7d39b3e0 100644 --- a/Android/app/src/main/res/values-de/strings.xml +++ b/Android/app/src/main/res/values-de/strings.xml @@ -195,7 +195,7 @@ Keine laufenden Dienste Keine aktivierten Dienste Keine deaktivierten Dienste - Keine statischen Dienste + Keine statische Dienste Keine abnormale Dienste Keine maskierten Dienste @@ -489,4 +489,34 @@ Aktiviert 3D-Hardwarebeschleunigung mittels VirGL VirGL Parameter z.B. --multi-clients --angle-vulkan + Autostart-Priorität + Reihenfolge in der die Autostart Container starten + Ziehen zum Neuanordnen. Die Container werden von oben nach unten gestartet – ein Gateway (z. B. OpenWRT) an erster Stelle einfügen, damit es vor den Containern startet, die Daten über dieses Gateway weiterleiten. + Kein Container mit aktivierten Autostart vorhanden. + Ziehen zum Neuanordnen + Gateway (Virtueller Router) + Gateway + Gateway Einstellungen + Diesen Container über einen anderen Container weiterleiten, der als Router fungiert (z. B. OpenWRT). Das Gateway muss bereits laufen. + Gateway Container + LAN Name + lan + Schnittstelle im Gateway + eth1 + Host-Brücke + ds-lan + Gateway konfigurieren + Gateway konfigurieren + Diese Einstellungen legen fest, wie dieser Container in das virtuelle LAN des Gateways eingebunden wird. Die Standardeinstellungen gelten für ein einzelnes LAN hinter einem Gateway – nur ändern, wenn mehrere LAN-Segmente oder mehr als ein Gateway betrieben wird. + Der Name der Schnittstelle, unter dem diese Verbindung innerhalb des Gateway-Containers angezeigt wird (z. B. eth1 in OpenWRT). Er muss mit der Schnittstelle übereinstimmen, auf der das Gateway DHCP bereitstellt. Jedes einzelne Segment desselben Gateways benötigt einen eigenen, eindeutigen Namen. Standard: eth1. + Eine Bezeichnung für dieses LAN-Segment. Container, die denselben Gateway- und LAN-Namen verwenden, werden demselben virtuellen Switch zugeordnet und können sich gegenseitig sehen. Mit einem anderen Namen lässt sich ein isoliertes zweites Segment einrichten. Standard: lan. + Name des auf dem Android-Host erstellten virtuellen Switches. Felder leer lassen, damit der Name automatisch aus dem LAN-Namen (ds-<lan>) generiert wird. Nur einstellen, wenn ein bestimmter Brückenname erforderlich ist. Maximal 15 Zeichen. + Gateway Container auswählen + Ein Container kann nicht sein eigenes Gateway sein + Der Gateway Container ist nicht installiert + Einen anderen Container installieren, der als Gateway fungiert + Nur Buchstaben, Ziffern, _ oder - verwenden + Zu lang - maximal 15 Zeichen + Brücke wird bereits von \'%1$s\' verwendet. Anderen LAN Namen verwenden. + Die Schnittstelle wird bereits von einem anderen Segment (\'%1$s\') verwendet. Verwenden Sie eine andere. diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index c27644ee..0ca31ebd 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -462,4 +462,34 @@ VirGL 额外参数 例如: --multi-clients --angle-vulkan Termux 要求 + 自启动优先级 + 系统启动时运行的容器的启动顺序 + 拖动即可重新排列顺序。容器从上到下排列——先放置网关(例如 OpenWRT),这样它会先启动,然后才是通过它的其他容器。 + 没有容器设置了 “开机时自启”。 + 拖动以重新排序 + 网关(虚拟路由器) + 网关 + 网关设置 + 把这个容器通过另一个充当路由器的容器(例如 OpenWRT)路由。该网关必须先运行起来。 + 网关容器 + 局域网名称 + 局域网 + 网关中的接口 + eth1 + 主机桥 + ds-lan + 配置网关 + 配置网关 + 这些控制这个容器如何接入网关的虚拟局域网。默认设置适用于单个网关后的单个局域网——只有在运行多个局域网段或多个网关时才需要更改它们。 + 这个连接显示的接口名称在网关容器内部出现(例如 OpenWRT 中的 eth1)。它必须与网关设置为提供 DHCP 的接口匹配。同一网关上的每个独立网络段都需要一个唯一的名称。默认值:eth1。 + 这个 LAN 段的标签。共享相同网关和 LAN 名称的容器会加入同一个虚拟交换机,并且可以彼此互相访问。使用不同的名称可以创建一个隔离的第二个段。默认值:lan。 + 在 Android 主机上创建的虚拟交换机名称。留空可根据 LAN 名称自动命名(ds-<lan>)。只有在你需要指定精确的桥接名称时才设置。最多 15 个字符。 + 选择一个网关容器 + 一个容器不能是它自己的网关 + 这个网关容器没有安装 + 安装另一个容器来充当网关 + 仅使用字母、数字、_ 或 - + 太长了 - 最多15个字符 + 桥接已被 \'%1$s\' 使用。请使用不同的局域网名称。 + 接口已被另一个段(\'%1$s\')使用。请使用其他接口。 diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index f099e7d5..77c881a2 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -457,4 +457,12 @@ VirGL 額外參數標記 例如: --multi-clients --angle-vulkan Termux 需求 + 自動啟動優先權 + 容器啟動時的運行順序 + 拖曳即可重新排序。容器從上到下排列-首先放置網關(例如 OpenWRT),以便它在路由經過它的容器之前啟動。 + 所有容器均未啟用「啟動時運行」功能。 + 拖曳以重新排序 + 閘道器(虛擬路由器) + 網路閘道器 + 網路閘道器設定 From 310abb5b919141e909369ed35c34c209640373bb Mon Sep 17 00:00:00 2001 From: Yoshinobu Date Date: Tue, 16 Jun 2026 02:55:46 +0900 Subject: [PATCH 059/211] build: add Debian package artifact generation (#206) Add scripts/mkdeb.sh to package built Droidspaces binaries as Debian binary packages using dpkg-deb. The script maps the existing Droidspaces build architectures to Debian architecture names, installs the static binary under /usr/bin, and includes README/license documentation under /usr/share/doc/droidspaces. Update CI to install dpkg-dev, run the Debian package builder after the existing tarball build, upload .deb files as build artifacts, and include them in GitHub releases. This keeps packaging as a thin wrapper around the existing static musl build output and does not change the Makefile build flow. --- .github/workflows/ci.yml | 10 ++- .gitignore | 1 + scripts/mkdeb.sh | 153 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 3 deletions(-) create mode 100755 scripts/mkdeb.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0abec2f..40e48d4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,8 +69,10 @@ jobs: echo "[+] Toolchains ready." tree -L 3 $HOME/toolchains - - name: Build All Tarball & Sync to Android - run: make all-tarball + - name: Build All Tarball, Debian Packages & Sync to Android + run: | + make all-tarball + scripts/mkdeb.sh - name: Decode Keystore env: @@ -110,6 +112,7 @@ jobs: mkdir -p dist mv Android/app/build/outputs/apk/release/app-release.apk dist/${APK_NAME} mv ${TAR_NAME} dist/ + mv droidspaces_*.deb dist/ echo "APK_FILE=dist/${APK_NAME}" >> $GITHUB_ENV echo "TAR_FILE=dist/${TAR_NAME}" >> $GITHUB_ENV @@ -134,7 +137,7 @@ jobs: echo "EOF" } >> "$GITHUB_ENV" - - name: Upload Final Artifacts (APK & Tarball) + - name: Upload Final Artifacts (APK, Tarball & Debian Packages) uses: actions/upload-artifact@v4 with: name: droidspaces-artifacts @@ -211,3 +214,4 @@ jobs: files: | release-assets/*.apk release-assets/*.tar.gz + release-assets/*.deb diff --git a/.gitignore b/.gitignore index 8bdbb956..dbfe53da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.o *.tar.gz +*.deb output/ *.img *.patch diff --git a/scripts/mkdeb.sh b/scripts/mkdeb.sh new file mode 100755 index 00000000..e95eecdb --- /dev/null +++ b/scripts/mkdeb.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# mkdeb.sh - Debian package builder for Droidspaces +# Copyright (C) 2026 ravindu644 +# SPDX-License-Identifier: GPL-3.0-or-later + +set -e + +BINARY_NAME="droidspaces" +OUT_DIR="output" +PKG_RELEASE="1" + +usage() { + echo "Usage: $0 [arch ...]" + echo "" + echo "Architectures:" + echo " x86_64 (Debian: amd64)" + echo " aarch64 (Debian: arm64)" + echo " armhf (Debian: armhf)" + echo " x86 (Debian: i386)" + echo " riscv64 (Debian: riscv64)" + echo "" + echo "If no architecture is supplied, all built binaries in $OUT_DIR are packaged." + exit 1 +} + +deb_arch_for() { + case "$1" in + x86_64) echo "amd64" ;; + aarch64) echo "arm64" ;; + armhf) echo "armhf" ;; + x86) echo "i386" ;; + riscv64) echo "riscv64" ;; + *) return 1 ;; + esac +} + +detect_arch() { + local desc + desc=$(file "$1") + + case "$desc" in + *x86-64*) echo "x86_64" ;; + *ARM\ aarch64*) echo "aarch64" ;; + *RISC-V*) echo "riscv64" ;; + *Intel\ 80386*|*80386*|*i386*) echo "x86" ;; + *ARM*) echo "armhf" ;; + *) return 1 ;; + esac +} + +package_one() { + local arch="$1" + local binary="$2" + local deb_arch + local pkg_version + local pkg_name + local temp_dir + local pkg_root + local installed_size + + deb_arch=$(deb_arch_for "$arch") || { + echo "[-] Error: Unsupported architecture '$arch'" + exit 1 + } + + if [ ! -f "$binary" ]; then + echo "[-] Error: Binary not found: $binary" + exit 1 + fi + + pkg_version="${VERSION}-${PKG_RELEASE}" + pkg_name="${BINARY_NAME}_${pkg_version}_${deb_arch}.deb" + temp_dir=$(mktemp -d "/tmp/${BINARY_NAME}-deb-${arch}.XXXXXX") + pkg_root="$temp_dir/${BINARY_NAME}_${pkg_version}_${deb_arch}" + + mkdir -p "$pkg_root/DEBIAN" + install -Dm755 "$binary" "$pkg_root/usr/bin/$BINARY_NAME" + install -Dm644 "LICENSE" "$pkg_root/usr/share/doc/$BINARY_NAME/copyright" + install -Dm644 "README.md" "$pkg_root/usr/share/doc/$BINARY_NAME/README.md" + gzip -9n "$pkg_root/usr/share/doc/$BINARY_NAME/README.md" + + installed_size=$(du -sk "$pkg_root/usr" | awk '{print $1}') + + cat > "$pkg_root/DEBIAN/control" < +Installed-Size: $installed_size +Homepage: https://github.com/ravindu644/Droidspaces-OSS +Description: Lightweight Linux container runtime for Android and Linux + Droidspaces is a static, namespace-based container runtime with support for + full init systems such as systemd, OpenRC, runit, s6, and SysV init. +EOF_CONTROL + + chmod 0755 "$pkg_root/DEBIAN" + chmod 0644 "$pkg_root/DEBIAN/control" + + echo "[*] Creating $pkg_name..." + dpkg-deb --build --root-owner-group "$pkg_root" "$pkg_name" >/dev/null + rm -rf "$temp_dir" + echo "[+] Created: $pkg_name ($(du -h "$pkg_name" | cut -f1))" +} + +if ! command -v dpkg-deb >/dev/null 2>&1; then + echo "[-] Error: dpkg-deb not found. Please install dpkg." + exit 1 +fi + +VERSION=$(grep "DS_VERSION" "src/include/droidspace.h" | awk '{print $3}' | tr -d '"') +if [ -z "$VERSION" ]; then + echo "[-] Error: Could not determine Droidspaces version." + exit 1 +fi + +ARCHES=() +if [ "$#" -gt 0 ]; then + for arch in "$@"; do + case "$arch" in + -h|--help) usage ;; + *) ARCHES+=("$arch") ;; + esac + done +else + for arch in x86_64 aarch64 armhf x86 riscv64; do + if [ -f "$OUT_DIR/$BINARY_NAME-$arch" ]; then + ARCHES+=("$arch") + fi + done + + if [ "${#ARCHES[@]}" -eq 0 ] && [ -f "$OUT_DIR/$BINARY_NAME" ]; then + ARCHES+=("$(detect_arch "$OUT_DIR/$BINARY_NAME")") + fi +fi + +if [ "${#ARCHES[@]}" -eq 0 ]; then + echo "[-] Error: No built Droidspaces binaries found in $OUT_DIR." + echo "[!] Build first with: make native or make all-build" + exit 1 +fi + +for arch in "${ARCHES[@]}"; do + if [ -f "$OUT_DIR/$BINARY_NAME-$arch" ]; then + package_one "$arch" "$OUT_DIR/$BINARY_NAME-$arch" + elif [ "${#ARCHES[@]}" -eq 1 ] && [ -f "$OUT_DIR/$BINARY_NAME" ]; then + package_one "$arch" "$OUT_DIR/$BINARY_NAME" + else + echo "[-] Error: Binary not found: $OUT_DIR/$BINARY_NAME-$arch" + exit 1 + fi +done From 802c93392a12d5f82cbb3394f4dbc6a00c440e28 Mon Sep 17 00:00:00 2001 From: J0ker <163512031+da-ai-xian-zun@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:16:23 +0800 Subject: [PATCH 060/211] docs: add TB520FU (Yoga Tab Plus ROW ZUI 17.5.10.096) to community-supported-devices (#207) --- Documentation/community-supported-devices.md | 1 + Documentation/zh-CN/community-supported-devices.md | 1 + 2 files changed, 2 insertions(+) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 9a6a8316..9274cb42 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -225,6 +225,7 @@ This document is a community-maintained compatibility list for Android devices k | **OnePlus Pad Pro** | OPD2404 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Turbo 6** | PLU110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Turbo 6V** | PLY110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Yoga Tab Plus (International variant)** | TB520FU | ZUI 17.5.10.096 (ROW) - Android14 | `6.1.112` | SukiSU-Ultra v4.1.3 (40796) | [Source](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki) | [Download](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/releases/download/v1.0.0/tb520fu-droidspaces-phase2-images.zip) | Both | Turnip | Partial | [@da-ai-xian-zun](https://github.com/da-ai-xian-zun) | Maintainer path verified with **locked bootloader** via 9008 four-image manual flash (not AnyKernel3), see [MANUAL_FLASH.md](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/blob/main/docs/MANUAL_FLASH.md); verified on this ZUI build (ROW, `UKQ1.240826.001`) only—bundled `init_boot`/`vbmeta` are tied to this AVB chain; if bootloader is unlocked, use fastboot with your own `init_boot`/vbmeta for looser AVB constraints; use directory mode for containers; App sparse install unresolved; VirGL not tested | ## Contribution guidelines diff --git a/Documentation/zh-CN/community-supported-devices.md b/Documentation/zh-CN/community-supported-devices.md index 1e2d623b..b152aa83 100644 --- a/Documentation/zh-CN/community-supported-devices.md +++ b/Documentation/zh-CN/community-supported-devices.md @@ -178,6 +178,7 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | OnePlus Pad Pro | OPD2404 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Turbo 6 | PLU110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Turbo 6V | PLY110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| **Yoga Tab Plus(国际版)** | TB520FU | ZUI 17.5.10.096 (ROW) - Android 14 | `6.1.112` | SukiSU-Ultra v4.1.3 (40796) | [Source](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki) | [Download](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/releases/download/v1.0.0/tb520fu-droidspaces-phase2-images.zip) | Both | Turnip | 部分可用 | [@da-ai-xian-zun](https://github.com/da-ai-xian-zun) | 维护者路径为**不解锁 BL**下的 9008 四镜像刷入(非 AnyKernel3),见 [MANUAL_FLASH.md](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/blob/main/docs/MANUAL_FLASH.md);仅在该 ZUI 小版本(ROW,`UKQ1.240826.001`)验证,Release 内 `init_boot`/`vbmeta` 绑定本机构建 AVB 链,其他小版本或国行勿直接套用;若已解锁 BL 可自行 fastboot 刷写并自备 `init_boot`/vbmeta,AVB 链限制较宽松;容器请用目录模式;App sparse 安装未解决;Virgl 未测试 | ## 贡献指南 From 204e7e95e39a820be99db6ba8e4fa0e2bcf1609b Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Tue, 16 Jun 2026 11:17:09 +0200 Subject: [PATCH 061/211] Translated using Weblate (Chinese (Simplified Han script)) (#208) Currently translated at 100.0% (499 of 499 strings) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (499 of 499 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hant/ Translation: Droidspaces/Strings Co-authored-by: Jung Chi Wang Co-authored-by: MonkiFantasy --- .../src/main/res/values-zh-rCN/strings.xml | 13 +++++-- .../src/main/res/values-zh-rTW/strings.xml | 35 +++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index 0ca31ebd..0be64270 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -302,8 +302,8 @@ OpenRC 服务 OpenRC Init 系统 - Systemd/OpenRC 不可用 - 此容器未安装 systemd 或 OpenRC + Systemd/OpenWrt/OpenRC 不可用 + 此容器未安装 systemd、OpenWrt/procd 或 OpenRC 安装概要 刷新用户 屏蔽服务 @@ -338,7 +338,7 @@ 直接与宿主 Linux 内核交互。无需 Hypervisor,亦无虚拟机层。 由于设计层面而无法被终止 原生支持 init.rc 集成与双层用户空间守护进程。不会被轻易终止。 - 以 systemd 或 OpenRC 作为 PID 1 + 以 systemd / OpenWrt procd / OpenRC 作为 PID 1 支持完整的 Init 系统。具备真实的服务管理能力,并非简单的模拟环境。 完全隔离 为每个容器提供独立的 PID 树、挂载表、主机名、IPC 及 cgroup 层级。 @@ -492,4 +492,11 @@ 太长了 - 最多15个字符 桥接已被 \'%1$s\' 使用。请使用不同的局域网名称。 接口已被另一个段(\'%1$s\')使用。请使用其他接口。 + OpenWrt 服务 + 将在下一个补丁中加入 %1$s 的服务管理功能。 + 未知 + 重新加载服务 + 没有未知状态的服务 + 已检测到 OpenWrt/procd + OpenWrt diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index 77c881a2..a2f2d13e 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -264,8 +264,8 @@ OpenRC 服務 OpenRC Init 系統 - Systemd/OpenRC 不可用 - 此容器未安裝或執行 systemd 或 OpenRC + Systemd/OpenWrt/OpenRC 不可用 + 此容器未安裝 systemd 或 OpenWrt/OpenRC 需求檢測 主機名稱:%1$s 沒有root權限。 @@ -369,7 +369,7 @@ 直接與主機 Linux 核心通訊。無需虛擬機器管理程序,也無需虛擬機器層。 設計上即不可終止 支援原生 init.rc 整合和雙層使用者空間守護程式。完全無法終止。 - systemd / OpenRC 作為 PID 1 + systemd /OpenWrt,procd/ OpenRC 作為 PID 1 完整支援init系統。真正的服務管理,而非模擬環境。 完全隔離 每個容器擁有私有的 PID 樹、掛載表、主機名稱、IPC 和 cgroup 層次結構。 @@ -465,4 +465,33 @@ 閘道器(虛擬路由器) 網路閘道器 網路閘道器設定 + ds-lan + 設置網路閘道器 + 設置網路閘道器 + 這些設定控制此容器如何連接到網關的虛擬區域網路。預設設定適用於單一網關後的單一區域網路-僅在執行多個區域網路區段或多個閘道時才需要變更這些設定。 + 此連接的介面名稱顯示在網關容器內部(例如,OpenWRT 中的 eth1)。它必須與網關用於提供 DHCP 服務的介面名稱相符。同一網關上的每個獨立網段都需要有其唯一的名稱。預設值:eth1。 + 此 LAN 網段的標籤。共用相同網關和 LAN 名稱的容器將加入同一個虛擬交換機,彼此可以互相存取。使用不同的名稱可以啟動一個獨立的第二個網段。預設值:lan。 + 在 Android 主機上建立的虛擬交換器的名稱。留空則會根據 LAN 名稱自動命名 (ds-<lan>)。僅當需要精確的網橋名稱時才設定此項目。最多 15 個字元。 + 選擇網關容器 + 容器不能成為它本身的網關 + 此網關容器未安裝 + 安裝另一個容器作為網關 + 僅使用字母、數字、底線或連字符 + 字數過多 - 最多 15 個字符 + 網橋已被 \'%1$s\' 使用。請使用其他區域網路名稱。 + 介面已被其他區段 (\'%1$s\') 使用。請使用其他介面。 + OpenWrt 服務 + OpenWrt + 偵測到 OpenWrt/procd + 下一個補丁將新增對 %1$s 的服務管理。 + 未知 + 重載服務 + 沒有狀態未知的服務 + 將此容器的流量路由到另一個充當路由器的容器(例如 OpenWRT)。該網關必須先運作。 + 網關容器 + LAN名稱 + lan + 網關介面 + eth1 + 主機網橋 From 9d6b8612554fa8e13c07345963e0554eeb0603d4 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 17 Jun 2026 23:38:28 +0530 Subject: [PATCH 062/211] app: trim and collapse whitespace in container names Signed-off-by: ravindu644 --- .../app/ui/screen/ContainerNameScreen.kt | 16 +++++++++------- .../com/droidspaces/app/util/ValidationUtils.kt | 11 +++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt index 213c9f7e..09ffe99e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt @@ -59,18 +59,19 @@ fun ContainerNameScreen( // Check for duplicate name when container name changes (instant check using cached names) LaunchedEffect(containerName, existingContainerNames) { if (containerName.isNotBlank()) { - // Validate format first - val formatResult = ValidationUtils.validateContainerName(containerName, context) + // Validate format first (against the normalized name, which is what we store) + val normalizedName = ValidationUtils.normalizeContainerName(containerName) + val formatResult = ValidationUtils.validateContainerName(normalizedName, context) if (formatResult.isError) { nameError = formatResult.errorMessage } else { // Check for duplicate - instant check using cached names // Compare both exact names and sanitized names // This handles cases where user enters "My Container" and there's "My-Container" or vice versa - val sanitizedInput = ContainerManager.sanitizeContainerName(containerName) + val sanitizedInput = ContainerManager.sanitizeContainerName(normalizedName) val isDuplicate = existingContainerNames.any { existingName -> // Check exact match (case-insensitive) - existingName.equals(containerName, ignoreCase = true) || + existingName.equals(normalizedName, ignoreCase = true) || // Check sanitized match (handles spaces vs dashes) ContainerManager.sanitizeContainerName(existingName).equals(sanitizedInput, ignoreCase = true) } @@ -120,10 +121,11 @@ fun ContainerNameScreen( enabled = isNextValid, onClick = { clearFocus() - val nameResult = ValidationUtils.validateContainerName(containerName, context) - val hostnameResult = ValidationUtils.validateHostname(hostname.ifEmpty { ValidationUtils.sanitizeHostname(containerName) }, context) + val normalizedName = ValidationUtils.normalizeContainerName(containerName) + val nameResult = ValidationUtils.validateContainerName(normalizedName, context) + val hostnameResult = ValidationUtils.validateHostname(hostname.ifEmpty { ValidationUtils.sanitizeHostname(normalizedName) }, context) if (!nameResult.isError && !hostnameResult.isError) { - onNext(containerName, hostname.ifEmpty { ValidationUtils.sanitizeHostname(containerName) }) + onNext(normalizedName, hostname.ifEmpty { ValidationUtils.sanitizeHostname(normalizedName) }) } else { nameError = nameResult.errorMessage hostnameError = hostnameResult.errorMessage diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt index 2540bda2..e1fe893c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt @@ -10,6 +10,17 @@ import com.droidspaces.app.R object ValidationUtils { const val MAX_CONTAINER_NAME_LENGTH = 17 // 63 - len("/data/local/Droidspaces/Containers/") - len("/rootfs.img") + /** + * Normalizes a container name before it is stored or used to build paths. + * Trims leading/trailing whitespace and collapses internal whitespace runs to a + * single space, so that e.g. "alpine " -> "alpine" and "alpine container" -> + * "alpine container". This must run BEFORE sanitizeContainerName so the directory + * name, the config "name=" field, and the --name passed to the backend all agree. + */ + fun normalizeContainerName(name: String): String { + return name.trim().replace(Regex("\\s+"), " ") + } + /** * Validates container name: letters, numbers, hyphens, underscores, spaces, and dots allowed. */ From e31a047526ad4bf5dcc40d774fb3a8a9315ba743 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 17 Jun 2026 23:46:34 +0530 Subject: [PATCH 063/211] docs: document non-root GPU access for Turnip Add a note to the Turnip section (EN and zh-CN) explaining that non-root users must be added to the droidspaces-gpu group to access the GPU device nodes. Signed-off-by: ravindu644 --- Documentation/Graphics-and-Audio.md | 4 ++++ Documentation/zh-CN/Graphics-and-Audio.md | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/Documentation/Graphics-and-Audio.md b/Documentation/Graphics-and-Audio.md index 0db4dd3b..7def9206 100644 --- a/Documentation/Graphics-and-Audio.md +++ b/Documentation/Graphics-and-Audio.md @@ -164,6 +164,10 @@ Near-native hardware acceleration for **Qualcomm Adreno GPUs** using the open-so 4. Run a GUI application inside the container - Turnip GPU acceleration will be active. +> [!NOTE] +> +> **Permission Management (Non-Root Users):** If you are using a non-root user, you must grant them access to the GPU device nodes: `sudo usermod -aG droidspaces-gpu ` + --- diff --git a/Documentation/zh-CN/Graphics-and-Audio.md b/Documentation/zh-CN/Graphics-and-Audio.md index 242f0467..f813573d 100644 --- a/Documentation/zh-CN/Graphics-and-Audio.md +++ b/Documentation/zh-CN/Graphics-and-Audio.md @@ -164,6 +164,11 @@ Droidspaces 会自动启动 X 服务器和 VirGL 服务器,并将 `DISPLAY=:5` 4. 在容器内运行 GUI 程序 — Turnip GPU 加速将自动生效。 +> [!NOTE] +> +> **权限管理(非 root 用户):** 如果你使用的是非 root 用户,则必须授予其访问 GPU 设备节点的权限: +`sudo usermod -aG droidspaces-gpu ` + --- From 76cbd21ee29646d100852adfcbe9bcc1e9a39bd5 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 18 Jun 2026 00:30:37 +0530 Subject: [PATCH 064/211] app: center tab empty/error states above the floating bar The Containers and Control Panel tabs render their empty, root-unavailable, backend-error, and loading states centered over a content area that extends behind the opaque floating bottom bar, so they appeared lower than the visible center and shifted when switching tabs. Measure the floating bar's actual height in MainTabScreen and pass it down as a bottom inset, reserving that space so the centered states sit in the visible region. The measurement includes the system navigation inset, so it adapts to both gesture and 3-button navigation. Signed-off-by: ravindu644 --- .../app/ui/screen/ContainersScreen.kt | 15 ++++++++---- .../app/ui/screen/ControlPanelScreen.kt | 10 +++++--- .../app/ui/screen/MainTabScreen.kt | 23 +++++++++++++++---- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index 6be3914c..e25c3733 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.material3.SnackbarHost import com.droidspaces.app.ui.util.ProgressDialog @@ -82,7 +83,8 @@ fun ContainersScreen( onNavigateToContainerDetails: (String) -> Unit = {}, containerViewModel: ContainerViewModel, expandedContainerName: String?, - onExpandedContainerNameChange: (String?) -> Unit + onExpandedContainerNameChange: (String?) -> Unit, + emptyStateBottomInset: Dp = 0.dp ) { val scope = rememberCoroutineScope() val context = LocalContext.current @@ -597,15 +599,15 @@ fun ContainersScreen( // Show root unavailable state first, then backend unavailable, then content when { !isRootAvailable -> { - RootUnavailableState() + RootUnavailableState(modifier = Modifier.padding(bottom = emptyStateBottomInset)) } !isBackendAvailable -> { - ErrorState() + ErrorState(modifier = Modifier.padding(bottom = emptyStateBottomInset)) } containers.isEmpty() -> { if (containerViewModel.isRefreshing) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(bottom = emptyStateBottomInset), contentAlignment = Alignment.Center ) { LoadingIndicator(size = LoadingSize.Large) @@ -614,7 +616,10 @@ fun ContainersScreen( EmptyState( icon = Icons.Default.Storage, title = context.getString(R.string.no_containers_installed), - description = context.getString(R.string.install_container_description) + description = context.getString(R.string.install_container_description), + // Reserve the floating tab bar's space so the centered + // content sits in the visible region, not behind the bar. + modifier = Modifier.padding(bottom = emptyStateBottomInset) ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt index 2526681c..d308eef4 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.droidspaces.app.ui.component.EmptyState @@ -36,6 +37,7 @@ fun ControlPanelScreen( containerViewModel: ContainerViewModel, onNavigateToContainerDetails: (String) -> Unit = {}, onNavigateToTerminal: (String) -> Unit = {}, + emptyStateBottomInset: Dp = 0.dp, ) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } @@ -65,10 +67,10 @@ fun ControlPanelScreen( // Using when instead of early return to prevent UI glitches during recomposition when { !isRootAvailable -> { - RootUnavailableState() + RootUnavailableState(modifier = Modifier.padding(bottom = emptyStateBottomInset)) } !isBackendAvailable -> { - ErrorState() + ErrorState(modifier = Modifier.padding(bottom = emptyStateBottomInset)) } else -> { if (runningContainers.isEmpty()) { @@ -76,7 +78,9 @@ fun ControlPanelScreen( icon = Icons.Default.Dashboard, title = context.getString(R.string.no_containers_running), description = context.getString(R.string.start_container_first), - modifier = Modifier.padding(bottom = 120.dp) // Clear floating tab bar + // Reserve the floating tab bar's space so the centered + // content sits in the visible region, not behind the bar. + modifier = Modifier.padding(bottom = emptyStateBottomInset) ) } else { Column( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt index c0dd9069..a25770a9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt @@ -15,8 +15,11 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.activity.compose.BackHandler import androidx.lifecycle.viewmodel.compose.viewModel @@ -273,6 +276,11 @@ fun MainTabScreen( }, contentWindowInsets = WindowInsets(0) ) { innerPadding -> + val density = LocalDensity.current + // Measured height of the floating bottom bar (incl. system nav inset). + // Reserved as bottom space for centered empty states so they sit in the + // visible region above the bar instead of behind it. + var bottomBarHeight by remember { mutableStateOf(0.dp) } Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { Column( modifier = Modifier @@ -303,7 +311,8 @@ fun MainTabScreen( containerViewModel = containerViewModel, onRefresh = { performRefresh(TabItem.Containers) }, expandedContainerName = expandedContainerName, - onExpandedContainerNameChange = { expandedContainerName = it } + onExpandedContainerNameChange = { expandedContainerName = it }, + emptyStateBottomInset = bottomBarHeight ) } @@ -314,7 +323,8 @@ fun MainTabScreen( containerViewModel = containerViewModel, onRefresh = { performRefresh(TabItem.ControlPanel) }, onNavigateToContainerDetails = onNavigateToContainerDetails, - onNavigateToTerminal = onNavigateToTerminal + onNavigateToTerminal = onNavigateToTerminal, + emptyStateBottomInset = bottomBarHeight ) } } @@ -325,6 +335,7 @@ fun MainTabScreen( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() + .onSizeChanged { bottomBarHeight = with(density) { it.height.toDp() } } ) { MainBottomBar( selectedTab = selectedTab, @@ -499,7 +510,8 @@ private fun ContainersTabContent( containerViewModel: ContainerViewModel, onRefresh: suspend () -> Unit, expandedContainerName: String?, - onExpandedContainerNameChange: (String?) -> Unit + onExpandedContainerNameChange: (String?) -> Unit, + emptyStateBottomInset: Dp = 0.dp ) { PullToRefreshWrapper(onRefresh = { onRefresh() }) { ContainersScreen( @@ -510,7 +522,8 @@ private fun ContainersTabContent( onNavigateToContainerDetails = onNavigateToContainerDetails, containerViewModel = containerViewModel, expandedContainerName = expandedContainerName, - onExpandedContainerNameChange = onExpandedContainerNameChange + onExpandedContainerNameChange = onExpandedContainerNameChange, + emptyStateBottomInset = emptyStateBottomInset ) } } @@ -523,6 +536,7 @@ private fun ControlPanelTabContent( onRefresh: suspend () -> Unit, onNavigateToContainerDetails: (String) -> Unit, onNavigateToTerminal: (String) -> Unit, + emptyStateBottomInset: Dp = 0.dp ) { var refreshTrigger by remember { mutableStateOf(0) } @@ -536,6 +550,7 @@ private fun ControlPanelTabContent( containerViewModel = containerViewModel, onNavigateToContainerDetails = onNavigateToContainerDetails, onNavigateToTerminal = onNavigateToTerminal, + emptyStateBottomInset = emptyStateBottomInset ) } } From 5c7066f8587b75aaf56b0907f36b05d794efdb80 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 24 Jun 2026 15:08:54 +0530 Subject: [PATCH 065/211] selinux: fix permission denied when read/write mounted storage Fixes: https://github.com/ravindu644/Droidspaces-OSS/issues/213 Signed-off-by: ravindu644 --- Android/app/src/main/assets/boot-module/sepolicy.rule | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Android/app/src/main/assets/boot-module/sepolicy.rule b/Android/app/src/main/assets/boot-module/sepolicy.rule index e80d8656..efdcbcc2 100644 --- a/Android/app/src/main/assets/boot-module/sepolicy.rule +++ b/Android/app/src/main/assets/boot-module/sepolicy.rule @@ -71,3 +71,6 @@ allow audioserver droidspacesd binder { call transfer } allow audioserver droidspacesd fd use allow mediaserver droidspacesd binder { call transfer } allow mediaserver droidspacesd fd use + +# fix permission denied when read/write mounted storage (#213) +allow mediaprovider_app droidspacesd fd use From cbfcd4707f1af1ae7ba819b04b2b350be8e73ea9 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 24 Jun 2026 11:43:51 +0200 Subject: [PATCH 066/211] Translated using Weblate (Indonesian) (#212) Currently translated at 100.0% (499 of 499 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/id/ Translation: Droidspaces/Strings Co-authored-by: Arif Budiman --- .../app/src/main/res/values-in/strings.xml | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/Android/app/src/main/res/values-in/strings.xml b/Android/app/src/main/res/values-in/strings.xml index 03a515be..1b7d7025 100644 --- a/Android/app/src/main/res/values-in/strings.xml +++ b/Android/app/src/main/res/values-in/strings.xml @@ -219,8 +219,8 @@ Layanan OpenRC OpenRC Sistem Init - Systemd/OpenRC Tidak Tersedia - Container ini tidak memiliki systemd atau OpenRC yang terpasang + Systemd/OpenWrt/OpenRC Tidak Tersedia + Container ini tidak memiliki systemd, OpenWrt/procd, atau OpenRC yang terpasang Sedang memuat layanan... Mencari layanan... Aktif @@ -327,7 +327,7 @@ Berkomunikasi langsung dengan kernel Linux host. Tanpa hypervisor, tanpa lapisan mesin virtual. Tidak dapat dihentikan paksa Mendukung integrasi init.rc native dan daemon userspace 2 lapis. Sepenuhnya tidak dapat dihentikan paksa. - systemd / OpenRC sebagai PID 1 + systemd / OpenWrt procd / OpenRC sebagai PID 1 Dukungan sistem init penuh. Manajemen layanan nyata, bukan lingkungan simulasi. Isolasi lengkap Pohon PID, tabel mount, nama host, IPC, dan hierarki cgroup privat per container. @@ -444,4 +444,54 @@ URL tidak boleh kosong URL harus diawali dengan https:// Belum ada repositori kustom yang dilanggan. + Persyaratan Termux + VirGL + PulseAudio + Konfigurasikan PulseAudio + Aktifkan dukungan audio melalui PulseAudio + Flag Tambahan Termux:X11 + mis. -dpi 160 -force-sysvshm + Konfigurasikan Akselerasi 3D VirGL + Aktifkan akselerasi perangkat keras 3D melalui VirGL + Flag Tambahan VirGL + mis. --multi-clients --angle-vulkan + Prioritas Boot Otomatis + Urutan container yang dijalankan saat boot dimulai + Seret untuk menyusun ulang. Container dimulai dari atas ke bawah - letakkan gateway (mis. OpenWRT) di atas agar boot lebih dulu sebelum container yang merutekan lewatnya. + Tidak ada container yang mengaktifkan Jalankan saat Boot. + Seret untuk menyusun ulang + Host + Gateway (Router Virtual) + Gateway + Pengaturan Gateway + Rutekan container ini melalui container lain yang bertindak sebagai router (mis. OpenWRT). Gateway tersebut harus berjalan lebih dulu. + Container Gateway + Nama LAN + lan + Antarmuka di Gateway + eth1 + Bridge Host + ds-lan + Konfigurasikan Gateway + Konfigurasikan Gateway + Pengaturan ini mengontrol bagaimana container ini terhubung ke LAN virtual gateway. Nilai bawaan cocok untuk satu LAN di belakang satu gateway - ubah hanya saat menjalankan beberapa segmen LAN atau lebih dari satu gateway. + Nama antarmuka tempat koneksi ini muncul DI DALAM container gateway (mis. eth1 di OpenWRT). Harus cocok dengan antarmuka tempat gateway Anda melayani DHCP. Setiap segmen terpisah pada gateway yang sama memerlukan nama uniknya sendiri. Bawaan: eth1. + Label untuk segmen LAN ini. Container yang memakai gateway dan nama LAN yang sama akan bergabung ke virtual switch yang sama dan dapat saling melihat. Gunakan nama berbeda untuk membuat segmen kedua yang terisolasi. Bawaan: lan. + Nama virtual switch yang dibuat pada host Android. Biarkan kosong agar nama dibuat otomatis dari nama LAN (ds-<lan>). Isi hanya jika Anda memerlukan nama bridge yang persis. Maks. 15 karakter. + Pilih container gateway + Container tidak dapat menjadi gateway bagi dirinya sendiri + Container gateway ini tidak terpasang + Pasang container lain untuk bertindak sebagai gateway + Gunakan hanya huruf, angka, _ atau - + Terlalu panjang - maks. 15 karakter + Bridge sudah digunakan oleh \'%1$s\'. Gunakan nama LAN yang berbeda. + Antarmuka sudah digunakan oleh segmen lain (\'%1$s\'). Gunakan yang berbeda. + Hanya baca + Layanan OpenWrt + OpenWrt + OpenWrt/procd terdeteksi + Manajemen layanan untuk %1$s akan ditambahkan pada patch berikutnya. + Tidak diketahui + Muat ulang layanan + Tidak ada layanan dengan status tidak diketahui From 7a268e96e5ca6662abcfbfe0ee1207c92c2904ad Mon Sep 17 00:00:00 2001 From: GCross Date: Wed, 24 Jun 2026 20:58:21 +0800 Subject: [PATCH 067/211] update Simplify Chinese translation (#219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 微调 README_CN.md 与社区支持设备文档措辞 * 修复 GPU-Acceleration_CN 缺少 Rootfs 仓库引用链接 * 翻译 community-supported-devices_CN 新增的 WildKernels OnePlus 设备表格(96行) * 翻译 community-supported-devices_CN 中 none→无、Working→正常运行 * 翻译社区支持设备状态选项:正常运行/部分可用/不可用 * 修复社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 微调社区支持设备文档措辞 * 同步翻译 community-supported-devices.md 至与英文版严格一致 - 补齐 Wild Kernels OnePlus 缺失的 32+ 个设备/内核版本条目 - 新增 OnePlus ACE 6 Ultra 设备条目翻译 - 新增 OnePlus 7 Pro 设备条目翻译 - 修复 ACE 5 Racing Source URL 拼接错误 - 修复 2 处 Source URL 与英文版不匹配问题 - 严格逐行校对:EN 197 行 = CN 197 行,内核版本/Source URL 全部对应 * 翻译 Networking-From-Zero.md(从零开始的网络基础,656行) 全文中文化:IP 地址、LAN/WAN、DHCP、DNS、NAT、网桥、 veth 对、网络命名空间、OpenWRT、网关模式架构与配置。 * 修复 Networking-From-Zero_CN 标题锚点与 TOC 对齐 * 微调 Networking-From-Zero_CN 措辞 --- Documentation/zh-CN/Networking-From-Zero.md | 656 ++++++++++++++++++ .../zh-CN/community-supported-devices.md | 142 ++-- 2 files changed, 750 insertions(+), 48 deletions(-) create mode 100644 Documentation/zh-CN/Networking-From-Zero.md diff --git a/Documentation/zh-CN/Networking-From-Zero.md b/Documentation/zh-CN/Networking-From-Zero.md new file mode 100644 index 00000000..8e30eb0e --- /dev/null +++ b/Documentation/zh-CN/Networking-From-Zero.md @@ -0,0 +1,656 @@ + + +# 从零开始的网络基础:理解 Droidspaces 网关模式 + +### 快速导航 + +- [第一部分:绝对基础](#第一部分绝对基础) + - [什么是 IP 地址?](#什么是-ip-地址) + - [什么是网络?](#什么是网络) + - [LAN——局域网](#lan局域网) + - [WAN——广域网](#wan广域网) + - [网关](#网关) +- [第二部分:数据究竟是如何送达的](#第二部分数据究竟是如何送达的) + - [MAC 地址 vs IP 地址](#mac-地址-vs-ip-地址) + - [什么是数据包?](#什么是数据包) +- [第三部分:DHCP(你是如何获取 IP 地址的)](#第三部分dhcp你是如何获取-ip-地址的) +- [第四部分:DNS(域名如何变成地址)](#第四部分dns域名如何变成地址) +- [第五部分:NAT(你的路由器施展的魔法)](#第五部分nat你的路由器施展的魔法) + - [NAT 在 Droidspaces 中的应用](#nat-在-droidspaces-中的应用) +- [第六部分:网桥与虚拟线缆(Linux 管道)](#第六部分网桥与虚拟线缆linux-管道) + - [什么是网桥?](#什么是网桥) + - [什么是 veth 对?](#什么是-veth-对) + - [NAT 模式如何使用网桥和 veth](#nat-模式如何使用网桥和-veth) +- [第七部分:网络命名空间(容器是如何隔离的)](#第七部分网络命名空间容器是如何隔离的) +- [第八部分:OpenWRT 及其概念](#第八部分openwrt-及其概念) +- [第九部分:全新的网关模式——融会贯通](#第九部分全新的网关模式融会贯通) + - [为什么要有网关模式?](#为什么要有网关模式) + - [架构总览](#架构总览) + - [逐步说明——启动网关模式容器时会发生什么](#逐步说明启动网关模式容器时会发生什么) + - ["延迟挂接"的含义](#延迟挂接的含义) + - [为什么网关模式下不修改 resolv.conf](#为什么网关模式下不修改-resolvconf) + - [为什么 bridge-nf-call-iptables 要设为 0](#为什么-bridge-nf-call-iptables-要设为-0) + - [网关容器必须先运行](#网关容器必须先运行) + - [容器停止时会发生什么](#容器停止时会发生什么) +- [第十部分:网关模式的标志与配置](#第十部分网关模式的标志与配置) + - [必选标志 vs 可选标志](#必选标志-vs-可选标志) + - [--gateway-net 的作用](#--gateway-net-的作用) + - [--gateway-iface 的作用](#--gateway-iface-的作用) + - [必须避免的标志冲突](#必须避免的标志冲突) + - [验证规则与内核要求](#验证规则与内核要求) +- [第十一部分:所有网络模式对比](#第十一部分所有网络模式对比) +- [第十二部分:网关模式的真实使用场景](#第十二部分网关模式的真实使用场景) +- [快速参考——术语表](#快速参考术语表) + +--- + +## 第一部分:绝对基础 + +### 什么是 IP 地址? + +任何想要在网络上通信的设备都需要一个地址,这样其他设备才知道将数据发往何处。这个地址称为 **IP 地址**。 + +可以把它想象成家庭住址。如果有人想给你寄信,就需要你的地址。同理:如果你的手机想给 Google 发送数据,就需要知道 Google 的地址,而 Google 也需要知道你手机的地址才能将回复发回来。 + +IP 地址看起来像这样:`192.168.1.5` + +它是由点号分隔的四个数字(0-255)。每个数字称为一个 **八位组(octet)**。 + +### 什么是网络? + +**网络**就是一组可以相互直接通信的设备。 + +想象一个房间里有 5 台笔记本电脑都连接着同一个 Wi-Fi 路由器。这 5 台笔记本在同一个网络上——它们可以彼此传输文件,而不需要经过互联网。 + +### LAN——局域网 + +**LAN** = 你**家庭内部**的网络(或者办公室内部,或者在我们的场景中,是**容器世界内部**)。 + +之所以叫"局域网",是因为设备在物理上很近:你的手机、笔记本电脑、智能电视,都连接着你的家庭 Wi-Fi 路由器。它们都生活在同一个局域网内,可以直接相互通信。 + +局域网地址通常长这样: +- `192.168.x.x` +- `10.x.x.x` +- `172.16.x.x` 到 `172.31.x.x` + +这些称为**私有 IP 范围**。它们保留给本地网络使用,永远不会在公网上出现。 + +### WAN——广域网 + +**WAN** = 你**家庭之外的**网络:就是互联网本身。 + +你的路由器有两面: +- **LAN 侧**面向家里的设备 +- **WAN 侧**面向互联网服务提供商(ISP) + +ISP 会给你的路由器分配一个公网 IP 地址用于 WAN 侧。家庭内部的所有设备都共享这一个公网 IP 来访问互联网。 + +``` +[你的手机]--+ +[你的笔记本]-+--[路由器]--[ISP]--[互联网] +[你的电视]---+ + (LAN 侧) (WAN 侧) +``` + +### 网关 + +**网关**是将两个不同的网络连接在一起的设备。 + +在你的家里,路由器*就是*网关。你手机的 IP 是 `192.168.1.5`(LAN)。当你的手机想要访问 Google 的 `142.250.80.46`(WAN/互联网),它并不知道如何直接到达那里。于是它将数据发送给网关(路由器),由路由器想办法将其转发到互联网。 + +**规则:** 局域网上的每台设备都配置了一个"默认网关"——当设备不知道往哪发送数据包的时候,所有流量都会发送到的那个地址。 + +--- + +## 第二部分:数据究竟是如何送达的 + +### MAC 地址 vs IP 地址 + +实际上网络中有*两种*地址: + +| 类型 | 外观 | 用途 | +|---|---|---| +| **IP 地址** | `192.168.1.5` | 逻辑地址——用于跨网络路由 | +| **MAC 地址** | `a4:c3:f0:12:34:56` | 物理地址——用于在**同一**网络上传递 | + +可以这样理解: +- IP 地址是**城市和街道**——用于跨国家的导航 +- MAC 地址是**门牌号**——到达大楼后使用的最终定位 + +当你的笔记本电脑向路由器发送数据包时,会使用路由器的 MAC 地址(因为它们在同一局域网上)。然后路由器使用 IP 地址来确定下一步发送到哪里。 + +### 什么是数据包? + +在网络上传输的数据被分割成小块,称为**数据包**。每个数据包包含: +- 它从哪里来(源 IP) +- 它要去哪里(目的 IP) +- 实际数据的一小部分 + +网络在目的地将所有数据包重新组装起来。 + +--- + +## 第三部分:DHCP(你是如何获取 IP 地址的) + +### 问题 + +每台设备都需要一个 IP 地址才能加入网络。但不能有两台设备使用相同的 IP——就像两个房子有同样的邮政地址一样,信件会丢失。 + +你*可以*手动为每台设备分配唯一的 IP,但那会很痛苦。如果有 50 台设备怎么办? + +### 解决方案:DHCP + +**DHCP** = 动态主机配置协议 + +它是一种由一个设备(**DHCP 服务器**)自动为每个加入网络的新设备分发 IP 地址的系统。 + +对话过程如下: + +``` +新设备: "有人在吗?我刚加入这个网络,需要一个 IP 地址。" +DHCP 服务器: "我听到了。来,192.168.1.42拿着。另外,你的网关是 192.168.1.1, + DNS 使用 1.1.1.1。你的租约持续 24 小时。" +新设备: "收到,谢谢!" +``` + +新设备现在拥有了在网络中工作所需的一切: +- 自己的 IP 地址 +- 网关地址(知道将流量发往何处) +- DNS 地址(接下来会解释) + +在你家里,**路由器运行着 DHCP 服务器**。它为每一台连接的设备分配 IP。 + +在 **Droidspaces NAT 模式**中,Droidspaces 自身会运行一个迷你 DHCP 服务器,为容器分配 IP(在 `172.28.x.x` 范围内)。IP 是确定性的:它由容器名称派生而来,持久化到配置文件中,并且在每次启动时重新提供——因此同一个容器在重启后始终拥有相同的地址。 + +--- + +## 第四部分:DNS(域名如何变成地址) + +### 问题 + +IP 地址很难记住。没有人会输入 `142.250.80.46` 来访问 Google。人们都是输入 `google.com`。 + +但计算机只理解 IP 地址。所以需要一个系统将人类可读的名称转换成 IP 地址。 + +### 解决方案:DNS + +**DNS** = 域名系统 + +它基本上是互联网的电话簿。你给它一个名称(`google.com`),它返回一个 IP 地址(`142.250.80.46`)。 + +对话过程: +``` +你的浏览器: "google.com 的 IP 地址是什么?" +DNS 服务器: "是 142.250.80.46" +你的浏览器: "谢了。" [现在连接到 142.250.80.46] +``` + +每台设备都配置了一个 DNS 服务器地址。在大多数家庭网络中,路由器*就*是 DNS 服务器(它将你的查询转发给 ISP 的 DNS 或公共 DNS,如 `1.1.1.1`)。 + +在 **Droidspaces NAT 模式**中,Droidspaces 会在容器内写入 `resolv.conf` 文件,指向 DNS 服务器(默认 `1.1.1.1` 和 `8.8.8.8`,或通过 `--dns` 指定的地址)。同样的 DNS 服务器也会在 DHCP 租约中通告。 + +--- + +## 第五部分:NAT(你的路由器施展的魔法) + +### 问题 + +ISP 只给你**一个**公网 IP 地址。但你家有 10 台设备。这 10 台设备如何同时使用互联网? + +### 解决方案:NAT + +**NAT** = 网络地址转换 + +你的路由器维护着一张秘密表格。当局域网内的一台设备向互联网发送数据包时,路由器会: +1. 将源 IP 从设备的私有 IP(`192.168.1.5`)改写为路由器的公网 IP +2. 记住是哪台设备发送的 +3. 当回复从互联网返回时,将目的地址改写回设备的私有 IP 并转发给它 + +从互联网的视角看,你所有的家庭设备看起来都是*一个设备*:就是路由器。 + +``` +[笔记本: 192.168.1.5] --发送数据包--> [路由器] + | + | 将源 IP 改写为公网 IP + v + [互联网] + | + | 回复回来了 + v + [路由器] + | + | 将目的 IP 改写回 192.168.1.5 + v + [笔记本: 192.168.1.5] +``` + +### NAT 在 Droidspaces 中的应用 + +在 Droidspaces NAT 模式中,Droidspaces 的行为*和你的家庭路由器完全一样*——不过是面向容器: + +- 容器获得一个私有 IP(`172.28.x.x`) +- Droidspaces 安装 iptables `MASQUERADE` 规则(这是 Linux 中实现 NAT 目标的名称),外加 FORWARD 接受规则和 MSS 钳制规则,确保流量真正流通 +- 容器可以访问互联网;互联网看到的是 Android 的 IP,而不是容器的 IP +- Droidspaces 还会为容器运行一个嵌入式 DHCP 服务器并配置其 DNS +- 在 Android 上,后台路由监视器会自动检测活跃的互联网上行链路(通过读取内核的路由规则),并在活跃网络切换时(例如 Wi-Fi 到移动数据切换)立即将容器流量重新指向正确的接口 + +--- + +## 第六部分:网桥与虚拟线缆(Linux 管道) + +现在我们深入一层,看看 Linux 究竟是如何将容器彼此连接起来的。 + +### 什么是网桥? + +**网桥**的工作原理类似于网络交换机。物理网络交换机是一个盒子,可以插入多根以太网线缆;所有连接到它的设备都可以相互通信。 + +Linux 的**网桥**就是一个虚拟交换机,完全由软件实现。你可以用一条命令创建它,然后将虚拟网络接口"插入"其中。 + +``` +物理世界: Linux 世界: ++--------------+ +--------------+ +| 交换机 | | 网桥 | (纯软件,没有物理盒子) +| 端口1 端口2 | | 端口1 端口2 | ++--+------+---+ +--+------+---+ + | | | | +[PC1] [PC2] [veth1] [veth2] (虚拟线缆) +``` + +### 什么是 veth 对? + +**veth** = 虚拟以太网 + +veth 对是一对互相连接的虚拟网络接口,就像一根管道。你从一端发送的任何东西都会从另一端出来。 + +可以把它想象成一根有两端插头的虚拟以太网线缆。你把一端插在容器内部,另一端留在宿主机上(或插入一个网桥)。 + +``` +[容器 netns] [宿主机 netns] + eth0 --------------------- ds-veth0 + (容器内的插头) (宿主机侧的插头) +``` + +### NAT 模式如何使用网桥和 veth + +在 Droidspaces NAT 模式中: + +``` +[容器 netns] + eth0(例如 172.28.137.42) + | + | veth 对(虚拟线缆) + | +[宿主机侧] + ds-v ---- ds-br0(网桥,IP 为 172.28.0.1) + | + iptables MASQUERADE + | + wlan0 / rmnet0 + (Android 的真实网络) +``` + +网桥 `ds-br0` 拥有网关 IP `172.28.0.1`,每个 NAT 容器都将其作为默认网关使用。veth 对以容器的 init 进程 ID 命名:宿主机侧为 `ds-v`,容器侧初始为 `ds-p`,然后在容器内重命名为 `eth0`。 + +Droidspaces 运行一个小型的给每个容器开放的 DHCP 服务器,监听在容器的宿主机侧 veth 上。整个 `172.28.0.0/16` 子网属于 Droidspaces(`172.28.0.x` 段保留给网关自身,所以容器始终落在 `172.28.1.x` 到 `172.28.254.x` 之间),其中所有内容通过 Android 的真实接口进行 NAT 出站。 + +--- + +## 第七部分:网络命名空间(容器是如何隔离的) + +### 什么是命名空间? + +Linux 有一个名为**命名空间**的功能,可以让你创建系统资源的隔离视图。 + +**网络命名空间**是整个网络栈的一份隔离副本。它拥有自己的: +- 网络接口 +- 路由表 +- iptables 规则 +- 与网络相关的一切 + +当 Droidspaces 启动一个容器时,会为它创建一个新的网络命名空间。容器住在该命名空间中。它完全看不到宿主机的网络接口——只能看到 Droidspaces 显式放入其命名空间内的东西。 + +veth 对是宿主机命名空间和容器命名空间之间的隧道: +- veth 的一端进入容器的网络命名空间(显示为 `eth0`) +- 另一端留在宿主机的网络命名空间中(Droidspaces 将其连接到网桥) + +--- + +## 第八部分:OpenWRT 及其概念 + +### 什么是 OpenWRT? + +**OpenWRT** 是专为路由器设计的 Linux 发行版。通常情况下它运行在物理路由器硬件上,但也可以在常规 Linux 系统或容器内运行。 + +当 OpenWRT 运行时,它提供: +- **netifd**——网络接口守护进程(管理网络接口、DHCP 客户端/服务器等) +- **dnsmasq**——DNS 和 DHCP 服务器 +- **firewall3** 或 **nftables**——防火墙 +- **LuCI**——配置用的 Web 界面 +- 真正路由器所做的一切,以软件形式实现 + +这意味着你可以在 Droidspaces 容器内运行 OpenWRT,它会表现得和真正的路由器一模一样:管理网络、分发 DHCP 租约、处理 DNS、应用防火墙规则、路由 VPN 流量等。 + +--- + +## 第九部分:全新的网关模式——融会贯通 + +### 为什么要有网关模式? + +在 NAT 模式中,Droidspaces 就是路由器。它包办一切。这在大多数情况下都很好。 + +但如果你想让 **OpenWRT 成为其他容器的路由器**呢?你想要 OpenWRT 的防火墙规则、OpenWRT 的 DHCP、OpenWRT 的 VPN 路由,并让其他容器(比如 Kali Linux 容器)处在 OpenWRT 的局域网内,一切从 OpenWRT 获取。 + +问题在于:如果 Droidspaces 也试图为这些容器安装 NAT、DHCP 和 DNS,就会与 OpenWRT 正在做的事情*冲突*。两个 DHCP 服务器争抢由谁分配 IP 地址。两个防火墙应用互相矛盾的规则。 + +**网关模式解决了这个问题。** Droidspaces 退后一步。它只做 L2 的管道(虚拟线缆和交换机),让 OpenWRT 掌管所有策略:DHCP、DNS、防火墙、路由。 + +### 架构总览 + +``` +Android 宿主机内核 +| ++-- wlan0(Android 的真实 Wi-Fi——WAN) +| ++-- [OpenWRT 容器——net=nat 模式] +| netns:拥有 eth0(WAN 侧,由 Droidspaces 提供 NAT) +| eth1(LAN 侧——由网关模式插入 ds-lan 网桥) +| 运行:dnsmasq、netifd、防火墙、VPN +| ++-- ds-lan(宿主机网桥——无 IP 地址,仅作为交换机) +| | +| +-- ds-g[hash](veth 宿主机侧,连接到 OpenWRT 的 netns 作为 eth1) +| +-- ds-v[pid] (veth 宿主机侧,连接到 Kali 的 netns 作为 eth0) +| ++-- [Kali 容器——net=gateway 模式] + netns:拥有 eth0(LAN 侧——插入 ds-lan 网桥) + 从 OpenWRT 的 dnsmasq 获取 DHCP + 路由决策由 OpenWRT 做出 + 防火墙规则由 OpenWRT 应用 +``` + +### 逐步说明——启动网关模式容器时发生了什么 + +**第 1 步——先启动 OpenWRT(NAT 模式)** + +```bash +droidspaces --name=openwrt --rootfs=/data/openwrt --net=nat start +``` + +OpenWRT 启动。它拥有: +- WAN 侧的 `eth0`(Droidspaces 为其管理 NAT) +- 还没有 LAN 侧——OpenWRT 正在等待 + +**第 2 步——启动 Kali(网关模式)** + +```bash +droidspaces --name=kali --rootfs=/data/kali --net=gateway --gateway=openwrt start +``` + +Droidspaces 执行以下操作(仅管道,无策略): + +1. 找到 OpenWRT 正在运行的进程 ID,以便访问其网络命名空间 +2. 在宿主机上创建一个名为 `ds-lan` 的网桥,该网桥上不设置 IP 地址 +3. 禁用 `bridge-nf-call-iptables`,使 Android 宿主机防火墙**不**拦截该网桥上的流量,让 OpenWRT 的防火墙成为唯一的权威 +4. 为 OpenWRT 的 LAN 侧创建一个 veth 对——一端进入 OpenWRT 的 netns(显示为 `eth1`),另一端插入 `ds-lan` 网桥 +5. 为 Kali 创建一个 veth 对——一端进入 Kali 的 netns(显示为 `eth0`),另一端插入 `ds-lan` 网桥 +6. **不**安装 NAT、DHCP、DNS 或任何防火墙规则 + +**第 3 步——OpenWRT 接管** + +OpenWRT 的 `netifd` 检测到 `eth1` 出现。它将其配置为 LAN 接口。 +OpenWRT 的 `dnsmasq` 开始在 `eth1` 上响应 DHCP 请求。 + +Kali 的 `eth0` 发送 DHCP 请求,OpenWRT 的 `dnsmasq` 回复: +- IP 地址:`192.168.1.100`(或 OpenWRT DHCP 范围内的任意 IP) +- 网关:`192.168.1.1`(即 OpenWRT 本身) +- DNS:`192.168.1.1`(OpenWRT 的 dnsmasq) + +Kali 现在已完全配置好,以 OpenWRT 作为其路由器。 + +**第 4 步——流量经过 OpenWRT** + +当 Kali 尝试访问互联网时: + +``` +Kali eth0 --> ds-lan 网桥 --> OpenWRT eth1 + | + OpenWRT 防火墙规则在此应用 + | + OpenWRT 路由到 eth0(WAN) + | + Droidspaces NAT(eth0 -> wlan0) + | + Android wlan0 --> 互联网 +``` + +OpenWRT 的防火墙看到 Kali 的全部流量,并可以应用任何规则:阻止特定网站、通过 VPN 重定向、带宽整形、连接日志——完全和真正的路由器一样。 + +### "延迟挂接"的含义 + +网关 veth 是"延迟挂接"的。这意味着: + +- 当你启动 OpenWRT 时,它**不会**立即获得 `eth1` +- `eth1` 仅在**第一个网关模式容器启动时**才会出现在 OpenWRT 内部 +- 这是有意为之——OpenWRT 只带着它的 WAN 侧启动(`eth0`),其 LAN 线缆(`eth1`)稍后按需插入 + +这模拟了你在路由器运行后,再将物理线缆插入路由器 LAN 端口的场景。 + +### 为什么网关模式下不修改 resolv.conf + +在 NAT 模式中,Droidspaces 会在容器内写入 `/etc/resolv.conf`,指向 `1.1.1.1` 或 `8.8.8.8`。 + +在网关模式中,Droidspaces **不**写入 `resolv.conf`(除非你显式传入 `--dns`)。这是因为 OpenWRT 的 `dnsmasq` 会通过 DHCP 租约将 DNS 服务器地址分发给容器。如果 Droidspaces 也写入了 `resolv.conf`,就会与 dnsmasq 提供的内容冲突——容器将使用错误的 DNS,完全绕过 OpenWRT 的 DNS 过滤/缓存。 + +### 为什么 bridge-nf-call-iptables 要设为 0 + +网桥 `ds-lan` 承载着 OpenWRT 和 Kali 之间的流量。默认情况下,Linux 可以将桥接流量通过宿主机的 iptables 处理。这意味着 Android 的 iptables 规则(可能会意外丢弃或 NAT 某些流量)会干扰本该由 OpenWRT 管理的流量。 + +将其设为 `0` 告诉 Linux:"不对桥接流量运行 iptables。"这让 OpenWRT 的防火墙成为查看此流量的*唯一*防火墙——这正是我们想要的效果。 + +### 网关容器必须先运行 + +启动顺序很重要。当一个网关模式容器启动时,Droidspaces 会查找网关容器的实时进程 ID 以访问其网络命名空间。如果此时网关容器没有运行,网络设置会失败并发出警告,客户端容器依然会启动——但完全没有网络(只有 loopback)。它不会自行重试。 + +同样的逻辑也适用于网关重启后。veth 对是一起死亡的:当网关容器停止时,它内部的 `eth1` 端被销毁,这也会销毁宿主机侧的那一端。现有的客户端仍然插在网桥上,但已经没有了路由器。LAN 线缆会在该网段上**任何**网关模式容器的下一次启动时重新插入——所以在重启网关后,重启一个客户端(或启动一个新客户端)即可让网段恢复。 + +### 容器停止时会发生什么 + +网关模式中的清理被刻意最小化,遵循"仅管道"的理念: + +- **客户端停止:** 仅移除该客户端自己的 veth(`ds-v`)。网桥和网关的 `eth1` 保持运行,因此同一网段上的其他客户端不受影响。 +- **网关停止:** 网关侧的 veth 随其命名空间一起消失(见上文),但网桥本身保留。 +- 委托网桥(`ds-lan` 等)永远不会被 Droidspaces 拆除。它在闲置时无害——没有 IP 也不承载任何策略——并持续存在直到你手动删除它或重启设备。 + +--- + +## 第十部分:网关模式的标志与配置 + +### 必选标志 vs 可选标志 + +使用 `--net=gateway` 时**只有一个标志是必选的**: + +```bash +--gateway= +``` + +如果省略它,Droidspaces 会打印错误并拒绝启动。其他所有标志都有可用的默认值: + +| 标志 | 默认值 | 它控制什么 | +|---|---|---| +| `--gateway=NAME` | *(无——必选)* | 哪个运行中的容器是路由器 | +| `--gateway-net=NAME` | `lan` | LAN 网段名称——见下文 | +| `--gateway-iface=IFACE` | `eth1` | 网关容器内的接口名称 | +| `--gateway-bridge=BR` | `ds-{gateway-net}` | 完全覆盖宿主机网桥名称 | + +因此最小有效命令是: + +```bash +droidspaces --name=client --net=gateway --gateway=openwrt start +``` + +这与显式拼出所有默认值完全相同: + +```bash +droidspaces --name=client --net=gateway --gateway=openwrt \ + --gateway-net=lan \ + --gateway-iface=eth1 \ + start +``` + +### --gateway-net 的作用 + +该标志同时控制两件事,均从同一个名称派生。 + +**1. 它命名宿主机网桥。** + +Droidspaces 在宿主机上创建的网桥命名为 `ds-{NAME}`: + +``` +--gateway-net=lan -> 宿主机网桥:ds-lan +--gateway-net=vpn -> 宿主机网桥:ds-vpn +--gateway-net=iot -> 宿主机网桥:ds-iot +``` + +**2. 它是网段标识符——客户端落在哪个网桥上。** + +网关 LAN 侧的 veth 名称是通过对字符串 `{gateway_container}:{gateway_net}` 进行哈希来生成的。相同的哈希 = 相同的 veth = 相同的网桥网段。这意味着共享相同 `--gateway` 和 `--gateway-net` 的多个客户端容器最终都在同一个网桥上,并且都从同一个 OpenWRT 接口获取 DHCP。 + +这就是 `--gateway-net` 真正的威力所在:通过同一个网关容器运行多个隔离的 LAN 网段。 + +```bash +# 这两个落在 ds-lan 上——它们彼此可见,OpenWRT 将它们作为一个局域网路由 +droidspaces --name=kali --net=gateway --gateway=openwrt --gateway-net=lan start +droidspaces --name=ubuntu --net=gateway --gateway=openwrt --gateway-net=lan start + +# 这一个落在 ds-vpn 上——一个完全独立的网桥 +# OpenWRT 可以对这个网段应用不同的防火墙/VPN 规则 +droidspaces --name=torbox --net=gateway --gateway=openwrt --gateway-net=vpn start +``` + +在 OpenWRT 内部,`lan` 客户端通过 `eth1` 传入,`vpn` 客户端通过 `eth2` 传入(每个网段获得自己的 veth,因为 `openwrt:lan` 和 `openwrt:vpn` 的哈希不同)。 + +### --gateway-iface 的作用 + +这控制了**LAN 接口在网关容器的网络命名空间内部使用的名称**。 + +当 Droidspaces 为一个网段创建网关 veth 时,它会将一端移入 OpenWRT 的 netns,并将其从原始的哈希名称(`ds-hXXXXXXXX`)重命名为你在此传入的名称(默认 `eth1`)。 + +**为什么要这么做?** OpenWRT 的配置是围绕接口名称构建的。如果你的 OpenWRT `/etc/config/network` 写着: + +``` +config interface 'lan' + option device 'eth1' +``` + +...那么出现在 OpenWRT 内部的接口**必须**命名为 `eth1`,否则 OpenWRT 不会将其识别为 LAN,也不会在上面提供 DHCP。`--gateway-iface=eth1` 确保了这一点。 + +对于第二个网段,你需要传入 `--gateway-iface=eth2`,这样 OpenWRT 会将其视为一个独立的接口,你可以为其添加第二个 UCI network 配置块。 + +**重要细节:** `--gateway-iface` 仅在该网段的网关 veth 首次创建时生效——也就是该网段上第一个客户端容器启动时。网关 veth 由同一个 `--gateway-net` 上的所有客户端共享;它只创建一次并重复使用。第一个之后的每个客户端完全跳过网关 veth 的创建,仅仅将自己的应用 veth 接入现有的网桥。 + +这意味着如果你在 `--gateway-net=lan` 上启动两个容器,且两个都传入 `--gateway-iface=eth1`,完全没有问题:第一个容器创建 veth 并重命名为 `eth1`,第二个容器发现 veth 已经存在,根本不处理 `--gateway-iface`。 + +### 必须避免的标志冲突 + +问题只会在你使用**两个不同的 `--gateway-net` 网段但相同的 `--gateway-iface`** 时出现: + +```bash +# 网段 1——在 OpenWRT 内部创建 eth1 +droidspaces --name=kali --net=gateway --gateway=openwrt --gateway-net=lan --gateway-iface=eth1 start + +# 网段 2——错误:也尝试在 OpenWRT 内部创建 eth1 +droidspaces --name=torbox --net=gateway --gateway=openwrt --gateway-net=vpn --gateway-iface=eth1 start +``` + +当第二条命令运行时,Droidspaces 尝试将一个新的 veth 对端移入 OpenWRT 并重命名为 `eth1`。但 `eth1` 已经存在于 OpenWRT 内部(来自第一个网段)。代码不会直接报错,而是检测到冲突后仅将现有的 `eth1` 重新启用,让新的 veth 对端以其原始哈希名称(`ds-hYYYYYYYY`)留在 OpenWRT 中。OpenWRT 没有 `ds-hYYYYYYYY` 的配置,会静默忽略它。`vpn` 网段得不到网关侧接口:没有 DHCP、没有路由,其上的容器实际上处于隔离状态。 + +**规则:** 每个 `--gateway-net` 网段必须有唯一的 `--gateway-iface` 名称。 + +```bash +# 正确:两个网段,两个接口名称 +--gateway-net=lan --gateway-iface=eth1 -> OpenWRT 内的 eth1(LAN 网段) +--gateway-net=vpn --gateway-iface=eth2 -> OpenWRT 内的 eth2(VPN 网段) +``` + +### 验证规则与内核要求 + +Droidspaces 在启动时强制执行几条规则,违规则拒绝启动: + +- 容器不能将自己作为自己的网关(`--gateway` 必须命名一个不同的容器) +- 接口和网桥名称必须短于 16 个字符(Linux `IFNAMSIZ` 限制),且只能包含字母、数字、`_` 和 `-` +- 内核必须支持网络命名空间(`CONFIG_NET_NS`)、veth 对(`CONFIG_VETH`)和网桥(`CONFIG_BRIDGE`)。Droidspaces 在启动前检查这三项,缺少任一则致命错误退出 + +另外还有两点需要了解: + +- `--port` 仅在 NAT 模式中有意义。在网关模式中,它会被忽略并发出警告——端口转发和上行链路选择现在是网关容器的工作。 +- 当宿主机网桥名称从 `--gateway-net` 自动派生时,名称会被清理(仅保留字母、数字、`_`、`-`)并截断为 9 个字符,生成 `ds-` 加上最多 9 个字符。如果需要精确的网桥名称,请使用 `--gateway-bridge` 显式设定。 + +--- + +## 第十一部分:所有网络模式对比 + +| 特性 | NAT 模式 | Host 模式 | None 模式 | 网关模式 | +|---|---|---|---|---| +| 谁分配 IP? | Droidspaces DHCP | Android(共享) | 无(仅 loopback) | OpenWRT dnsmasq | +| 谁做 NAT? | Droidspaces iptables | Android | 不适用 | OpenWRT(通过 Droidspaces 为 OpenWRT 的 WAN 做 NAT) | +| 谁管理防火墙? | Droidspaces | Android | 不适用 | OpenWRT | +| 谁管理 DNS? | Droidspaces | Android | 无 | OpenWRT dnsmasq | +| 容器与宿主机网络隔离? | 是 | 否 | 是 | 是 | +| 互联网访问? | 是 | 是 | 否 | 是(通过网关容器) | +| 需要第二个容器才能工作? | 否 | 否 | 否 | 是(网关容器) | +| 适用于 | 简单的互联网访问 | 最大性能,零开销 | 离线 / 沙盒环境 | 路由器设备、VPN 网关、分段局域网 | + +--- + +## 第十二部分:网关模式的真实使用场景 + +### 1. 为特定容器设置 VPN 终止开关 + +运行带有 WireGuard 或 OpenVPN 客户端的 OpenWRT。配置 OpenWRT 的防火墙丢弃所有不经过 VPN 隧道的流量。使用网关模式的任何容器都无法将流量泄漏到 VPN 之外——OpenWRT 在网桥层面强制实施这一点,而不是在各个容器内部。 + +### 2. 多个隔离的 LAN 网段 + +使用 `--gateway-net` 在同一个 OpenWRT 上创建独立的网段。`--gateway-net=lan` 上的容器无法访问 `--gateway-net=vpn` 上的容器,除非 OpenWRT 显式地在它们之间进行路由。你可以通过一个网关容器实现类 VLAN(虚拟局域网)风格的隔离。 + +### 3. 流量分析 + +运行带有 `tcpdump` 或 `nftables` 日志功能的 OpenWRT。来自每个网关模式容器的每个数据包都流经 OpenWRT,因此你获得了一个单一的咽喉点,可以同时观察所有容器的全部网络活动。 + +### 4. 自定义 DNS 过滤 + +运行带有 `dnsmasq` 黑名单(或通过 opkg 安装 Adblock)的 OpenWRT。网关局域网上的每个容器都会获得经过过滤的 DNS,而无需单独触碰每个容器。 + +### 5. 带宽整形 + +OpenWRT 的 `tc`(流量控制)和 `sqm-scripts` 可以按容器进行带宽整形,因为 OpenWRT 将每个容器视为到达其 LAN 接口的一个独立 MAC 地址。 + +--- + +## 快速参考——术语表 + +| 术语 | 一句话定义 | +|---|---| +| **IP 地址** | 网络上设备的数字地址(例如 `192.168.1.5`) | +| **MAC 地址** | 网络接口的硬件地址,用于同一网络内的传递 | +| **LAN** | 局域网——彼此靠近、可以直接通信的设备 | +| **WAN** | 广域网——互联网,本地网络之外的部分 | +| **网关** | 连接两个网络并在它们之间路由流量的设备 | +| **DHCP** | 自动为设备分配 IP 地址的协议 | +| **DNS** | 将域名(`google.com`)转换为 IP 地址的系统 | +| **NAT** | 将一个公网 IP 在多个私有 IP 设备之间共享的技术 | +| **网桥** | 连接多个网络接口的虚拟(或物理)交换机 | +| **veth 对** | 一对像管道一样连接的虚拟网络接口——从一端进,从另一端出 | +| **网络命名空间** | Linux 网络栈的隔离副本——容器生活在自己的命名空间中 | +| **OpenWRT** | 一个为作为路由器/网关运行而设计的 Linux 发行版——运行 dnsmasq、netifd、防火墙 | +| **netifd** | OpenWRT 的网络接口守护进程——管理接口和 DHCP | +| **dnsmasq** | OpenWRT 使用的轻量级 DHCP 和 DNS 服务器 | +| **MASQUERADE** | 实现 NAT 的 Linux iptables 规则(改写源 IP) | +| **委托 LAN** | Droidspaces 在网关模式中创建的网桥网络——策略由网关容器而非 Droidspaces 掌管 | +| **网段** | 由 `--gateway-net` 标识的一个隔离 LAN——每个网段获得自己的网桥和在网关容器内的独立接口 | +| **延迟挂接** | 网关的 LAN 侧 veth 仅在第一个客户端容器启动时创建,而非网关容器启动时创建 | diff --git a/Documentation/zh-CN/community-supported-devices.md b/Documentation/zh-CN/community-supported-devices.md index b152aa83..fb931ba6 100644 --- a/Documentation/zh-CN/community-supported-devices.md +++ b/Documentation/zh-CN/community-supported-devices.md @@ -26,11 +26,12 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | 设备名称 | 型号 | Android / ROM | Baseband / Build | 内核版本 | Root 方式 | 内核源码 | 下载链接 | Droidspaces 模式 | GPU 加速 | 状态 | 维护者 | 附加说明 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| **Galaxy S10(日版)** | SCV41 / SM-G973J | One UI 4.1 - Android 12 | `SCV41KDU1DWC1` | `4.14.190` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces) | [Download](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces/releases/download/v2/Droidspaces-KSUN-Samsung-SCV41.tar) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | -| **Poco X2** | M1912G7Bx | BlissROM-v16.5 - Android 13 | - | `4.14.274` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces) | [Download](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces/releases/download/v1/Droidspaces-KSUNv1.1.1-phoenix.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | +| **Galaxy S10(日版)** | SCV41 / SM-G973J | One UI 4.1 - Android 12 | `SCV41KDU1DWC1` | `4.14.190` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces) | [Download](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces/releases/download/v2/Droidspaces-KSUN-Samsung-SCV41.tar) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | +| **Poco X2** | M1912G7Bx | BlissROM-v16.5 - Android 13 | - | `4.14.274` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces) | [Download](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces/releases/download/v1/Droidspaces-KSUNv1.1.1-phoenix.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | | **Realme 6, 6i, 6s** | RMX2001L1 | RUI 2.0 - Android 11 | - | `4.14.336` | KSU-Next v3.2.0 / ReSukiSU v4.1.0 | [Source](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo) | [Download](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo/releases/tag/v1.1) | Both | Virgl | 正常运行 | [@Prime-TITAN-CameraMan](https://github.com/Prime-TITAN-CameraMan) | 仅限 RUI 2.0 且不支持防火墙(UFW/Fail2ban) | -| **Redmi K20 Pro(国行)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | 正常运行 | [@Alhkxsj](https://github.com/Alhkxsj) | 内核源码为原版 YAAP 内核;通过自动化构建工作流应用 Droidspaces 配置 | -| **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | 无 | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | 正常运行 | [@JackA1ltman](https://github.com/JackA1ltman) | 仅限自定义 ROM。支持 NTSYNC。 | +| **Redmi K20 Pro(国行)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | 正常运行 | [@Alhkxsj](https://github.com/Alhkxsj) | 内核源码为原版 YAAP 内核;通过自动化构建工作流应用 Droidspaces 配置 | +| **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | 无 | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | 正常运行 | [@JackA1ltman](https://github.com/JackA1ltman) | 仅限自定义 ROM。支持 NTSYNC。 | +| **OnePlus 7 Pro** | GM1911 | LineageOS 17.1 - Android 10 | `QQ3A.200805.001` | `4.14.117` | Magisk | [Source](https://github.com/charan-gn/guacamole-droidspaces-kernel) | [Download](https://github.com/charan-gn/guacamole-droidspaces-kernel/releases/tag/v1.0) | Both | Turnip & Virgl | 正常运行 | [@charan-gn](https://github.com/charan-gn) | AnyKernel3 可在任何 ROM 上刷入。 | ## GKI 设备(内核 5.4 及以上版本) @@ -38,114 +39,151 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | **Galaxy A16 5G** | SM-A166P | One UI 6.1 - Android 14 | `5.15.148` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/r20260402-c17abb78/KernelSU-SM-A166P-OneUI6-A166PXXS4AYC1-r20260402-c17abb78.tar.zip) | Both | 仅 Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | | **Galaxy A16 5G** | SM-A166P | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/ubuntu-x-docker/Ubuntu-x-KernelSU-Next-SM-A166P-dev.zip) | Both | 仅 Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | -| **Galaxy M14 4G** | SM-M145F | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_sm_m145f) | [Download](https://github.com/ravindu644/android_kernel_sm_m145f/releases/download/v1/SM-M145F-Droidspaces-KSUNv1.1.1-M145FXXS8DYH1.tar.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | +| **Galaxy M14 4G** | SM-M145F | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_sm_m145f) | [Download](https://github.com/ravindu644/android_kernel_sm_m145f/releases/download/v1/SM-M145F-Droidspaces-KSUNv1.1.1-M145FXXS8DYH1.tar.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | | **Galaxy A25 5G** | SM-A253Z | One UI 8.0 - Android 16 | `5.15.180` | Magisk | [Source](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835) | [Download](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835/releases/download/r20260421-89a95a08/DroidSpaces-SM-A253Z-OneUI8-A253ZSCS1BZA2-r20260421-89a95a08.tar.zip) | Both | 无 | 正常运行 | [@yoshi3jp](https://github.com/yoshi3jp) | - | -| **OnePlus Pad 2 Pro(国行)** | OPD2413 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 3 | -| **OnePlus 13(国行)** | PJZ110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **OnePlus 13(国行)** | PJZ110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **OnePlus Ace 5 Pro(国行)** | PKR110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 5 Pro(国行)** | PKR110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus 13T(国行)** | PKX110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus 13T(国行)** | PKX110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 6(国行)** | PLR110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 Pro(国行)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 Pro(国行)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 5(国行)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 13R | -| **OnePlus Ace 5(国行)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 13R | -| **OnePlus 12(国行)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **OnePlus 12(国行)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **OnePlus Pad Pro(国行)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 2(国际版) | -| **OnePlus Pad Pro(国行)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 2(国际版) | -| **Realme GT 5 Pro(国行)** | RMX3888 | RealmeUI 7.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3(国行)** | PJE110 | ColorOS 16.0 - Android 16 | `5.15.180` | 无 | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.180_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 12R | -| **OnePlus Ace 3(国行)** | PJE110 | ColorOS 15.0 - Android 15 | `5.15.167` | 无 | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.167_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 12R | +| **OnePlus Pad 2 Pro(国行)** | OPD2413 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 3 | +| **OnePlus 13(国行)** | PJZ110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **OnePlus 13(国行)** | PJZ110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **OnePlus Ace 5 Pro(国行)** | PKR110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 5 Pro(国行)** | PKR110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13T(国行)** | PKX110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13T(国行)** | PKX110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 6(国行)** | PLR110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 3 Pro(国行)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 3 Pro(国行)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 5(国行)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 13R | +| **OnePlus Ace 5(国行)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 13R | +| **OnePlus 12(国行)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **OnePlus 12(国行)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **OnePlus Pad Pro(国行)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 2(国际版) | +| **OnePlus Pad Pro(国行)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 2(国际版) | +| **Realme GT 5 Pro(国行)** | RMX3888 | RealmeUI 7.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 3(国行)** | PJE110 | ColorOS 16.0 - Android 16 | `5.15.180` | 无 | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.180_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 12R | +| **OnePlus Ace 3(国行)** | PJE110 | ColorOS 15.0 - Android 15 | `5.15.167` | 无 | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.167_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 12R | | **OnePlus Ace 竞速版(国行)** | PGZ110 | ColorOS 15.0 - Android 15 | `5.10.167` | Resukisu | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU_oki_5.10.226_Gold_bug_%E5%A4%A9%E7%8E%91%E7%89%B9%E4%BE%9B.zip) | Both | bridge & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus SM8650(所有 LineageOS OnePlus 6.1 设备)** | SM8650 | LineageOS 23.2 - Android 16 QPR 2 | `6.1.166` | 无 | [Source](https://github.com/LineageOS/android_kernel_oneplus_sm8650) | [Download](https://github.com/linx3141/CRom-KSU-Builder/releases/tag/sm8650_LineageOS-lineage-23.2_None_20260512_1647) | Both | Turnip & Virgl | 正常运行 | [@linx3141](https://github.com/linx3141) | - | -| **Xiaomi 17(国行)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **Xiaomi 17 Pro(国行)** | 2509FPN0BC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **Xiaomi 17 Pro max(国行)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **红魔 11 Pro(国行)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **HONOR Magic Pad 3 Pro 13.3(国行)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus SM8650(所有 LineageOS OnePlus 6.1 设备)** | SM8650 | LineageOS 23.2 - Android 16 QPR 2 | `6.1.166` | 无 | [Source](https://github.com/LineageOS/android_kernel_oneplus_sm8650) | [Download](https://github.com/linx3141/CRom-KSU-Builder/releases/tag/sm8650_LineageOS-lineage-23.2_None_20260512_1647) | Both | Turnip & Virgl | 正常运行 | [@linx3141](https://github.com/linx3141) | - | +| **Xiaomi 17(国行)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **Xiaomi 17 Pro(国行)** | 2509FPN0BC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **Xiaomi 17 Pro max(国行)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **红魔 11 Pro(国行)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **HONOR Magic Pad 3 Pro 13.3(国行)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **Xiaomi Pad7(国行)** | 2410CRP4CC | HyperOS 2.0 - Android 15 | `6.1.118` | Sukisu Ultra | [Source](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS.git) | [Download](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS/releases/tag/release) | Both | Turnip & Zink | 正常运行 | [HuangJunLin8](https://github.com/HuangJunLin8) | 支持 NTsync | -| **Xiaomi Pad 8 Pro(国行)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu
    &
    无Root | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
    [无Root](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
    支持 NTsync
    ReSukiSU版本为34924 | -| **Xiaomi 15(国行)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu
    &
    无Root | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
    [无Root](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
    支持 NTsync
    ReSukiSU版本为34924 | -| **Xiaomi 14(国行)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | 正常运行 | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | 适用于国际版 | -| **红魔 9 Pro(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | -| **红魔 9 SPro+(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | -| **Xiaomi Pad 6S Pro(国行)** | 24018RPACC | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | 正常运行 | [@mlfc666](https://github.com/mlfc666) | 适用于国际版
    支持 NTsync | -| **Redmi K70(国行)** | 23113RKC6C | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | 正常运行 | [@mlfc666](https://github.com/mlfc666) | 支持 NTsync | -| **Xiaomi Pad 6 Max (yudi)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync | -| **Xiaomi Pad 6 Pro (liuqin)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync | -| **Redmi K60 / POCO F5 Pro (mondrian)** | 23013RK75C | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync \| 适用于国际版 | -| **Redmi K50 Ultra / Xiaomi 12T Pro (diting)** | 22081212C | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync \| 适用于国际版 | -| **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@loystonpais](https://github.com/loystonpais) | - | +| **Xiaomi Pad 8 Pro(国行)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu
    &
    无Root | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
    [无Root](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
    支持 NTsync
    ReSukiSU版本为34924 | +| **Xiaomi 15(国行)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu
    &
    无Root | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
    [无Root](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
    支持 NTsync
    ReSukiSU版本为34924 | +| **Xiaomi 14(国行)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | 正常运行 | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | 适用于国际版 | +| **红魔 9 Pro(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | +| **红魔 9 SPro+(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | +| **Xiaomi Pad 6S Pro(国行)** | 24018RPACC | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | 正常运行 | [@mlfc666](https://github.com/mlfc666) | 适用于国际版
    支持 NTsync | +| **Redmi K70(国行)** | 23113RKC6C | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | 正常运行 | [@mlfc666](https://github.com/mlfc666) | 支持 NTsync | +| **Xiaomi Pad 6 Max (yudi)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync | +| **Xiaomi Pad 6 Pro (liuqin)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync | +| **Redmi K60 / POCO F5 Pro (mondrian)** | 23013RK75C | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync \| 适用于国际版 | +| **Redmi K50 Ultra / Xiaomi 12T Pro (diting)** | 22081212C | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync \| 适用于国际版 | +| **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@loystonpais](https://github.com/loystonpais) | - | | **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | 正常运行 | [@superuseryu](https://github.com/superuseryu) | 其他 KSU 变体可能因 SELinux 上下文重置而失败。 | -| **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | 正常运行 | [@MySelly](https://github.com/MySelly) | - | +| **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | 正常运行 | [@MySelly](https://github.com/MySelly) | - | | **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | 正常运行 | [@miaizhe](https://github.com/miaizhe) | - | | OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/210ba17c8f19beed0212eb4f209109e35ef35746) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10R | CPH2411 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10R | CPH2411 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/bdebb03938ab5f6719abc50af454d76a1b12df67) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/45235533105840dc875a80e9d5b4cd881e453472) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/7d9f64c901ac11ce80bf78682d33fd336c825fd9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/bee3255be1f407e7cff7b0b8f1c26daaccbbb510) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus 11R | CPH2487 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 11R | CPH2487 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/6821a3751c4fd016a7ee4da5aea635d42a8b3d43) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 11R | CPH2487 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 11R | CPH2487 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/c461b745bc361581d0b419effd284367d85f1e57) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/2936373a255da66646a05384e993d55a8dfecc80) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/86a89359565410962004fd838e3d2e3b42a047ca) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/717aea450444dfd00db7d60c99cff77fbcbf014e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android 15 (OOS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/cec4a1056a32dfdd68c23767f6c88bb6c698f1bf) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android 15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13 PJZ | PJZ110 | Android 15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/d68f486cb00b246abf4b7b66375082dc8bcb884e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/35f7c28cf36e19e3243a9db8496d5e70b94e34ce) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13S | CPH2723 | Android 15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13S | CPH2723 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13T | PKX110 | Android 15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/06be241a7b9eefa01db78447cc7877339b3685f3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13T | PKX110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13T | PKX110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 15 | CPH2747, CPH2745, PLK110, CPH2749 | Android 16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 15R | CPH2769, CPH2767 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 15T | PLZ110 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE | PGKM10 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE | PGKM10 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/e5f7c2a470fd9d8b43c5082aed8b58a6d5a2bf7c) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE | PGKM10 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 2 | PHK110 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/ef7ca029d0f6beddf38e60f6b9016935d0f1ac55) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2 | PHK110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2 | PHK110 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 2 Pro | PJA110 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/95b9da8a7cd99053f65e7d4cebec310b5f5d5744) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2 Pro | PJA110 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2 Pro | PJA110 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2V | PHP110 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 2V | PHP110 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/8a49e1e297768839d78730f15e8f34e0d0b59f2b) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2V | PHP110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2V | PHP110 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3 | PJE110 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3a51ce558bfa3a1c7eeaaab69ee54d5e6bd03afb) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus ACE 3 | PJE110 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3 | PJE110 | Android 16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3dc000f085497a728cdf21b28fab9d3ef632f5fd) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus ACE 3 | PJE110 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3 Pro | PJX110 | Android 14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3 Pro | PJX110 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/1cee682fb60cc9ffd86a2342fa8051bcde77116f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3 Pro | PJX110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3 Pro | PJX110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3V | PJF110 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/ac51a5e4c2b0f13f06e9234e415bfa68fb24a8b5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3V | PJF110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3V | PJF110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/65c3035422d899d95bfde4e30dc426712ffe52f2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3V | PJF110 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 | PKG110 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/8c797fd3ff3f76248b44a619410ace1277692907) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 | PKG110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 | PKG110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/7499247fd6e0669a062ec44cce948ec1e9d4c75e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 | PKG110 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus ACE 5 Pro | PKR110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 Pro | PKR110 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | +| OnePlus ACE 5 Pro | PKR110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus ACE 5 Pro | PKR110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 Racing | PLF110 | Android 15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus ACE 5 Racing | PLF110 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_racehttps://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 Racing | PLF110 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 Ultra | PLC110 | Android 15 (OOS/COS 15) | 6.6.50 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/06b1dfbb8f777d329a25d6f932f58f0e45d87627) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 Ultra | PLC110 | Android 15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 Ultra | PLC110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 6 | PLQ110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 6 Ultra | PMB110 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 6T | PLR110 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE Race | PGZ110 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE Race | PGZ110 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/7a64b08a000cc7040d99543cc7093aaa13e0c786) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE Race | PGZ110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/6409af87cfda3739caff4ee29eb28e77abe8a290) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE Race | PGZ110 | Android 15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 3 | CPH2491, CPH2493 | Android 14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 3 | CPH2491, CPH2493 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/b9b40be7bbe4e2c201ba263ac72f2e1b10171998) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 3 | CPH2491, CPH2493 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 4 | CPH2663, CPH2661 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/a3c5659c859e888102a144be0884fbf3731b170f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 4 | CPH2663, CPH2661 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 4 | CPH2663, CPH2661 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/975bcbc2e288c3b7c2d2799421c7f356f9c78ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 4 | CPH2663, CPH2661 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 4 CE | CPH2613 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/d8187f64c9e60fee0969a678a67ac77114a926d9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 4 CE | CPH2613 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/0d28103245225a507d4f4b3ca657d3e27aae2f20) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 4 CE | CPH2613 | Android 15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 4 CE | CPH2613 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 5 | CPH2709, CPH2707 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 5 | CPH2709, CPH2707 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/fbeaa9ff25bef199618b9e47004f2eb8fe135f9f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 5 | CPH2709, CPH2707 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 6 | CPH2793, CPH2795 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | @@ -153,16 +191,22 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | OnePlus Nord CE5 | CPH2719, CPH2717 | Android 15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord CE5 | CPH2719, CPH2717 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord N30 SE | CPH2605 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Open | CPH2551 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/2a845b8afd7afc3d72679ddbe5adb07c390966bc) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Open | CPH2551 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Open | CPH2551 | Android 16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/1e88584a40b3b055e5ff3796f9a184655ef2d0b8) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Open | CPH2551 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 MT6991 | OPD2508 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad 2 Pro | OPD2413 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/68c46d941cc1818d49f8a8805c79e63fbee01eac) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 Pro | OPD2413 | Android 15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 Pro | OPD2413 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad 2 SM8650 | OPD2403 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/f5a170ce44c7657894247de3de41d23a89e248f7) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 SM8650 | OPD2403 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 SM8650 | OPD2403 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad 3 MT6897 | OPD2407 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/cbae97472bca105f69ffaef126065f9ab06e1c12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus Pad 3 MT6897 | OPD2407 | Android 15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 3 MT6897 | OPD2407 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 3 Pro | OPD2513 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad 3 SM8750 | OPD2415 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/35366be96d7e73d1d0d7093e1cf2fafd58f9c7d4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus Pad 3 SM8750 | OPD2415 | Android 15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 3 SM8750 | OPD2415 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 4 | OPD2514 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | @@ -171,9 +215,11 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | OnePlus Pad Lite | OPD2480, OPD2481 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad Lite | OPD2480, OPD2481 | Android 16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad MT6983 | OPD2203 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad MT6983 | OPD2203 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/4790201712d757f4f337cd55e5709255edc8bee5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad MT6983 | OPD2203 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad MT6983 | OPD2203 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad Pro | OPD2404 | Android 14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad Pro | OPD2404 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/dcb47d3dcba84e13ae678f18b4629b22333e8898) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad Pro | OPD2404 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad Pro | OPD2404 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Turbo 6 | PLU110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | From 80f687f2e4971eebb2b4b1b91e92d05974cdc7d6 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 24 Jun 2026 20:40:56 +0530 Subject: [PATCH 068/211] app: show rootfs.img disk usage on sparse container details Add a Disk Usage card between Container Info and Available Users on the container details screen, shown only for sparse-image containers. It reports how much real device storage the rootfs.img occupies (used of total) via a single stat call, fetched once when the screen opens and cached in memory for instant re-open. related to the feature request: https://github.com/ravindu644/Droidspaces-OSS/issues/205 Signed-off-by: ravindu644 --- .../app/ui/screen/ContainerDetailsScreen.kt | 125 ++++++++++++++++++ .../app/util/ContainerDiskUsageManager.kt | 54 ++++++++ Android/app/src/main/res/values/strings.xml | 3 + 3 files changed, 182 insertions(+) create mode 100644 Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt index 1184172d..62f5f378 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt @@ -26,6 +26,7 @@ import com.droidspaces.app.util.ContainerOSInfoManager import com.droidspaces.app.util.ContainerSystemdManager import com.droidspaces.app.util.ContainerProcdManager import com.droidspaces.app.util.ContainerOpenRCManager +import com.droidspaces.app.util.ContainerDiskUsageManager import com.droidspaces.app.util.ContainerManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -76,6 +77,14 @@ fun ContainerDetailsScreen( // Init system state - detect systemd first, then OpenWrt/procd, then OpenRC var initSystemState by remember { mutableStateOf(InitSystemCardState.Checking) } + // Disk usage of the sparse rootfs.img (only relevant for sparse-image containers). + // Seed from the in-memory cache so a re-opened screen paints instantly. + var diskUsage by remember { + mutableStateOf( + if (container.useSparseImage) ContainerDiskUsageManager.getCached(container.rootfsPath) else null + ) + } + LaunchedEffect(container.name) { initSystemState = when { ContainerSystemdManager.isSystemdAvailable(container.name) -> @@ -130,6 +139,15 @@ fun ContainerDetailsScreen( } } + // Disk-usage footprint - computed once when the screen opens (the sparse image's + // footprint changes slowly, so a snapshot is enough). Runs in its own effect so the + // first reading isn't blocked behind the heavier OS-info shell calls above. + if (container.useSparseImage) { + LaunchedEffect(container.name) { + ContainerDiskUsageManager.getUsage(container.rootfsPath)?.let { diskUsage = it } + } + } + Scaffold( topBar = { TopAppBar( @@ -218,6 +236,13 @@ fun ContainerDetailsScreen( } } + // Disk Usage Card - only for sparse-image containers (shows rootfs.img footprint) + if (container.useSparseImage) { + item(key = "diskusage_${container.name}") { + SparseDiskUsageCard(usage = diskUsage) + } + } + // Users Card - Stable key (don't include refreshTrigger to prevent recreation) item(key = "users_${container.name}") { ContainerUsersCard( @@ -439,6 +464,106 @@ private fun TerminalCard( } } +/** + * Disk Usage Card - shows how much real device storage a sparse rootfs.img occupies. + * Only rendered for sparse-image containers. [usage] is null until the first measurement + * lands, during which a lightweight placeholder is shown. + */ +@Composable +private fun SparseDiskUsageCard( + usage: ContainerDiskUsageManager.DiskUsage?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { visible = true } + + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = AnimationUtils.cardFadeSpec(), + label = "disk_usage_card_fade" + ) + + val fraction = usage?.let { + if (it.totalBytes > 0L) (it.usedBytes.toFloat() / it.totalBytes.toFloat()).coerceIn(0f, 1f) else 0f + } ?: 0f + val animatedFraction by animateFloatAsState( + targetValue = fraction, + animationSpec = AnimationUtils.cardFadeSpec(), + label = "disk_usage_progress" + ) + + Surface( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 88.dp) + .alpha(alpha) + .graphicsLayer { this.alpha = alpha }, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_disk), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Column { + Text( + text = context.getString(R.string.disk_usage), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + val subtitle = usage?.let { + context.getString( + R.string.disk_usage_used_of_total, + formatBytes(it.usedBytes), + formatBytes(it.totalBytes) + ) + } ?: context.getString(R.string.disk_usage_calculating) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + + LinearProgressIndicator( + progress = { animatedFraction }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } +} + +/** + * Formats a byte count as a human-readable string (GiB with one decimal, or MiB below 1 GiB). + */ +private fun formatBytes(bytes: Long): String { + val gb = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) + if (gb >= 1.0) return String.format("%.1f GB", gb) + val mb = bytes.toDouble() / (1024.0 * 1024.0) + return String.format("%.0f MB", mb) +} + /** * PREMIUM INIT SYSTEM CARD - handles Systemd, OpenWrt/procd, OpenRC, and unavailable states. */ diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt new file mode 100644 index 00000000..efb5c06b --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt @@ -0,0 +1,54 @@ +package com.droidspaces.app.util + +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Reports the on-disk footprint of a sparse-image container's rootfs.img. + * + * For a sparse file the apparent size (e.g. 32 GB) is much larger than the blocks + * actually allocated on the host (e.g. 5.6 GB). A single `stat -c '%s %b'` gives both: + * - %s = apparent size in bytes -> total + * - %b = allocated 512-byte blocks -> used = %b * 512 + * + * This is the storage the image currently occupies on the device. Note it reflects + * host-allocated blocks, which only grow: deleting files inside the guest will not + * shrink it unless the guest issues fstrim/discard. Works whether the container is + * running or not - no mount required. + * + * Always uses our bundled busybox (pinned version) rather than the system `stat`/toybox, + * so the `%s %b` output is reproducible across devices regardless of the OEM userspace. + */ +object ContainerDiskUsageManager { + + /** Allocated 512-byte blocks are always 512 bytes regardless of filesystem block size. */ + private const val STAT_BLOCK_SIZE = 512L + + data class DiskUsage(val usedBytes: Long, val totalBytes: Long) + + /** Last measured value per image path - lets a re-opened screen paint instantly. */ + private val lastKnown = HashMap() + + /** Last value measured for [imgPath] in this process, or null if never measured. */ + fun getCached(imgPath: String): DiskUsage? = synchronized(lastKnown) { lastKnown[imgPath] } + + /** + * @param imgPath path to the rootfs.img (use [ContainerInfo.rootfsPath] for a sparse container). + * @return used/total in bytes, or null if the image could not be stat'd. + */ + suspend fun getUsage(imgPath: String): DiskUsage? = withContext(Dispatchers.IO) { + val cmd = "${Constants.BUSYBOX_BINARY_PATH} stat -c '%s %b' \"$imgPath\" 2>/dev/null" + val result = Shell.cmd(cmd).exec() + if (!result.isSuccess) return@withContext null + + val parts = result.out.firstOrNull()?.trim()?.split(Regex("\\s+")) ?: return@withContext null + val total = parts.getOrNull(0)?.toLongOrNull() ?: return@withContext null + val blocks = parts.getOrNull(1)?.toLongOrNull() ?: return@withContext null + if (total <= 0L) return@withContext null + + DiskUsage(usedBytes = blocks * STAT_BLOCK_SIZE, totalBytes = total).also { + synchronized(lastKnown) { lastKnown[imgPath] = it } + } + } +} diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 01092b7f..d6989198 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -170,6 +170,9 @@ IP Address Uptime Unable to read container information + Disk Usage + %1$s / %2$s used + Calculating… Systemd Checking Not Available From d4e518c8b8bead98499c74bf3bde989fa74db474 Mon Sep 17 00:00:00 2001 From: "Garry Strait (Chara)" <92406354+GarryStraitYT@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:16:07 -0400 Subject: [PATCH 069/211] Documentation: Updated community-supported-devices.md to bring 1 new device and organization (#222) * Update community-supported devices list This commit adds Motorola Moto G Stylus 5G (2022) [milanf/XT2215] to the list of supported devices. Additionally, it reorganizes the table to be sorted by kernel version instead of whenever the device was added to the list, improving readability and maintenance. Tests performed: None (Documentation update) * Group devices by kernel version in docs Add kernel-version group headers to Documentation/community-supported-devices.md (5.4, 5.10, 5.15, 6.1, 6.6, 6.12) to improve readability and organization of the device compatibility table. Minor table formatting adjustments were made for a few device rows to align them under the new sections. --- Documentation/community-supported-devices.md | 312 ++++++++++--------- 1 file changed, 159 insertions(+), 153 deletions(-) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 9274cb42..4038b3d8 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -26,207 +26,213 @@ This document is a community-maintained compatibility list for Android devices k | Device Name | Model Number | Android / ROM | Baseband / Build | Kernel version | Root Method | Kernel Source | Download Link | Droidspaces Mode | GPU Acceleration | Status | Maintainer | Additional notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| **OnePlus 7 Pro** | GM1911 | LineageOS 17.1 - Android 10 | `QQ3A.200805.001` | `4.14.117` | Magisk | [Source](https://github.com/charan-gn/guacamole-droidspaces-kernel) | [Download](https://github.com/charan-gn/guacamole-droidspaces-kernel/releases/tag/v1.0) | Both | Turnip & Virgl | Working | [@charan-gn](https://github.com/charan-gn) | AnyKernel3 flashable on any ROM. | | **Galaxy S10 (Japanese variant)** | SCV41 / SM-G973J | One UI 4.1 - Android 12 | `SCV41KDU1DWC1` | `4.14.190` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces) | [Download](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces/releases/download/v2/Droidspaces-KSUN-Samsung-SCV41.tar) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | | **Poco X2** | M1912G7Bx | BlissROM-v16.5 - Android 13 | - | `4.14.274` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces) | [Download](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces/releases/download/v1/Droidspaces-KSUNv1.1.1-phoenix.zip) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | | **Realme 6, 6i, 6s** | RMX2001L1 | RUI 2.0 - Android 11 | - | `4.14.336` | KSU-Next v3.2.0 / ReSukiSU v4.1.0 | [Source](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo) | [Download](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo/releases/tag/v1.1) | Both | Virgl | Working | [@Prime-TITAN-CameraMan](https://github.com/Prime-TITAN-CameraMan) | RUI 2.0 only & No Firewall Support (UFW/Fail2ban) | | **Redmi K20 Pro (China)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | Working | [@Alhkxsj](https://github.com/Alhkxsj) | Kernel source is stock YAAP kernel; Droidspaces configs applied via automated build workflow | -| **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | none | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | Working | [@JackA1ltman](https://github.com/JackA1ltman) | Custom ROM only. Supported NTSYNC. | -| **OnePlus 7 Pro** | GM1911 | LineageOS 17.1 - Android 10 | `QQ3A.200805.001` | `4.14.117` | Magisk | [Source](https://github.com/charan-gn/guacamole-droidspaces-kernel) | [Download](https://github.com/charan-gn/guacamole-droidspaces-kernel/releases/tag/v1.0) | Both | Turnip & Virgl | Working | [@charan-gn](https://github.com/charan-gn) | AnyKernel3 flashable on any ROM. | | **Redmi note 7 pro (violet)** | M1901F7S | LineagOS 23.2 - Android 16 | `.c2-00029-SM6150_GEN_PACK-1.352094.1.355031.2` | `4.14.357` | Magisk | [source](https://github.com/subhu2008/android_kernel_xiaomi_sm6150) | [Download](https://github.com/subhu2008/android_kernel_xiaomi_sm6150/releases/tag/stable) | Both | Turnip & Virgl | Working | [@subhu2008](https://github.com/subhu2008) | LineageOS 23.2 only. If it works on other AOSP ROM's too, then good. | +| **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | none | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | Working | [@JackA1ltman](https://github.com/JackA1ltman) | Custom ROM only. Supported NTSYNC. | ## GKI Devices (Kernel 5.4 and up) | Device Name | Model Number | Android / ROM | Kernel version | Root Method | Kernel Source | Download Link | Droidspaces Mode | GPU Acceleration | Status | Maintainer | Additional notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| **Galaxy A16 5G** | SM-A166P | One UI 6.1 - Android 14 | `5.15.148` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/r20260402-c17abb78/KernelSU-SM-A166P-OneUI6-A166PXXS4AYC1-r20260402-c17abb78.tar.zip) | Both | Virgl only | Working | [@ravindu644](https://github.com/ravindu644) | - | -| **Galaxy A16 5G** | SM-A166P | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/ubuntu-x-docker/Ubuntu-x-KernelSU-Next-SM-A166P-dev.zip) | Both | Virgl only | Working | [@ravindu644](https://github.com/ravindu644) | - | -| **Galaxy M14 4G** | SM-M145F | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_sm_m145f) | [Download](https://github.com/ravindu644/android_kernel_sm_m145f/releases/download/v1/SM-M145F-Droidspaces-KSUNv1.1.1-M145FXXS8DYH1.tar.zip) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | -| **Galaxy A25 5G** | SM-A253Z | One UI 8.0 - Android 16 | `5.15.180` | Magisk | [Source](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835) | [Download](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835/releases/download/r20260421-89a95a08/DroidSpaces-SM-A253Z-OneUI8-A253ZSCS1BZA2-r20260421-89a95a08.tar.zip) | Both | none | Working | [@yoshi3jp](https://github.com/yoshi3jp) | - | -| **OnePlus Pad 2 Pro (Chinese variant)** | OPD2413 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 3 | -| **OnePlus 13 (Chinese variant)** | PJZ110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | -| **OnePlus 13 (Chinese variant)** | PJZ110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | -| **OnePlus Ace 5 Pro (Chinese variant)** | PKR110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 5 Pro (Chinese variant)** | PKR110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus 13T (Chinese variant)** | PKX110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus 13T (Chinese variant)** | PKX110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 6 (Chinese variant)** | PLR110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 Pro (Chinese variant)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 Pro (Chinese variant)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 5 (Chinese variant)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 13R | -| **OnePlus Ace 5 (Chinese variant)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 13R | -| **OnePlus 12 (Chinese variant)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | -| **OnePlus 12 (Chinese variant)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | -| **OnePlus Pad Pro (Chinese variant)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 2 (International version) | -| **OnePlus Pad Pro (Chinese variant)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 2 (International version) | -| **Realme GT 5 Pro (Chinese variant)** | RMX3888 | RealmeUI 7.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 (Chinese variant)** | PJE110 | ColorOS 16.0 - Android 16 | `5.15.180` | none | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.180_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 12R | -| **OnePlus Ace 3 (Chinese variant)** | PJE110 | ColorOS 15.0 - Android 15 | `5.15.167` | none | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.167_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 12R | -| **OnePlus Ace 竞速版 (Chinese variant)** | PGZ110 | ColorOS 15.0 - Android 15 | `5.10.167` | Resukisu | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU_oki_5.10.226_Gold_bug_%E5%A4%A9%E7%8E%91%E7%89%B9%E4%BE%9B.zip) | Both | bridge & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus SM8650 (All LineageOS OnePlus 6.1 Devices)** | SM8650 | LineageOS 23.2 - Android 16 QPR 2 | `6.1.166` | none | [Source](https://github.com/LineageOS/android_kernel_oneplus_sm8650) | [Download](https://github.com/linx3141/CRom-KSU-Builder/releases/tag/sm8650_LineageOS-lineage-23.2_None_20260512_1647) | Both | Turnip & Virgl | Working | [@linx3141](https://github.com/linx3141) | - | -| **Xiaomi 17 (Chinese variant)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | -| **Xiaomi 17 Pro (Chinese variant)** | 2509FPN0BC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **Xiaomi 17 Pro max (Chinese variant)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **红魔 11 Pro (Chinese variant)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **HONOR Magic Pad 3 Pro 13.3 (Chinese variant)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **Xiaomi Pad 7 (Chinese variant)** | 2410CRP4CC | HyperOS 2.0 - Android 15 | `6.1.118` | Sukisu Ultra | [Source](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS.git) | [Download](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS/releases/tag/release) | Both | Turnip & Zink | Working | [HuangJunLin8](https://github.com/HuangJunLin8) | Supports NTsync | -| **Xiaomi Pad 8 Pro (Chinese variant)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu & NoRoot | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
    [NoRoot](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
    Supports NTsync
    ReSukiSU version 34924 | -| **Xiaomi 15 (Chinese variant)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu & NoRoot | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
    [NoRoot](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
    Supports NTsync
    ReSukiSU version 34924 | -| **Xiaomi 14 (Chinese variant)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | Working | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | Applicable to the international version | -| **红魔 9 Pro (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | -| **红魔 9 SPro+ (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | -| **Xiaomi Pad 6S Pro (Chinese variant)** | 24018RPACC | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | Working | [@mlfc666](https://github.com/mlfc666) | Applicable to the international version
    Supports NTsync | -| **Redmi K70 (Chinese variant)** | 23113RKC6C | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | Working | [@mlfc666](https://github.com/mlfc666) | Supports NTsync | -| **Xiaomi Pad 6 Max (yudi)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | -| **Xiaomi Pad 6 Pro (liuqin)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | -| **Redmi K60 / POCO F5 Pro (mondrian)** | 23013RK75C | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | Applicable to the international version -| **Redmi K50 Ultra / Xiaomi 12T Pro (diting)** | 22081212C | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | Applicable to the international version -| **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | Working | [@loystonpais](https://github.com/loystonpais) | - | -| **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | Working | [@superuseryu](https://github.com/superuseryu) | Other KSU variants may fail due to SELinux context reset. | -| **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | Working | [@MySelly](https://github.com/MySelly) | - | +| **5.4 Devices**| - | - | 5.4.x | - | - | - | - | - | - | -| - | | **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | Working | [@miaizhe](https://github.com/miaizhe) | - | +| **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | Working | [@loystonpais](https://github.com/loystonpais) | - | +| **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | Working | [@MySelly](https://github.com/MySelly) | - | +| **Motorola Moto G Stylus 5G (2022) | XT2215 | Android 16 QPR2 (LineageOS 23.2 or equivalent) | `5.4.306` | ReSukiSU | [Source](https://github.com/chararomandroid/android_kernel_motorola_sm6375) | [Download](https://github.com/CharaROMAndroid/android_kernel_motorola_sm6375/releases/tag/chaoskernel-milanf) | Both | Turnip | Working | [@GarryStraitYT](https://github.com/GarryStraitYT) | Tested on CharaROM 1.0 Beta 2, should work on any LineageOS 23.2 equivalent. Tested with Retail US variant of XT2215-4 (8GB RAM + 256GB ROM), but should work on any variant. +| **5.10 Devices**| - | - | 5.10.x | - | - | - | - | - | - | -| - | +| **OnePlus Ace 竞速版 (Chinese variant)** | PGZ110 | ColorOS 15.0 - Android 15 | `5.10.167` | Resukisu | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU_oki_5.10.226_Gold_bug_%E5%A4%A9%E7%8E%91%E7%89%B9%E4%BE%9B.zip) | Both | bridge & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 10R** | CPH2411 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/210ba17c8f19beed0212eb4f209109e35ef35746) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 10R** | CPH2411 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 10R** | CPH2411 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/bdebb03938ab5f6719abc50af454d76a1b12df67) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/6821a3751c4fd016a7ee4da5aea635d42a8b3d43) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/e5f7c2a470fd9d8b43c5082aed8b58a6d5a2bf7c) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/ef7ca029d0f6beddf38e60f6b9016935d0f1ac55) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/8a49e1e297768839d78730f15e8f34e0d0b59f2b) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/7a64b08a000cc7040d99543cc7093aaa13e0c786) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/b9b40be7bbe4e2c201ba263ac72f2e1b10171998) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Go** | OPD2304, OPD2305 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_u_14.0.0_onepluspad_go) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/4790201712d757f4f337cd55e5709255edc8bee5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/45235533105840dc875a80e9d5b4cd881e453472) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/6409af87cfda3739caff4ee29eb28e77abe8a290) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Lite** | OPD2480, OPD2481 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Xiaomi Pad 6 Max (yudi)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | +| **Xiaomi Pad 6 Pro (liuqin)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | +| **Redmi K60 / POCO F5 Pro (mondrian)** | 23013RK75C | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | Applicable to the international version | +| **Redmi K50 Ultra / Xiaomi 12T Pro (diting)** | 22081212C | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | Applicable to the international version | +| **5.15 Devices** | - | - | 5.15.x | - | - | - | - | - | - | -| - | | **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Galaxy A16 5G** | SM-A166P | One UI 6.1 - Android 14 | `5.15.148` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/r20260402-c17abb78/KernelSU-SM-A166P-OneUI6-A166PXXS4AYC1-r20260402-c17abb78.tar.zip) | Both | Virgl only | Working | [@ravindu644](https://github.com/ravindu644) | - | | **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/7d9f64c901ac11ce80bf78682d33fd336c825fd9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/86a89359565410962004fd838e3d2e3b42a047ca) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2 Pro** | PJA110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/95b9da8a7cd99053f65e7d4cebec310b5f5d5744) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3a51ce558bfa3a1c7eeaaab69ee54d5e6bd03afb) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/d8187f64c9e60fee0969a678a67ac77114a926d9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/2a845b8afd7afc3d72679ddbe5adb07c390966bc) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Galaxy A16 5G** | SM-A166P | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/ubuntu-x-docker/Ubuntu-x-KernelSU-Next-SM-A166P-dev.zip) | Both | Virgl only | Working | [@ravindu644](https://github.com/ravindu644) | - | +| **Galaxy M14 4G** | SM-M145F | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_sm_m145f) | [Download](https://github.com/ravindu644/android_kernel_sm_m145f/releases/download/v1/SM-M145F-Droidspaces-KSUNv1.1.1-M145FXXS8DYH1.tar.zip) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | +| **OnePlus Ace 3 (Chinese variant)** | PJE110 | ColorOS 15.0 - Android 15 | `5.15.167` | none | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.167_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 12R | | **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/bee3255be1f407e7cff7b0b8f1c26daaccbbb510) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 11R** | CPH2487 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 11R** | CPH2487 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/6821a3751c4fd016a7ee4da5aea635d42a8b3d43) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 11R** | CPH2487 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 11R** | CPH2487 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/c461b745bc361581d0b419effd284367d85f1e57) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/2936373a255da66646a05384e993d55a8dfecc80) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/86a89359565410962004fd838e3d2e3b42a047ca) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/717aea450444dfd00db7d60c99cff77fbcbf014e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13 CPH** | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/cec4a1056a32dfdd68c23767f6c88bb6c698f1bf) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13 CPH** | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13 PJZ** | PJZ110 | Android15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/d68f486cb00b246abf4b7b66375082dc8bcb884e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/35f7c28cf36e19e3243a9db8496d5e70b94e34ce) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13S** | CPH2723 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13S** | CPH2723 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13T** | PKX110 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/06be241a7b9eefa01db78447cc7877339b3685f3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13T** | PKX110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 13T** | PKX110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 15** | CPH2747, CPH2745, PLK110, CPH2749 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 15R** | CPH2769, CPH2767 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus 15T** | PLZ110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE** | PGKM10 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE** | PGKM10 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/e5f7c2a470fd9d8b43c5082aed8b58a6d5a2bf7c) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE** | PGKM10 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2** | PHK110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/ef7ca029d0f6beddf38e60f6b9016935d0f1ac55) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2** | PHK110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2** | PHK110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2 Pro** | PJA110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/95b9da8a7cd99053f65e7d4cebec310b5f5d5744) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 2 Pro** | PJA110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2 Pro** | PJA110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2V** | PHP110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2V** | PHP110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/8a49e1e297768839d78730f15e8f34e0d0b59f2b) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2V** | PHP110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 2V** | PHP110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 3** | PJE110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3a51ce558bfa3a1c7eeaaab69ee54d5e6bd03afb) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3** | PJE110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3** | PJE110 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3dc000f085497a728cdf21b28fab9d3ef632f5fd) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/0d28103245225a507d4f4b3ca657d3e27aae2f20) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/1e88584a40b3b055e5ff3796f9a184655ef2d0b8) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Xiaomi Pad 6S Pro (Chinese variant)** | 24018RPACC | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | Working | [@mlfc666](https://github.com/mlfc666) | Applicable to the international version
    Supports NTsync | +| **Redmi K70 (Chinese variant)** | 23113RKC6C | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | Working | [@mlfc666](https://github.com/mlfc666) | Supports NTsync | +| **Galaxy A25 5G** | SM-A253Z | One UI 8.0 - Android 16 | `5.15.180` | Magisk | [Source](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835) | [Download](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835/releases/download/r20260421-89a95a08/DroidSpaces-SM-A253Z-OneUI8-A253ZSCS1BZA2-r20260421-89a95a08.tar.zip) | Both | none | Working | [@yoshi3jp](https://github.com/yoshi3jp) | - | +| **OnePlus Ace 3 (Chinese variant)** | PJE110 | ColorOS 16.0 - Android 16 | `5.15.180` | none | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.180_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 12R | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2 Pro** | PJA110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3** | PJE110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | Working | [@superuseryu](https://github.com/superuseryu) | Other KSU variants may fail due to SELinux context reset. | +| **6.1 Devices** | - | - | 6.1.x | - | - | - | - | - | - | -| - | +| **红魔 9 SPro+ (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/c461b745bc361581d0b419effd284367d85f1e57) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/d68f486cb00b246abf4b7b66375082dc8bcb884e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3 Pro** | PJX110 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3 Pro** | PJX110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/1cee682fb60cc9ffd86a2342fa8051bcde77116f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/ac51a5e4c2b0f13f06e9234e415bfa68fb24a8b5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/8c797fd3ff3f76248b44a619410ace1277692907) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/a3c5659c859e888102a144be0884fbf3731b170f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE4 Lite** | CPH2619, CPH2621 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 SM8650** | OPD2403 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/f5a170ce44c7657894247de3de41d23a89e248f7) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 MT6897** | OPD2407 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/cbae97472bca105f69ffaef126065f9ab06e1c12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/dcb47d3dcba84e13ae678f18b4629b22333e8898) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Yoga Tab Plus (International variant)** | TB520FU | ZUI 17.5.10.096 (ROW) - Android14 | `6.1.112` | SukiSU-Ultra v4.1.3 (40796) | [Source](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki) | [Download](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/releases/download/v1.0.0/tb520fu-droidspaces-phase2-images.zip) | Both | Turnip | Partial | [@da-ai-xian-zun](https://github.com/da-ai-xian-zun) | Maintainer path verified with **locked bootloader** via 9008 four-image manual flash (not AnyKernel3), see [MANUAL_FLASH.md](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/blob/main/docs/MANUAL_FLASH.md); verified on this ZUI build (ROW, `UKQ1.240826.001`) only—bundled `init_boot`/`vbmeta` are tied to this AVB chain; if bootloader is unlocked, use fastboot with your own `init_boot`/vbmeta for looser AVB constraints; use directory mode for containers; App sparse install unresolved; VirGL not tested | +| **OnePlus ACE 5 Racing** | PLF110 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE5** | CPH2719, CPH2717 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Ace 3 Pro (Chinese variant)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 5 (Chinese variant)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 13R | +| **OnePlus 12 (Chinese variant)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | +| **OnePlus Pad Pro (Chinese variant)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 2 (International version) | +| **Xiaomi Pad 7 (Chinese variant)** | 2410CRP4CC | HyperOS 2.0 - Android 15 | `6.1.118` | Sukisu Ultra | [Source](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS.git) | [Download](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS/releases/tag/release) | Both | Turnip & Zink | Working | [HuangJunLin8](https://github.com/HuangJunLin8) | Supports NTsync | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/2936373a255da66646a05384e993d55a8dfecc80) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/35f7c28cf36e19e3243a9db8496d5e70b94e34ce) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3 Pro** | PJX110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3 Pro** | PJX110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 3V** | PJF110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/ac51a5e4c2b0f13f06e9234e415bfa68fb24a8b5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3V** | PJF110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 3V** | PJF110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/65c3035422d899d95bfde4e30dc426712ffe52f2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 3V** | PJF110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5** | PKG110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/8c797fd3ff3f76248b44a619410ace1277692907) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 5** | PKG110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 5** | PKG110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/7499247fd6e0669a062ec44cce948ec1e9d4c75e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5** | PKG110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5 Pro** | PKR110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5 Racing** | PLF110 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5 Racing** | PLF110 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5 Ultra** | PLC110 | Android15 (OOS/COS 15) | 6.6.50 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/06b1dfbb8f777d329a25d6f932f58f0e45d87627) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5 Ultra** | PLC110 | Android15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 5 Ultra** | PLC110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 6** | PLQ110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 6 Ultra** | PMB110 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE 6T** | PLR110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE Race** | PGZ110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/7a64b08a000cc7040d99543cc7093aaa13e0c786) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/6409af87cfda3739caff4ee29eb28e77abe8a290) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 3** | CPH2491, CPH2493 | Android14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 3** | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/b9b40be7bbe4e2c201ba263ac72f2e1b10171998) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 3** | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 4** | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/a3c5659c859e888102a144be0884fbf3731b170f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Nord 4** | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Nord 4** | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/975bcbc2e288c3b7c2d2799421c7f356f9c78ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 4** | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/d8187f64c9e60fee0969a678a67ac77114a926d9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/0d28103245225a507d4f4b3ca657d3e27aae2f20) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 4 CE** | CPH2613 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Nord 5** | CPH2709, CPH2707 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Nord 5** | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/fbeaa9ff25bef199618b9e47004f2eb8fe135f9f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 5** | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord 6** | CPH2793, CPH2795 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord CE4 Lite** | CPH2619, CPH2621 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Nord CE4 Lite** | CPH2619, CPH2621 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_b_16.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord CE5** | CPH2719, CPH2717 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord CE5** | CPH2719, CPH2717 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Nord N30 SE** | CPH2605 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Open** | CPH2551 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/2a845b8afd7afc3d72679ddbe5adb07c390966bc) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Open** | CPH2551 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Open** | CPH2551 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/1e88584a40b3b055e5ff3796f9a184655ef2d0b8) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Open** | CPH2551 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad 2 MT6991** | OPD2508 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad 2 Pro** | OPD2413 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/68c46d941cc1818d49f8a8805c79e63fbee01eac) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad 2 Pro** | OPD2413 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad 2 Pro** | OPD2413 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad 2 SM8650** | OPD2403 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/f5a170ce44c7657894247de3de41d23a89e248f7) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Pad 2 SM8650** | OPD2403 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Pad 2 SM8650** | OPD2403 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad 3 MT6897** | OPD2407 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/cbae97472bca105f69ffaef126065f9ab06e1c12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Turbo 6V** | PLY110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **红魔 9 Pro (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | | **OnePlus Pad 3 MT6897** | OPD2407 | Android15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Racing** | PLF110 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE5** | CPH2719, CPH2717 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Pad 3 MT6897** | OPD2407 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad 3 Pro** | OPD2513 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Go 2** | OPD2504, OPD2505 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6878/tree/oneplus/mt6878_b_16.0.0_pad_go_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Xiaomi 14 (Chinese variant)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | Working | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | Applicable to the international version | +| **OnePlus Ace 3 Pro (Chinese variant)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 5 (Chinese variant)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 13R | +| **OnePlus 12 (Chinese variant)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | +| **OnePlus Pad Pro (Chinese variant)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 2 (International version) | +| **Realme GT 5 Pro (Chinese variant)** | RMX3888 | RealmeUI 7.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 5** | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus SM8650 (All LineageOS OnePlus 6.1 Devices)** | SM8650 | LineageOS 23.2 - Android 16 QPR 2 | `6.1.166` | none | [Source](https://github.com/LineageOS/android_kernel_oneplus_sm8650) | [Download](https://github.com/linx3141/CRom-KSU-Builder/releases/tag/sm8650_LineageOS-lineage-23.2_None_20260512_1647) | Both | Turnip & Virgl | Working | [@linx3141](https://github.com/linx3141) | - | +| **6.6 Devices** | - | - | 6.6.x | - | - | - | - | - | - | -| - | +| **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord N30 SE** | CPH2605 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/68c46d941cc1818d49f8a8805c79e63fbee01eac) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Pad 3 SM8750** | OPD2415 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/35366be96d7e73d1d0d7093e1cf2fafd58f9c7d4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android15 (OOS/COS 15) | 6.6.50 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/06b1dfbb8f777d329a25d6f932f58f0e45d87627) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 CPH** | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/cec4a1056a32dfdd68c23767f6c88bb6c698f1bf) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13S** | CPH2723 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/06be241a7b9eefa01db78447cc7877339b3685f3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Pad 3 SM8750** | OPD2415 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 (Chinese variant)** | PJZ110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | +| **OnePlus Ace 5 Pro (Chinese variant)** | PKR110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13T (Chinese variant)** | PKX110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13 PJZ** | PJZ110 | Android15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Xiaomi Pad 8 Pro (Chinese variant)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu & NoRoot | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
    [NoRoot](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
    Supports NTsync
    ReSukiSU version 34924 | +| **Xiaomi 15 (Chinese variant)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu & NoRoot | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
    [NoRoot](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
    Supports NTsync
    ReSukiSU version 34924 | +| **OnePlus Pad 2 Pro (Chinese variant)** | OPD2413 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 3 | +| **OnePlus 13 (Chinese variant)** | PJZ110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | +| **OnePlus Ace 5 Pro (Chinese variant)** | PKR110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13T (Chinese variant)** | PKX110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 6 (Chinese variant)** | PLR110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 CPH** | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13S** | CPH2723 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6** | PLQ110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 6** | CPH2793, CPH2795 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 MT6991** | OPD2508 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Pad 3 SM8750** | OPD2415 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad 4** | OPD2514 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad Go** | OPD2304, OPD2305 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_u_14.0.0_onepluspad_go) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad Go 2** | OPD2504, OPD2505 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6878/tree/oneplus/mt6878_b_16.0.0_pad_go_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad Lite** | OPD2480, OPD2481 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad Lite** | OPD2480, OPD2481 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad MT6983** | OPD2203 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad MT6983** | OPD2203 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/4790201712d757f4f337cd55e5709255edc8bee5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad MT6983** | OPD2203 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad MT6983** | OPD2203 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad Pro** | OPD2404 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad Pro** | OPD2404 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/dcb47d3dcba84e13ae678f18b4629b22333e8898) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad Pro** | OPD2404 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Pad Pro** | OPD2404 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Turbo 6** | PLU110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **OnePlus Turbo 6V** | PLY110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| **Yoga Tab Plus (International variant)** | TB520FU | ZUI 17.5.10.096 (ROW) - Android14 | `6.1.112` | SukiSU-Ultra v4.1.3 (40796) | [Source](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki) | [Download](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/releases/download/v1.0.0/tb520fu-droidspaces-phase2-images.zip) | Both | Turnip | Partial | [@da-ai-xian-zun](https://github.com/da-ai-xian-zun) | Maintainer path verified with **locked bootloader** via 9008 four-image manual flash (not AnyKernel3), see [MANUAL_FLASH.md](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/blob/main/docs/MANUAL_FLASH.md); verified on this ZUI build (ROW, `UKQ1.240826.001`) only—bundled `init_boot`/`vbmeta` are tied to this AVB chain; if bootloader is unlocked, use fastboot with your own `init_boot`/vbmeta for looser AVB constraints; use directory mode for containers; App sparse install unresolved; VirGL not tested | - +| **6.12 Devices** | - | - | 6.12.x | - | - | - | - | - | - | -| - | +| **Xiaomi 17 (Chinese variant)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | +| **Xiaomi 17 Pro (Chinese variant)** | 2509FPN0BC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **Xiaomi 17 Pro max (Chinese variant)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **红魔 11 Pro (Chinese variant)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **HONOR Magic Pad 3 Pro 13.3 (Chinese variant)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 15** | CPH2747, CPH2745, PLK110, CPH2749 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Lite** | OPD2480, OPD2481 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 15R** | CPH2769, CPH2767 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 15T** | PLZ110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6T** | PLR110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6 Ultra** | PMB110 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 Pro** | OPD2513 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 4** | OPD2514 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | ## Contribution guidelines To keep this list useful and reliable, please follow these rules when adding or updating entries: From bc6c536a7187a33de9829057c87e5bfd42d31ebc Mon Sep 17 00:00:00 2001 From: Jesse205 <51242302+Jesse205@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:15:38 +0800 Subject: [PATCH 070/211] docs: remove garbled characters from the Android installation guide (#223) --- Documentation/zh-CN/Installation-Android.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/zh-CN/Installation-Android.md b/Documentation/zh-CN/Installation-Android.md index 70efd76c..da72811e 100644 --- a/Documentation/zh-CN/Installation-Android.md +++ b/Documentation/zh-CN/Installation-Android.md @@ -67,7 +67,7 @@ Droidspaces 在 Android 上旨在提供"零终端"体验。从首次安装到运 你可以随时验证系统状态: 1. 前往**设置**(齿轮图标)-> **需求**。 2. 点击**检查需求**。这会在内部运行完整的 `droidspaces check` 套件。 -3. **内核配置**:如果你是一位内核开发者,你可以找到一���可复制的 `droidspaces.config` defconfig 片段,类似于[此页面](./Kernel-Configuration.md#required-kernel-configuration),以确保你的内核与 Droidspaces 完全兼容。 +3. **内核配置**:如果你是一位内核开发者,你可以找到可复制的 `droidspaces.config` defconfig 片段,类似于[此页面](./Kernel-Configuration.md#required-kernel-configuration),以确保你的内核与 Droidspaces 完全兼容。 ## 下一步 From 4206f21f6b8f8d0e203591688afbf27fc39011bc Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 28 Jun 2026 18:15:58 +0200 Subject: [PATCH 071/211] Added translation using Weblate (Tamil) (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added translation using Weblate (Tamil) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (502 of 502 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (502 of 502 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (502 of 502 strings) Translated using Weblate (German) Currently translated at 97.6% (490 of 502 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/de/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hant/ Translation: Droidspaces/Strings Co-authored-by: Jung Chi Wang Co-authored-by: MonkiFantasy Co-authored-by: Sandmann79 Co-authored-by: தமிழ்நேரம் --- Android/app/src/main/res/values-de/strings.xml | 2 +- Android/app/src/main/res/values-ta/plurals.xml | 3 +++ Android/app/src/main/res/values-ta/strings.xml | 3 +++ Android/app/src/main/res/values-zh-rCN/strings.xml | 3 +++ Android/app/src/main/res/values-zh-rTW/strings.xml | 3 +++ 5 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 Android/app/src/main/res/values-ta/plurals.xml create mode 100644 Android/app/src/main/res/values-ta/strings.xml diff --git a/Android/app/src/main/res/values-de/strings.xml b/Android/app/src/main/res/values-de/strings.xml index 7d39b3e0..207ba370 100644 --- a/Android/app/src/main/res/values-de/strings.xml +++ b/Android/app/src/main/res/values-de/strings.xml @@ -264,7 +264,7 @@ Kommuniziert direkt mit dem Host Linux-Kernel. Kein Hypervisor, keine Virtualisierungsschicht. Von Grund auf unkillbar Unterstützt die native Integration von init.rc und zweistufige Daemons im Userspace. Absolut unkillbar. - systemd / OpenRC mit PID 1 + systemd / OpenWrt procd / OpenRC mit PID 1 Vollständige Unterstützung des Init-Systems. Echte Dienstverwaltung, keine simulierte Umgebung. Vollständige Isolation Privater PID-Baum, Einhängepunkte, Hostname, IPC und cgroup-Hierarchie pro Container. diff --git a/Android/app/src/main/res/values-ta/plurals.xml b/Android/app/src/main/res/values-ta/plurals.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/Android/app/src/main/res/values-ta/plurals.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Android/app/src/main/res/values-ta/strings.xml b/Android/app/src/main/res/values-ta/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/Android/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index 0be64270..7cb5a704 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -499,4 +499,7 @@ 没有未知状态的服务 已检测到 OpenWrt/procd OpenWrt + 磁盘占用 + 已使用 %1$s / %2$s + 计算中… diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index a2f2d13e..1b29168a 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -494,4 +494,7 @@ 網關介面 eth1 主機網橋 + 磁碟使用情況 + %1$s / %2$s 已使用 + 正在計算… From ea1c703976becd300463d902bd3d6ef97223b11e Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 29 Jun 2026 08:50:32 +0200 Subject: [PATCH 072/211] Update translation files (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated by "Remove blank strings" add-on in Weblate. Update translation files Updated by "Remove blank strings" add-on in Weblate. Translated using Weblate (Tamil) Currently translated at 100.0% (502 of 502 strings) Translated using Weblate (Tamil) Currently translated at 100.0% (502 of 502 strings) Translated using Weblate (Tamil) Currently translated at 100.0% (3 of 3 strings) Translated using Weblate (Pangasinan (pag_PH)) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (Pangasinan (pag_PH)) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (Iloko (ilo_PH)) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (Iloko (ilo_PH)) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (Filipino) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (Filipino) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (Cebuano (ceb_PH)) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (Cebuano (ceb_PH)) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (cbk_PH (generated) (cbk_PH)) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (cbk_PH (generated) (cbk_PH)) Currently translated at 45.6% (229 of 502 strings) Translated using Weblate (Portuguese) Currently translated at 49.4% (248 of 502 strings) Translated using Weblate (Portuguese) Currently translated at 49.4% (248 of 502 strings) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (502 of 502 strings) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (502 of 502 strings) Translated using Weblate (Italian) Currently translated at 86.4% (434 of 502 strings) Translated using Weblate (Italian) Currently translated at 86.4% (434 of 502 strings) Translated using Weblate (Indonesian) Currently translated at 99.4% (499 of 502 strings) Translated using Weblate (Indonesian) Currently translated at 99.4% (499 of 502 strings) Translated using Weblate (Indonesian) Currently translated at 66.6% (2 of 3 strings) Translated using Weblate (Japanese) Currently translated at 82.2% (413 of 502 strings) Translated using Weblate (Japanese) Currently translated at 82.2% (413 of 502 strings) Translated using Weblate (Japanese) Currently translated at 66.6% (2 of 3 strings) Translated using Weblate (Sinhala) Currently translated at 33.3% (1 of 3 strings) Translated using Weblate (Khmer (Central)) Currently translated at 89.8% (451 of 502 strings) Translated using Weblate (Khmer (Central)) Currently translated at 89.8% (451 of 502 strings) Translated using Weblate (Romanian) Currently translated at 72.9% (366 of 502 strings) Translated using Weblate (Romanian) Currently translated at 72.9% (366 of 502 strings) Translated using Weblate (Romanian) Currently translated at 66.6% (2 of 3 strings) Translated using Weblate (Sinhala) Currently translated at 62.7% (315 of 502 strings) Translated using Weblate (Sinhala) Currently translated at 62.7% (315 of 502 strings) Translated using Weblate (Turkish) Currently translated at 62.3% (313 of 502 strings) Translated using Weblate (Turkish) Currently translated at 62.3% (313 of 502 strings) Translated using Weblate (Turkish) Currently translated at 62.3% (313 of 502 strings) Translated using Weblate (Turkish) Currently translated at 33.3% (1 of 3 strings) Translated using Weblate (Russian) Currently translated at 33.3% (1 of 3 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 33.3% (1 of 3 strings) Translated using Weblate (Hindi) Currently translated at 33.3% (1 of 3 strings) Translated using Weblate (French) Currently translated at 33.3% (1 of 3 strings) Translated using Weblate (Spanish) Currently translated at 33.3% (1 of 3 strings) Translated using Weblate (Russian) Currently translated at 45.0% (226 of 502 strings) Translated using Weblate (Russian) Currently translated at 45.0% (226 of 502 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 45.0% (226 of 502 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 45.0% (226 of 502 strings) Translated using Weblate (Hindi) Currently translated at 46.8% (235 of 502 strings) Translated using Weblate (Hindi) Currently translated at 46.8% (235 of 502 strings) Translated using Weblate (French) Currently translated at 45.0% (226 of 502 strings) Translated using Weblate (French) Currently translated at 45.0% (226 of 502 strings) Translated using Weblate (Spanish) Currently translated at 45.0% (226 of 502 strings) Translated using Weblate (Spanish) Currently translated at 45.0% (226 of 502 strings) Translated using Weblate (German) Currently translated at 97.6% (490 of 502 strings) Translated using Weblate (German) Currently translated at 97.6% (490 of 502 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/es/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/fr/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/hi/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/id/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/ja/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/pt_BR/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/ro/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/ru/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/si/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/ta/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/tr/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/cbk_PH/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/ceb_PH/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/de/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/es/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/fil/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/fr/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/hi/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/id/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/ilo_PH/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/it/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/ja/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/km/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/pag_PH/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/pt/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/pt_BR/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/ro/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/ru/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/si/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/ta/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/tr/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hant/ Translation: Droidspaces/Plurals Translation: Droidspaces/Strings Co-authored-by: Ravindu Deshan Co-authored-by: Yusuf Co-authored-by: தமிழ்நேரம் --- .../app/src/main/res/values-ta/plurals.xml | 14 +- .../app/src/main/res/values-ta/strings.xml | 498 +++++++++++++++++- 2 files changed, 510 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/res/values-ta/plurals.xml b/Android/app/src/main/res/values-ta/plurals.xml index 55344e51..78e6aa09 100644 --- a/Android/app/src/main/res/values-ta/plurals.xml +++ b/Android/app/src/main/res/values-ta/plurals.xml @@ -1,3 +1,15 @@ - \ No newline at end of file + + %d உறுதி + %d உறுதியளிக்கிறது + + + %d அமர்வு இயங்குகிறது + %d அமர்வுகள் இயங்குகின்றன + + + %d கொள்கலன் + %d கொள்கலன்கள் + + diff --git a/Android/app/src/main/res/values-ta/strings.xml b/Android/app/src/main/res/values-ta/strings.xml index 55344e51..fc4b33ac 100644 --- a/Android/app/src/main/res/values-ta/strings.xml +++ b/Android/app/src/main/res/values-ta/strings.xml @@ -1,3 +1,499 @@ - \ No newline at end of file + ரூட் அணுகல் சோதனை + எந்த லினக்ச் திறன்களையும் (முழு ரூட் சக்தி) கைவிட வேண்டாம். + சேமிப்பக கட்டமைப்பு + நீங்கள் என்ன செய்கிறீர்கள் என்பது உங்களுக்குத் தெரியாவிட்டால் இதை மாற்ற வேண்டாம். தவறான init பைனரியை அமைப்பது, கொள்கலன் பூட் செய்வதைத் தடுக்கும். + %1$d மாறி(கள்) உள்ளமைக்கப்பட்டது + நிராகரி + ரூட் அணுகல் மறுக்கப்பட்டது + SELinux இசைவு + தெரியாத நிலையில் சேவைகள் இல்லை + தொகுதிக் கோப்புகளைப் பிரித்தெடுக்கிறது... + பதிவிறக்கத்தை மீண்டும் தொடங்க முடியாது + தார்பால் + ரேம் பயன்பாடு + விருப்பத்தேர்வு (எ.கா. 8080 அல்லது 1000-2000) + மூடு + ஆண்ட்ராய்டு க்கான LXC-பாணி கொள்கலன் மேலாளர் + OLED காட்சிகளுக்கான தூய கருப்பு பின்னணி + மதிப்பாய்வு கட்டமைப்பு + பராமரிப்பாளர் + மாற்றங்களைச் சேமிக்கவும் + \"%1$s\" ஐ ஒரு சிறிய படத்திற்கு மாற்றுவது ஒரு பிரத்யேக ext4 கோப்பு முறைமையை உருவாக்கும். இந்த செயல்பாடு குறுக்கிடப்பட்டால் அழிவுகரமானது. உங்கள் தரவை காப்புப் பிரதி எடுக்கவும். + மாறியைச் சேர்க்கவும் + Droidspacesக்கு புதியதா? + உள்ளமை பெயர்வெளி உருவாக்கத்தைத் தடுப்பதன் மூலம் மரபு கர்னல்களில் (எ.கா., 4.14.x) கர்னல்-நிலை VFS முட்டுக்கட்டைகளைத் தடுக்கிறது. \n\nஎச்சரிக்கை: இதை இயக்குவது கொள்கலனுக்குள் Docker, Podman, LXC மற்றும் Systemd சாண்ட்பாக்சிங் ஆதரவைத் தடுக்கும். + அகற்று + பின்தளம்: நடவடிக்கை தேவை + தெரியவில்லை + VirGL கூடுதல் கொடிகள் + பதிவுகளைப் பார்க்கவும் + இயல்புநிலைக்கு காலியாக விடவும் + இந்த ஐபி ஏற்கனவே \"%1$s\"க்கு ஒதுக்கப்பட்டுள்ளது + அசாதாரணமானது + பின்தளம்: நிறுவப்படவில்லை + கொள்கலன் தகவலைப் படிக்க முடியவில்லை + அரிதான படத்தைப் பயன்படுத்தவும் + முகவரி கண்டிப்பாக https:// உடன் தொடங்க வேண்டும் + நான் எந்த பிழைகளையும் புகாரளிக்க மாட்டேன் + VirGL 3D முடுக்கத்தை உள்ளமைக்கவும் + Force Cgroup V1 + ஆண்ட்ராய்டு 12+ Monet தீமிங்கைப் பயன்படுத்தவும் (மெட்டீரியல் யூ) + Init அமைப்பு + Termux:X11க்கு X11 சாக்கெட் மவுண்டிங்கை இயக்கு + கொள்கலனில் எழுத்துக்கள், எண்கள், ஐபன்கள் (-), அடிக்கோடுகள் (_), புள்ளிகள் (.) மற்றும் இடைவெளிகள் மட்டுமே இருக்க வேண்டும் + பின்தளம்: நிறுவப்பட்டது + முழு வன்பொருள்/சிபியு அணுகல் + கொள்கலனை நாடாகாப்பகபந்து ஆக ஏற்றுமதி செய்யவும் + லினக்ச் கொள்கலன்கள். பழமை. மேல்நிலை இல்லை. + சேமிப்பு + ச்பார்ச் படத்திற்கு நகர்த்தவும் + சிபியு பயன்பாடு + டிராய்ச்பேச்களை தொடர்ந்து ஆய்வு செய்ய ரூட் அணுகலை வழங்கவும்! + தனிப்பட்ட PID மரம், மவுண்ட் டேபிள், ஓச்ட்பெயர், IPC மற்றும் ஒரு கொள்கலனுக்கு cgroup படிநிலை. + எ.கா. --மல்டி-கிளையண்ட்ச் --ஆங்கிள்-வல்கன் + துறைமுகம் ஒரு எண்ணாக இருக்க வேண்டும் + உண்மையான லினக்ச் கொள்கலன்கள் + ஒரு ச்பேர்ச் படமானது ஒரு பிரத்யேக ext4 கோப்பு முறைமை படக் கோப்பை உருவாக்குகிறது, அது ஒரு லூப் சாதனமாக ஏற்றப்பட்டு, சிறந்த தனிமைப்படுத்தல் மற்றும் இணக்கத்தன்மையை வழங்குகிறது. + பைனரியை நிறுவுகிறது: %1$s + போதிய சேமிப்பு இல்லை + SELinux அனுமதியை அமைக்கவும் + தொடங்குங்கள் + முனையத்தைத் திறக்கவும் + Droidspaces க்கான தேவைகள் + /proc மற்றும் /sys க்கு எழுதும் அணுகலை அனுமதிக்க சிறை முகமூடிகளை முடக்கவும். குறிப்பு: வன்பொருள் அணுகலும் இயக்கப்பட்டாலன்றி, /sys படிக்க மட்டுமே இருக்கும். + பிணையம் பிழை + HW + Droidspaces v6.1.0 இலிருந்து தொடங்கி, udevd ஆனது USB, block, input, TTY மற்றும் பிணைய துணை அமைப்புகளுக்கான உண்மையான அணுகலைக் கொண்டுள்ளது. உங்கள் கன்டெய்னர் ஆண்ட்ராய்டு தற்போது பயன்படுத்தும் வன்பொருளை சொந்தமாக வைத்திருக்க முடியும். \n\nஉங்கள் விசைப்பலகை, SD அட்டை அல்லது USB சாதனம் ஒரு கண்டெய்னரை அறிமுகப்படுத்திய பிறகு ஆண்ட்ராய்டு இல் வேலை செய்வதை நிறுத்தினால் - அது ஒரு பிழை அல்ல. கொள்கலன் அதை எடுத்தது. முழு வன்பொருள் அணுகலைக் கேட்டுள்ளீர்கள். \n\nஇந்த பயன்முறையைப் பயன்படுத்தும் போது வன்பொருள் முரண்பாடுகள் அல்லது கர்னல் பீதியால் ஏற்படும் சிக்கல்களைப் புகாரளிக்க வேண்டாம். + அனைத்து சலுகை பெற்ற கொடிகளையும் ஒரே நேரத்தில் இயக்கவும். + அரிதான படத்தின் அளவை மாற்றவும் + முகமூடி + புரவலன் வன்பொருளை (GPU, சென்சார்கள், USB, பிளாக் சாதனங்கள்) நேரடியாக உங்கள் கொள்கலன்களுக்கு வெளிப்படுத்துங்கள். + பைண்ட் மவுண்ட்டைச் சேர்க்கவும் + டீமான் பயன்முறை + Seccomp syscall வடிகட்டலை முடக்கவும். இது, உள்ளமை சாண்ட்பாக்சிங்கிற்கு (Bubblewrap, Flatpak) கண்டிப்பாகத் தேவைப்படும் சலுகையற்ற பயனர் பெயர்வெளிகள் போன்ற மேம்பட்ட கர்னல் அம்சங்களை செயல்படுத்துகிறது மற்றும் மரத்திற்கு வெளியே உள்ள கர்னல் தொகுதிகளை ஏற்றவும் அனுமதிக்கிறது. + கர்னல் + கோப்பு ஏற்கனவே உள்ளது + %1$s %2$s இல் தோல்வியடைந்தது + கொள்கலன் செய்தி + கொள்கலனைத் திருத்து: %1$s + ஆபத்தான பிரதேசம் + நுழைவாயில் உள்ள இடைமுகம் + ஆண்ட்ராய்டு ஓச்டில் உருவாக்கப்பட்ட மெய்நிகர் சுவிட்சின் பெயர். LAN பெயரிலிருந்து (ds-<lan>) தானாகப் பெயரிட காலியாக விடவும். உங்களுக்கு சரியான பாலத்தின் பெயர் தேவைப்பட்டால் மட்டுமே இதை அமைக்கவும். அதிகபட்சம் 15 எழுத்துகள். + Systemd/OpenWrt/OpenRC கிடைக்கவில்லை + பயனரைத் தேர்ந்தெடுக்கவும் + கொள்கலன் அமைப்பு + சேவையை மீண்டும் ஏற்றவும் + கொள்கலனை நிறுவல் நீக்கவும் + உள்ளக பயனர்கள் இல்லை + எ.கா. /mnt/sdcard + Droidspaces ஐ நிறுவுதல் + சேமிக்கப்பட்டது + பிழை அறிக்கை + கொள்கலன்கள் நிறுவப்படவில்லை + கொள்கலனை உள்ளிட பயனரைத் தேர்ந்தெடுக்கவும்: + நுழைவாயில் கொள்கலனைத் தேர்ந்தெடுக்கவும் + systemd / OpenWrt procd / OpenRC என PID 1 + ஐபி முகவரி + \"Droidspaces உருவாக்குபவர்கள்\" கீழ் Rootfs tarballs திறந்த மூல மற்றும் வெளிப்படையானவை. இந்தக் கார்டைத் தட்டுவதன் மூலம் எங்கள் மாற்றங்களை நீங்கள் கைமுறையாகப் பதிவிறக்கலாம் அல்லது ஆய்வு செய்யலாம். + இந்த கொள்கலனில் IPv6 ஐ முடக்கவும். IPv6 ஐ முடக்குவது ஓச்டில் இயங்கும் VPN பயன்பாடுகளை உடைக்கக்கூடும். + ஒரு கொள்கலன் அதன் சொந்த நுழைவாயிலாக இருக்க முடியாது + விருப்ப துவக்கம் + மீண்டும் முயற்சிக்கவும் + கணினி கருப்பொருள் தானாக பொருந்தும் + பற்றி + IPv6 ஐ முடக்கு + GKI கர்னல் தேவைகள் (>=5.4) + (சமச்சீர்) + 172.28.x.x சப்நெட்டில் இந்தக் கொள்கலனுக்கான நிலையான IPv4 ஐ அமைக்கவும். முதல் துவக்கத்தில் தானாக ஒதுக்க அதை காலியாக விடவும். + லேன் பெயர் + உள்ளமைக்கப்பட்ட மெய்நிகராக்கம் அல்லது மேம்பட்ட வன்பொருள் அணுகலுக்கான சிறுமணி பாதுகாப்பு தளர்வை உள்ளமைக்கவும். + GPU முனைகளை தனிமைப்படுத்தப்பட்ட / dev இல் பிரதிபலிக்கவும் (முழு வன்பொருள் பாச்த்ரூ இல்லை) + முழு சலுகை பெற்ற கொள்கலன்கள் + தெளிவு + eth1 + Droidspaces rootfs பதிவிறக்கம் + தெரியவில்லை + தொடக்கம் முடிவை விட குறைவாக இருக்க வேண்டும் + துறைமுகங்கள் எண்களாக இருக்க வேண்டும் + ரன்-அட்-பூட் கொள்கலன்கள் தொடங்கும் வரிசை + மறுதொடக்கத்திற்குப் பிறகு மாற்றங்கள் நடைமுறைக்கு வரும் + பின்தளம் கிடைக்கவில்லை + இயக்கப்பட்ட சேவைகள் இல்லை + உள்நுழைவை நகலெடுக்கவும் + நிறுவல் பாதை + %1$s பயனருக்கு உள்நுழைவு கட்டளை நகலெடுக்கப்பட்டது + தொகுதியை நிறுவுதல் + இடைமுகம் ஏற்கனவே மற்றொரு பிரிவால் பயன்படுத்தப்பட்டது (\'%1$s\'). வேறு ஒன்றைப் பயன்படுத்தவும். + பின்பக்கம்: சரிபார்க்கிறது... + கொள்கலன்கள் + மறைகுறியாக்கப்பட்ட சேமிப்பகம், F2FS /தரவு பகிர்வுகள் அல்லது நேரடி அடைவு மவுண்டிங்கில் பொருந்தக்கூடிய சிக்கல்களைக் கொண்ட பிற கோப்பு முறைமைகளில் கண்டெய்னர்களைப் பயன்படுத்தும் போது அரிதான படங்கள் பரிந்துரைக்கப்படுகின்றன. + களஞ்சியத்தின் பெயர் + பைனரி நிறுவல் தோல்வியடைந்தது + கணினி உள்ளமைவை சரிசெய்ய தட்டவும் + %1$s / %2$s பயன்படுத்தப்பட்டது + தேவைகள் சரிபார்ப்பு வெற்றிகரமாக முடிந்தது + நிறுவலைச் சரிபார்க்கிறது... + முழு வன்பொருள் அணுகல் + புதுப்பிப்பு + முழு init கணினி உதவி. உண்மையான பணி மேலாண்மை, உருவகப்படுத்தப்பட்ட சூழல் அல்ல. + அறிவிலிமையம் + %1$d sessions running · tap பெறுநர் restore + முடக்கப்பட்டது + மேசிச்க் தொகுதி வெற்றிகரமாக நிறுவப்பட்டது + இடம்பெயர்வு செயல்பாட்டைத் தொடங்குகிறது... + வெடிமையுடைய + எந்த கொள்கலன்களும் ரன் அட் பூட்டில் இயக்கப்படவில்லை. + நுழைவாயில் கொள்கலன் + கர்னல் தேவைகள் + மீட்டமை + இந்த LAN பிரிவுக்கான சிட்டை. ஒரே கேட்வே மற்றும் லேன் பெயரைப் பகிர்ந்து கொள்ளும் கன்டெய்னர்கள் ஒரே மெய்நிகர் சுவிட்சில் இணைகின்றன, மேலும் ஒன்றையொன்று பார்க்க முடியும். தனிமைப்படுத்தப்பட்ட இரண்டாவது பிரிவைச் சுழற்ற வேறு பெயரைப் பயன்படுத்தவும். இயல்புநிலை: lan. + நகலெடு + அடைவு + இந்த விருப்பங்களை இயக்குவதன் மூலம், உங்கள் சாதனத்தின் பாதுகாப்பை நீங்கள் வேறுபாடின்மை செய்கிறீர்கள். தரவு இழப்பு, ப்ரிக் செய்யப்பட்ட சாதனங்கள் அல்லது வித்தியாசமான பிழைகளுக்கு நாங்கள் பொறுப்பல்ல. இந்த முறைகளைப் பயன்படுத்தும் போது ஏற்பட்ட சிக்கல்களைப் புகாரளிக்க வேண்டாம். + சரக்குப் பெட்டகம் + Cgroup V1 + கோப்பு கிடைக்கவில்லை (404) + கொள்கலனை நிறுவவும் + கீழே உள்ள பொத்தானைப் பயன்படுத்தி கொள்கலனை நிறுவவும் + இயக்கப்பட்டது + TCP + கிடைக்கும் பயனர்கள் + ரூட் அணுகல் வழங்கப்பட்டது + துறைமுகம் பகிர்தல் + கண்டறியப்பட்ட கட்டமைப்பு: %1$s + OpenRC சேவைகள் + விர்சிஎல் + பின்தளத்தில் புதுப்பிப்பு உள்ளது, புதுப்பிக்க இங்கே சொடுக்கு செய்யவும் + இசைவு வழங்கவும் + தொகுதியை நிறுவுகிறது... + SELinux விதிகளைப் பயன்படுத்த மறுதொடக்கம் பரிந்துரைக்கப்படுகிறது மற்றும் துவக்கத்தில் டீமானைத் தொடங்கவும். + புரவலன் பாலம் + ரேம்: + %1$s களஞ்சியத்தில் இல்லை + பல்ச் ஆடியோவை உள்ளமைக்கவும் + இந்த கொள்கலனில் systemd, OpenWrt/procd அல்லது OpenRC நிறுவப்படவில்லை + ஆக்டெட் %1$d + LXC, Podman மற்றும் Docker போன்ற அதே கர்னல் பெயர்வெளி தொழில்நுட்பம். எமுலேசன் இல்லை, க்ரூட் இல்லை. + Droidspaces பின்தளம் வெற்றிகரமாக நிறுவப்பட்டது + பிணையம் பயன்முறை + தேவைகளை சரிபார்க்கவும் + மூலக் குறியீடு + Droidspaces ஆனது உங்கள் முனைய அமர்வுகளை பின்னணியில் இயங்க வைப்பதற்கு ஒரு நிலையான அறிவிப்பைக் காட்ட வேண்டும். இது இல்லாமல், ஆண்ட்ராய்டு (குறிப்பாக சாம்சங் சாதனங்களில்) எதிர்பாராத விதமாக டெர்மினல் சேவையை அழிக்கக்கூடும். + கொள்கலனை நிறுவல் நீக்கவா? + மறுதொடக்கம் + தவறான எண் + முழுமையான பாதையை வடிகட்டவும் அல்லது தட்டச்சு செய்யவும்… + எழுத்துக்கள், இலக்கங்கள், _ அல்லது - ஆகியவற்றை மட்டும் பயன்படுத்தவும் + விருந்தோம்பி + நீங்கள் இப்போது Droidspaces ஐப் பயன்படுத்தலாம் + HTTP தரவு பிழை + இது \"%1$s\" கண்டெய்னரையும் அதன் எல்லா தரவையும் நிரந்தரமாக நீக்கும். இந்தச் செயலைச் செயல்தவிர்க்க முடியாது. \n\nகொள்கலன் இயங்கினால், அது முதலில் நிறுத்தப்படும். + இந்த இணைப்பின் இடைமுகப் பெயர் கேட்வே கொள்கலனின் உள்ளே (எ.கா. OpenWRT இல் eth1) எனக் காட்டுகிறது. இது DHCP இல் பணி செய்ய உங்கள் நுழைவாயில் அமைக்கப்பட்ட இடைமுகத்துடன் பொருந்த வேண்டும். ஒரே நுழைவாயிலில் உள்ள ஒவ்வொரு தனிப் பிரிவுக்கும் அதன் சொந்தப் பெயர் தேவை. இயல்புநிலை: eth1. + களஞ்சியத்தை ஏற்ற முடியவில்லை + புரவலன்பெயர் + நிறுவல் நீக்குகிறது... + வன்பொருள் அணுகல் + சேமிப்பிடம் கிடைக்கவில்லை + பின் + அதை இங்கே பார்க்க முதலில் ஒரு கொள்கலனைத் தொடங்கவும் + Magisk/APatch, GrapheneOS, போன்றவற்றிலிருந்து seccomp பிளாக்சைத் தவிர்க்க, Droidspaces ஒரு நிலையான கணினி-நிலை டீமானாக இயக்கவும். இது பயனர் இடத்திலிருந்து கட்டளைகளைப் பெற்று இயக்குவதன் மூலம் உயர்-சலுகை செயல்பாடுகளை செயல்படுத்துகிறது (மறுதொடக்கம் தேவை). + மறுவரிசைப்படுத்த இழுக்கவும் + கணக்கிடுகிறது… + கேட்வேயின் மெய்நிகர் LAN இல் இந்தக் கொள்கலன் எவ்வாறு செருகப்படுகிறது என்பதை இவை கட்டுப்படுத்துகின்றன. இயல்புநிலைகள் ஒரு நுழைவாயிலுக்குப் பின்னால் ஒரு லேனுக்கு வேலை செய்கின்றன - பல லேன் பிரிவுகள் அல்லது ஒன்றுக்கு மேற்பட்ட கேட்வேகளை இயக்கும் போது மட்டுமே அவற்றை மாற்றவும். + நுழைவாயிலை உள்ளமைக்கவும் + OpenWrt/procd கண்டறியப்பட்டது + தொடங்கு + அதிகபட்ச அளவு 512 சிபி + கொள்கலன் பெயர் * + தோற்றம் + ரூட் அணுகலை சரிபார்க்க கீழே சொடுக்கு செய்யவும் + ஓச்ட்பெயரில் எழுத்துக்கள், எண்கள் மற்றும் கோடுகள் மட்டுமே இருக்க வேண்டும் (-) + துறைமுகங்கள் 1-65535 ஆக இருக்க வேண்டும் + இயக்க நேரம் + ஓச்டுக்கு MS_SHARED மவுண்ட் பிரசாரத்தை இயக்கவும். + பதிப்பு + நிறுவவும் + எனக்கு புரிகிறது + அரிதான படத்திற்கு நகர்த்தவும் + படிக்க மட்டும் + ரேம் மட்டும் பயன்முறை (நிறுத்தத்திற்குப் பிறகு மாற்றங்கள் மறைந்துவிடும்) + சாதன வடிகட்டலைத் தவிர்க்கவும். அனைத்து புரவலன் /டெவ் முனைகளும் தெரியும். + சரி + நுழைவாயிலை உள்ளமைக்கவும் + அறுவை மருத்தீடு வெற்றிகரமாக முடிந்தது! + நிறுத்தப்பட்டது + பைனரிகள் மற்றும் துவக்க தொகுதியை மீண்டும் நிறுவவும் + டெட்லாக் சீல்ட் + GPU + ச்பார்ச் படத்தின் அளவை மாற்றவும் + நிறுத்து + துவக்கத்தில் இயக்கவும் + அனுமதிகளை அமைக்கிறது... + 4-512 + நெட்வொர்க்கிங் + கொள்கலன் இயங்குகிறது + DNS சேவையகங்கள் + %1$dGB + பின்தளம் + PID: %1$d + தானியங்கு துவக்க முன்னுரிமை + மேம்பட்ட பயனர்களுக்கு சலுகை பெற்ற பயன்முறையுடன் பாதுகாப்பு தடைகளைத் தளர்த்தவும். + ஆதரிக்கப்படும் ஏபிஐக்கள் + அரிதான படங்கள் பற்றி + அடுத்து: கட்டமைப்பு + பாலம் ஏற்கனவே \'%1$s\' ஆல் பயன்படுத்தப்பட்டது. வேறு LAN பெயரைப் பயன்படுத்தவும். + ஆவியாகும் முறை + அளவு (சிபி) + %1$s கண்டெய்னரை நிறுத்த முடியவில்லை + கிடைக்கவில்லை + நிறுவல் நீக்கவும் + கோப்பகங்களை உருவாக்குகிறது... + சிறப்புரிமை பெற்றது + விருப்பங்கள்: %1$s + ds-lan + கர்னல் தேவைகள் இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது + கோப்பு முறைமை பிழை + முகமூடி சேவைகள் இல்லை + தவிர் + தேவைகள் சரிபார்ப்பு பிழைகளுடன் முடிந்தது + களஞ்சியம் + நிறுவல் தயாராகிறது... + உள்ளமைவு + தொகுதி நிறுவலைத் தயாராகிறது... + வெளியீடு இல்லை + ரூட் அணுகலைச் சரிபார்க்கவும் + கொள்கலன் பாதை: %1$s + நிறுவல் தோல்வியடைந்தது + NAT (இயல்புநிலை) + களஞ்சியங்களை நிர்வகிக்கவும் + அறியப்படாத பிழை ஏற்பட்டது + பதிவுகள்: %1$s + திரை பூட்டப்பட்டிருந்தாலும், தரவு குறியாக்கம் செய்யப்பட்டிருந்தாலும், பூட்டில் கொள்கலன்களைத் தானாகத் தொடங்கவும். + இந்த கொள்கலனை திசைவியாக செயல்படும் மற்றொரு கொள்கலன் வழியாக இயக்கவும் (எ.கா. OpenWRT). அந்த நுழைவாயில் முதலில் இயங்க வேண்டும். + சுற்றுச்சூழல் மாறிகள் + எதிர்பாராத HTTP பதில் + வேக் லாக்: ஆஃப் + வெற்று அடைவு + நிலையான சேவைகள் இல்லை + பதிவுகளை அழிக்கவும் + பங்களிப்பாளர்கள் + இருண்ட கருப்பொருள் இயக்கவும் + ஆபரேசன் தோல்வி! + விருந்தோம்பி + நெறிமுறை + தனிப்பயன் களஞ்சியங்கள் எதுவும் இதுவரை குழுசேரவில்லை. + துறைமுகம் முன்னோக்கி சேர்க்கவும் + இயல்புநிலை init (/sbin/init) பயன்படுத்த காலியாக விடவும் + மறுதொடக்கம் + விருப்பங்கள் + தேடல் சேவைகள்... + ரூட் அணுகல் இல்லை + OpenRC + இந்தப் பெயரில் ஒரு கொள்கலன் ஏற்கனவே உள்ளது + எதுவும் இல்லை (காற்று இடைவெளி) + செயலில் உள்ள கொள்கலன் முனைய அமர்வுகள் + Termux:X11 கூடுதல் கொடிகள் + முடக்கு + கேட்வே (மெய்நிகர் திசைவி) + ஒவ்வொரு வரிசையும் ஒரு KEY=VALUE இணை + வேக் லாக்: ஆன் + ஏற்றுமதியைத் தொடங்குகிறது... + கணினியின் இயல்புநிலை வண்ணத் தட்டுகளைப் பயன்படுத்தவும் + /sbin/init (இயல்புநிலை) + கொள்கலன் பாதையை உள்ளிடவும் + ரூட் அணுகல் இல்லாததால் இந்தப் பக்கம் முடக்கப்பட்டுள்ளது + குழுசேர் + பரவல் + பெயர் காலியாக இருக்கக்கூடாது + கொள்கலன் விருப்பங்கள் + கோப்புறையைத் தேர்ந்தெடு + பின்தளத்தை மீண்டும் நிறுவவும் + %1$sing கண்டெய்னரில் %2$s தோல்வி + டெர்மினல் பதிவுகள் + முழுமையான தனிமைப்படுத்தல் + ரூட்ஃப்ச் களஞ்சியம் + மேனுவல் டெட்லாக் சீல்டு + சலுகை பெற்ற பயன்முறை + பல வழிமாற்றுகள் + எ.கா. 1.1.1.1,8.8.8.8 (விரும்பினால்) + UDP + Droidspaces பின்தளம் நிறுவப்படவில்லை அல்லது சிதைக்கப்படவில்லை. முதலில் அதை நிறுவவும். + ஓடுகிறது + %1$s: %2$s + இயக்கு + %1$.1f%% + டெர்மினல் அமர்வுகள் + பழைய தொகுதியை நீக்குகிறது... + மரபு Cgroup V1 படிநிலையின் கட்டாயப் பயன்பாடு + ஆண்ட்ராய்டு பதிப்பு + %1$s கண்டெய்னரை நிறுவல் நீக்குவதில் தோல்வி + தவறான வரம்பு வடிவம் (START-END) + கொள்கலன் துறைமுகம் (1-65535 அல்லது வரம்பு) + முனையத்தில் + கணினி பாதையில் Droidspaces ஐ ஒருங்கிணைக்கவும் + ஆபரேசன் தோல்வியடைந்தது + தனிப்பயன் rootfs.json ஊட்டங்களுக்கு குழுசேரவும். + VirGL வழியாக 3D வன்பொருள் முடுக்கத்தை இயக்கவும் + இருண்ட கருப்பொருள் + ரத்துசெய் + முகமூடியை அவிழ்த்து விடுங்கள் + முக்கிய + SELinux இசைவு + IPv6 ஐ முடக்கு + அடுத்து: சேமிப்பு + GKI அல்லாத கர்னல் தேவைகள் (<=4.19) + சேவைகளைப் பெறுகிறது... + எ.கா. -dpi 160 -force-sysvshm + பதிவுகளை நகலெடுக்கவும் + புதுப்பிப்பு + திற + பிழைக் குறியீடு %1$d + NAT + தயவுசெய்து .tar.xz அல்லது .tar.gz கோப்பைத் தேர்ந்தெடுக்கவும். கோப்பு வகையைத் தீர்மானிக்க முடியவில்லை. + எதுவுமில்லை + பாதுகாப்பு & துவக்கம் + நுழைவாயில் அமைப்புகள் + ஏற்கனவே உள்ள துறைமுகம் முன்னோக்கி விதியுடன் மேலெழுகிறது + உங்கள் கோரிக்கையை நாங்கள் செயல்படுத்தும் வரை காத்திருக்கவும்... + குழு + %1$d MB (%2$.1f%%) + கொள்கலன் %1$s வெற்றிகரமாக நிறுவல் நீக்கப்பட்டது + SELinux நிலை + X11 + நுழைவாயிலாக செயல்பட மற்றொரு கொள்கலனை நிறுவவும் + புரவலன் லினக்ச் கர்னலுடன் நேரடியாகப் பேசுகிறது. ஐப்பர்வைசர் இல்லை, மெய்நிகர் இயந்திர அடுக்கு இல்லை. + தயவுசெய்து .tar.xz அல்லது .tar.gz கோப்பைத் தேர்ந்தெடுக்கவும். தேர்ந்தெடுக்கப்பட்டது: %1$s + AMOLED பயன்முறை + முனையத்தில் + rootfs.json முகவரி + சுழியம் மேல்நிலை + அனைத்து + பதிவிறக்கம் + பதிவிறக்கம் + மொழி + நிறுவல் பதிவுகள் + புரவலன் பாதை: %1$s + Systemd + தேடல் + புரவலன் துறைமுகம் (1-65535 அல்லது வரம்பு 100-200) + பின்பக்கம்: சிதைந்துள்ளது + Termux:X11ஐ உள்ளமைக்கவும் + நேட்டிவ் init.rc ஒருங்கிணைப்பு மற்றும் 2-லேயர் யூசர்ச்பேச் டெமான்களை ஆதரிக்கிறது. முற்றிலும் அழிக்க முடியாதது. + பின்தளம்: புதுப்பிப்பு உள்ளது + CPU: + கட்டிடக்கலை + அனுமதிகளை வழங்குகிறது... + Termux தேவைகள் + படத்தின் அளவு + பல்ச் ஆடியோ + கணினி கருப்பொருள் பின்பற்றவும் + நிர்வகிக்கவும் + துவக்கத்தில் கொள்கலனைத் தொடங்கவும் + வழிகாட்டி + பிழைத்திருத்தத்திற்கான கணினி செய்தி மற்றும் பதிவுகளை சேகரிக்கவும் + கொள்கலன்கள் இயங்கவில்லை + தேவைகள் சரிபார்ப்பு + ரத்துசெய் + GPU அணுகல் + கொள்கலன்களை நிர்வகிக்க ரூட் அணுகல் தேவை. + மறுவரிசைப்படுத்த இழுக்கவும். கொள்கலன்கள் மேலிருந்து கீழாகத் தொடங்குகின்றன - முதலில் ஒரு நுழைவாயிலை (எ.கா. OpenWRT) வைக்கவும், அதனால் அதன் வழியாக செல்லும் கொள்கலன்களுக்கு முன் அது பூட் ஆகும். + அசாதாரண சேவைகள் இல்லை + முடக்கப்பட்ட சேவைகள் இல்லை + உறுதிப்படுத்த, \"எந்த பிழைகளையும் நான் புகாரளிக்க மாட்டேன்\" என்று பெரிய எழுத்துக்களில் தட்டச்சு செய்க: + கொள்கலன் உள்ளமைவைத் திருத்து + வீடு + %1$sக்கான பணி நிர்வாகம் அடுத்த பேட்சில் சேர்க்கப்படும். + டெலிகிராம் சேனல் + \"%1$s\"க்கான ச்பேச் படத்தின் அளவை மாற்றுகிறது. சுருங்குவதை விட அளவை அதிகரிப்பது பாதுகாப்பானது. உங்கள் தரவை காப்புப் பிரதி எடுக்கவும். + இயங்கும் சேவைகள் இல்லை + அண்மைக் கால வெளியீட்டில் ரூட்ஃப்ச் டார்பால்கள் எதுவும் இல்லை + சரிபார்க்கிறது + நிலையான ஐபி முகவரி + குறைந்தபட்ச அளவு 4 சிபி + நுழைவாயில் + ஓடுகிறது + பிழை அறிக்கையை உருவாக்கவும் + %1$s %2$s செய் + ஒருங்கிணைப்பு & வன்பொருள் + சேமிக்கிறது... + லான் + ஓச்ட்/கன்டெய்னர் வரம்பு அகலம் பொருந்தவில்லை + பொருத்தங்கள் இல்லை + நிறுவல் முடிந்தது + OpenWrt + நிலையான + முகவரி காலியாக இருக்கக்கூடாது + அமைப்புகள் + கொள்கலனை நிறுத்துகிறது... + 1 அமர்வு இயங்குகிறது · மீட்டமைக்க தட்டவும் + ஏற்றுகிறது... + 4 முதல் 512 சிபி வரை அளவை உள்ளிடவும் + அறிவிப்பு இசைவு + எடுத்துக்காட்டுகள்: 8080 (ஒற்றை), 8000-8010 (வரம்பு). சமச்சீர் மேப்பிங்கிற்கு இலக்கை காலியாக விடவும். + NAT அமைப்புகள் + ஆயத்தம் + மிக நீளமானது - அதிகபட்சம் 15 எழுத்துகள் + %1$s கண்டெய்னரை ஏற்றுமதி செய்ய முடியவில்லை + ஒரு சிறிய படக் கோப்பில் கொள்கலனை உருவாக்கவும் + வெளியேறு + காலியாக இருந்தால், கொள்கலன் பெயரை ஓச்ட்பெயராகப் பயன்படுத்தும் + கட்டமைக்கப்படவில்லை + உள்ளமைவைப் புதுப்பிக்க முடியவில்லை + Droidspaces பற்றி + வட்டு பயன்பாடு + கொள்கலனை நிறுவுதல் + தனிப்பயன் களஞ்சியத்தைச் சேர்க்கவும் + வரம்பு: 1-254 + பவர் அப் ஆன் ஆட்டோ பூட் + தொடரவும் + ஆண்ட்ராய்டு சேமிப்பகத்தை ஏற்றவும் + ச்பேர்ச் பட கட்டமைப்பு + நிறுவவும் + 8 + பெயர் மிக நீளமானது - அதிகபட்சம் 17 எழுத்துகள் (கர்னல் லூப் சாதன பாதை வரம்பு) + %1$s: %2$s + அளவை மாற்றும் செயல்பாட்டைத் தொடங்குகிறது... + டெவலப்பர்(கள்) + ஆண்ட்ராய்டு சேமிப்பிடம் + , + கூடுதல் விருப்பங்கள் எதுவும் இயக்கப்படவில்லை + சேவைகள் எதுவும் இல்லை + தோல்வியடைந்தது + எண்கள், புள்ளிகள் மற்றும் பெருங்குடல்கள் மட்டுமே + கூட்டு + தொகுதி நிறுவல் தோல்வியடைந்தது + Systemd சேவைகள் + OpenWrt சேவைகள் + தேவைகள் + பாதை முழுமையானதாக இருக்க வேண்டும் (/ உடன் தொடங்கவும்) + ஓச்ட்பெயர்: %1$s + ரூட் அணுகல் இல்லை. + ஆண்ட்ராய்டு ரூட் செல்லில் droidspaces கட்டளையை அணுக /system/bin இல் ஒரு சிம்லிங்கை உருவாக்கவும். எச்சரிக்கை: உங்கள் மவுண்ட் சிச்டம் ஓவர்லேஃப்ச் மேவு மாட்யூல்களாக இருந்தால், இதை இயக்குவது பூட்லூப்பை ஏற்படுத்துகிறது. மேசிக் மவுண்ட் அடிப்படையிலான மவுண்ட் சிச்டத்தைப் பயன்படுத்தும் போது மட்டுமே இந்த விருப்பத்தைப் பயன்படுத்தவும் (மறுதொடக்கம் தேவை) + தனிப்பயன் தொடக்க பைனரி + நிறுவல் சுருக்கம் + ரூட் அணுகலைச் சரிபார்க்கிறது... + பயனர்களைப் புதுப்பிக்கவும் + வடிவமைப்பால் அழிக்க முடியாதது + முகமூடி பணி + சமச்சீர் மேப்பிங்கிற்கு காலியாக விடவும் + அறியப்படாத பிழை + மதிப்பு + இடைநிலைப்பலகைக்கு பதிவுகள் நகலெடுக்கப்பட்டன + அளவு தேவை + கொள்கலனின் பெயர் காலியாக இருக்கக்கூடாது + NAT மற்றும் எதுவுமில்லை பிணையம் முறைகளில் IPv6 எப்போதும் முடக்கப்பட்டிருக்கும். + பைண்ட் மவுண்ட்ச் + PulseAudio வழியாக ஆடியோ ஆதரவை இயக்கவும் + பிழை: %1$s + இந்த கொள்கலனுக்குள் ஒரு ஊடாடும் செல்லைத் திறக்கவும் + மேம்பட்ட விருப்பங்கள் + Droidspaces டெர்மினல் + கணினி இயல்புநிலை + சேவையை மீண்டும் தொடங்கவும் + இந்த நுழைவாயில் கொள்கலன் நிறுவப்படவில்லை + கொள்கலன் செய்தி + உங்கள் முதல் லினக்ச் கொள்கலனை எவ்வாறு அமைப்பது என்பதை அறிய இங்கே தட்டவும்! + முடிந்தது + From 901502daeaa2ca220c4f1d1d81ff62ac1ec823de Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Mon, 29 Jun 2026 12:57:24 +0530 Subject: [PATCH 073/211] net: self-heal delegated-LAN clients when the gateway (re)boots A delegated-LAN (gateway-mode) client loses its router whenever its gateway container reboots: the gateway's netns dies and takes the gateway-side veth with it, so the client's app veth survives on the bridge but the segment has no router. Clients also could not be wired at all until their gateway happened to be up. Make the gateway own the wiring, end to end: - gateway_wire_client() does the whole client wiring from the host side - bridge + gateway uplink, the app veth, a stable MAC, and the in-netns eth0 rename/up - so it behaves identically whether the client is just starting or already running. The gateway-mode child only brings up lo. - On every boot cycle the gateway container's monitor calls ds_net_rewire_gateway_clients(), which scans the running containers and runs gateway_wire_client() for each one that delegates to it. That re-establishes the gateway-side cable and every client's eth0 in the gateway's current netns, with no client restart. - A client started while its gateway is down now wires nothing and defers entirely to the gateway. This also closes a race: the client can no longer bake in gateway/LAN settings that the user edits before the gateway starts - the gateway wires each client from its current config. Supporting fixes: - Drop NLM_F_ACK on RTM_GETLINK: a successful GETLINK already replies with RTM_NEWLINK, so the extra ACK left a stray datagram in the socket buffer that desynced the next request's reply (ds_nl_link_exists false positives). Keep reading until our sequence matches. - Give gateway clients distinct veth prefixes (ds-c/ds-q) so the NAT last-container refcount cannot miscount them as live NAT containers. - Pin a stable, segment-derived MAC on the gateway-side cable and move+rename it into the gateway atomically, so OpenWrt's netifd sees one persistent eth1 instead of a churning random-MAC device. Signed-off-by: ravindu644 --- Documentation/Networking-From-Zero.md | 30 +- src/boot.c | 2 + src/include/droidspace.h | 6 + src/monitor.c | 15 +- src/net/netlink.c | 57 ++- src/net/network.c | 608 +++++++++++++++++++------- 6 files changed, 544 insertions(+), 174 deletions(-) diff --git a/Documentation/Networking-From-Zero.md b/Documentation/Networking-From-Zero.md index 3c13e273..9e005ae3 100644 --- a/Documentation/Networking-From-Zero.md +++ b/Documentation/Networking-From-Zero.md @@ -365,7 +365,7 @@ Android host kernel +-- ds-lan (host bridge - NO IP address, just a switch) | | | +-- ds-g[hash] (veth host-side, connected to OpenWRT's netns as eth1) -| +-- ds-v[pid] (veth host-side, connected to Kali's netns as eth0) +| +-- ds-c[pid] (veth host-side, connected to Kali's netns as eth0) | +-- [Kali container - net=gateway mode] netns: owns eth0 (LAN side - plugged into ds-lan bridge) @@ -445,7 +445,12 @@ This mimics how you might physically plug a cable into a router's LAN port after In NAT mode, Droidspaces writes `/etc/resolv.conf` inside the container, pointing to `1.1.1.1` or `8.8.8.8`. -In gateway mode, Droidspaces does NOT write `resolv.conf` (unless you explicitly pass `--dns`). This is because OpenWRT's `dnsmasq` hands the DNS server address to the container via the DHCP lease. If Droidspaces also wrote a `resolv.conf`, it would conflict with what dnsmasq is providing - the container would use the wrong DNS and bypass OpenWRT's DNS filtering/caching entirely. +In gateway mode, Droidspaces does NOT write a static `resolv.conf` (unless you explicitly pass `--dns`). This is because OpenWRT's `dnsmasq` hands the DNS server address to the container via the DHCP lease. If Droidspaces also wrote a `resolv.conf`, it would conflict with what dnsmasq is providing - the container would use the wrong DNS and bypass OpenWRT's DNS filtering/caching entirely. + +How this is wired depends on the client's init system: + +- **systemd containers:** `/etc/resolv.conf` is symlinked to `/run/systemd/resolve/resolv.conf`, which systemd-resolved populates from the DHCP lease. +- **non-systemd containers:** Droidspaces leaves `/etc/resolv.conf` entirely alone, so the container's own DHCP client (udhcpc/dhclient) writes the gateway-supplied nameserver from the lease. (Earlier builds wrote a hardcoded `1.1.1.1`/`8.8.8.8` here, which silently bypassed the gateway's DNS - that is fixed.) If a minimal rootfs ships no DHCP resolv.conf hook, pass `--dns` to set one explicitly. ### Why bridge-nf-call-iptables is set to 0 @@ -453,19 +458,28 @@ The bridge `ds-lan` carries traffic between OpenWRT and Kali. By default, Linux Setting it to `0` tells Linux: "do not run iptables on bridged traffic." This keeps OpenWRT's firewall as the *only* firewall that sees this traffic, which is exactly what we want. -### The gateway container must be running first +### Start order and automatic self-healing + +All wiring for a gateway-mode client is done **from the host side** by a single function, `gateway_wire_client()`: it ensures the bridge and the gateway-side cable, creates the client's app veth, and moves+renames the peer into the client's namespace as `eth0` (pinned MAC, brought up) — the client's own boot code only brings up `lo`. Because the host owns every step, the same function wires a client whether it is just starting or already running. + +This gives a deliberately simple rule keyed on **gateway liveness**: + +- **Gateway already running when a client starts** → the client wires immediately (its own monitor calls `gateway_wire_client`). +- **Gateway not running when a client starts** → the client wires **nothing at all** (no bridge, no veth, no `eth0`) and just boots. The work is deferred entirely to the gateway. + +Healing is therefore driven by **the gateway itself**, not by the clients. On every boot cycle the gateway container's monitor calls `ds_net_rewire_gateway_clients()`: it scans the running containers, finds the ones that delegate to this gateway, and runs `gateway_wire_client` for each — establishing the gateway-side `eth1` cable and every client's `eth0` into the gateway's *current* namespace. So when the gateway **starts or reboots**, every running client is (re)wired with **no client restart required**. -Start order matters. When a gateway-mode container starts, Droidspaces looks up the gateway container's live process ID to reach its network namespace. If the gateway container is not running at that moment, the network setup fails with a warning and the client container boots anyway - but with no network at all (only loopback). It does not retry on its own. +Skipping all client wiring while the gateway is down (rather than half-wiring a bridge and a danging veth) also closes a race: a client started before its gateway can have its gateway/LAN settings (`--gateway-net`, `--host-bridge`, …) edited before the gateway comes up, and the gateway then wires every client from each client's *current* config — never a stale one. -The same logic applies after a gateway restart. A veth pair dies together: when the gateway container stops, the `eth1` end inside it is destroyed, and that destroys the host-side end too. Existing clients stay plugged into the bridge but have no router anymore. The LAN cable is re-plugged the next time **any** gateway-mode container starts on that segment - so after restarting the gateway, restart one client (or start a new one) to bring the segment back to life. +There is exactly **one actor** (the gateway) doing the wiring, so there is nothing to poll and no thundering herd. Wiring is serialised per segment (an advisory file lock) to keep concurrent client starts and the gateway's re-wire from racing. Both `eth1` (gateway side) and each `eth0` (client side) keep a **stable MAC** and are moved+renamed into their namespace in a single atomic step, so the container's own `netifd`/DHCP sees one persistent device rather than re-initialising a churning one. ### What happens when containers stop Cleanup in gateway mode is deliberately minimal, matching the "plumbing only" philosophy: -- **A client stops:** only that client's own veth (`ds-v`) is removed. The bridge and the gateway's `eth1` stay up, so other clients on the segment are untouched. -- **The gateway stops:** the gateway-side veth disappears with its namespace (see above), but the bridge itself stays. -- The delegated bridge (`ds-lan` etc.) is never torn down by Droidspaces. It is harmless when idle - it has no IP and carries no policy - and persists until you delete it manually or reboot the device. +- **A client stops:** only that client's own veth is removed (gateway clients use the `ds-c` prefix, distinct from NAT's `ds-v`). The bridge and the gateway's `eth1` stay up, so other clients on the segment are untouched. +- **The last client stops while the gateway is still running:** the bridge is **kept** (not reaped). Tearing it down would flap the gateway's live `eth1` carrier and occasionally make netifd report "device initialization failed"; an idle IP-less bridge is harmless and the next client reuses it. +- **The gateway stops:** the gateway-side veth disappears with its namespace. Once no clients remain *and* the gateway is gone, the now-idle bridge is reaped. --- diff --git a/src/boot.c b/src/boot.c index 99e9d07e..4abab540 100644 --- a/src/boot.c +++ b/src/boot.c @@ -136,6 +136,8 @@ int internal_boot(struct ds_config *cfg) { if (nr != (ssize_t)sizeof(hs)) { ds_warn("[NET] Child: incomplete handshake (read %zd, expected %zu)", nr, sizeof(hs)); + } else if (cfg->net_mode == DS_NET_GATEWAY) { + ds_log("[NET] Child: handshake received (gateway mode)"); } else { ds_log("[NET] Child: handshake received: peer=%s ip=%s", hs.peer_name, hs.ip_str); diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 1050af9a..98792b5d 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -681,6 +681,10 @@ void ds_net_derive_handshake(pid_t init_pid, struct ds_config *cfg, struct ds_net_handshake *hs); void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid); void ds_net_start_route_monitor(void); +/* Gateway self-heal: when the gateway container (re)boots, re-wire its LAN + * cable to every running client that delegates to it, with no client restart. + * Called from the gateway container's monitor on each boot cycle. */ +void ds_net_rewire_gateway_clients(const char *gateway_name, pid_t gateway_pid); int ds_net_disable_tx_checksum(const char *ifname); void parse_cidr(const char *cidr, uint32_t *ip_out, uint32_t *mask_out); @@ -708,6 +712,8 @@ int ds_nl_add_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t ip_be, int ds_nl_add_route4(ds_nl_ctx_t *ctx, uint32_t dst_be, uint8_t dst_len, uint32_t gw_be, int oif_idx); int ds_nl_move_to_netns(ds_nl_ctx_t *ctx, const char *ifname, int netns_fd); +int ds_nl_move_to_netns_named(ds_nl_ctx_t *ctx, const char *ifname, + int netns_fd, const char *newname); int ds_nl_get_iface_table(ds_nl_ctx_t *ctx, const char *ifname, int *table_out); int ds_nl_get_table_default_oif(ds_nl_ctx_t *ctx, int table, char *ifname_out); int ds_nl_get_android_default(ds_nl_ctx_t *ctx, char *ifname_out, diff --git a/src/monitor.c b/src/monitor.c index e902dd2f..49814b44 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -404,11 +404,22 @@ reboot_loop:; /* Send handshake to init */ struct ds_net_handshake hs; ds_net_derive_handshake(netns_pid, cfg, &hs); - ds_log("[NET] Monitor: sending DONE: peer=%s ip=%s", hs.peer_name, - hs.ip_str); + if (cfg->net_mode == DS_NET_GATEWAY) + ds_log("[NET] Monitor: sending DONE (gateway mode: eth0 is wired " + "host-side, IP comes from the gateway's DHCP)"); + else + ds_log("[NET] Monitor: sending DONE: peer=%s ip=%s", hs.peer_name, + hs.ip_str); if (write(cfg->net_done_pipe[1], &hs, sizeof(hs)) != (ssize_t)sizeof(hs)) ds_warn("[NET] Monitor: failed to write handshake to init"); close(cfg->net_done_pipe[1]); + + /* Gateway self-heal: if any running client delegates to THIS container as + * its gateway, (re)plug their LAN cable into our (possibly just-rebooted) + * netns now. Runs on every boot cycle - a cheap no-op when nobody routes + * through us - so a gateway reboot re-wires its clients with no client + * restart. */ + ds_net_rewire_gateway_clients(cfg->container_name, netns_pid); } } diff --git a/src/net/netlink.c b/src/net/netlink.c index 2c885c66..22caa573 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -169,7 +169,11 @@ static int ds_nl_talk(ds_nl_ctx_t *ctx, struct nlmsghdr *req) { continue; /* more fragments coming */ return 0; } - break; + /* No message matching our sequence was in this datagram. This happens when + * a prior request left a trailing ACK in the socket buffer, or for a + * multipart reply that spans datagrams. recv() again for our actual reply + * rather than (wrongly) reporting success - the bug that made + * ds_nl_link_exists() return false positives. */ } return 0; } @@ -276,7 +280,11 @@ int ds_nl_link_exists(ds_nl_ctx_t *ctx, const char *ifname) { memset(&req, 0, sizeof(req)); req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); req.n.nlmsg_type = RTM_GETLINK; - req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + /* No NLM_F_ACK: a successful GETLINK already replies with RTM_NEWLINK; adding + * ACK makes the kernel send a *second* (NLMSG_ERROR err=0) message, whose + * leftover in the socket buffer used to desync the next request's reply. + * A missing link still returns NLMSG_ERROR(-ENODEV) regardless of ACK. */ + req.n.nlmsg_flags = NLM_F_REQUEST; req.i.ifi_family = AF_UNSPEC; nl_addattr(&req.n, (int)sizeof(req), IFLA_IFNAME, ifname, (int)strlen(ifname) + 1); @@ -632,6 +640,51 @@ int ds_nl_move_to_netns(ds_nl_ctx_t *ctx, const char *ifname, int netns_fd) { return ds_nl_talk(ctx, &req.n); } +/* --------------------------------------------------------------------------- + * Move an interface into another network namespace AND rename it in the same + * RTM_NEWLINK request (IFLA_NET_NS_FD + IFLA_IFNAME). The kernel applies both + * in a single op, so the device appears in the target netns already carrying + * its final name - there is no transient "raw name then rename" window for a + * daemon (e.g. OpenWrt netifd) in that netns to race against. + * + * The link is brought DOWN first: renaming a running device returns EBUSY on + * some drivers, and a freshly created veth peer is down anyway. The device + * arrives DOWN in the target netns; the caller brings it up there. + * + * Returns 0 on success. On failure (e.g. newname already exists in the target + * netns) nothing is guaranteed to have moved; callers should fall back to a + * plain move + in-netns rename. + * ---------------------------------------------------------------------------*/ +int ds_nl_move_to_netns_named(ds_nl_ctx_t *ctx, const char *ifname, + int netns_fd, const char *newname) { + if (!newname || !newname[0]) + return ds_nl_move_to_netns(ctx, ifname, netns_fd); + + ds_nl_link_down(ctx, ifname); /* allow rename during the move */ + + int idx = ds_nl_get_ifindex(ctx, ifname); + if (idx <= 0) + return -ENODEV; + + struct { + struct nlmsghdr n; + struct ifinfomsg i; + char buf[256]; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.n.nlmsg_type = RTM_NEWLINK; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + req.i.ifi_family = AF_UNSPEC; + req.i.ifi_index = idx; + + nl_addattr(&req.n, (int)sizeof(req), IFLA_NET_NS_FD, &netns_fd, + (int)sizeof(int)); + nl_addattr(&req.n, (int)sizeof(req), IFLA_IFNAME, newname, + (int)strlen(newname) + 1); + return ds_nl_talk(ctx, &req.n); +} + /* --------------------------------------------------------------------------- * Flush stale Droidspaces veth interfaces via RTM_GETLINK dump * diff --git a/src/net/network.c b/src/net/network.c index ef09a980..ee68d3d6 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -21,20 +21,33 @@ #include #include #include +#include #include /* --------------------------------------------------------------------------- * Internal helpers * ---------------------------------------------------------------------------*/ -/* Derive the host-side veth name from a container init PID */ -static void veth_host_name(pid_t pid, char *buf, size_t sz) { - snprintf(buf, sz, "ds-v%d", (int)pid); +/* Host-side veth prefix for an application container. NAT containers use + * "ds-v"; gateway clients use "ds-c" so the NAT-mode "last container" refcount + * (which scans for "ds-v") never miscounts a gateway client as a live NAT + * container and so keeps shared NAT iptables/route rules alive forever. */ +static const char *app_veth_host_prefix(const struct ds_config *cfg) { + return (cfg && cfg->net_mode == DS_NET_GATEWAY) ? "ds-c" : "ds-v"; } -/* Derive the peer (container-side) veth name from a container init PID */ -static void veth_peer_name(pid_t pid, char *buf, size_t sz) { - snprintf(buf, sz, "ds-p%d", (int)pid); +/* Derive the host-side veth name from a container init PID (mode-aware). */ +static void veth_host_name(const struct ds_config *cfg, pid_t pid, char *buf, + size_t sz) { + snprintf(buf, sz, "%s%d", app_veth_host_prefix(cfg), (int)pid); +} + +/* Derive the peer (container-side) veth name from a container init PID. + * Gateway clients use "ds-q" to match the distinct host-side prefix. */ +static void veth_peer_name(const struct ds_config *cfg, pid_t pid, char *buf, + size_t sz) { + const char *p = (cfg && cfg->net_mode == DS_NET_GATEWAY) ? "ds-q" : "ds-p"; + snprintf(buf, sz, "%s%d", p, (int)pid); } /* Derive a deterministic IP from a PID (avoids sequential collisions) */ @@ -83,6 +96,24 @@ static void ds_container_mac(const char *name, uint8_t mac[6]) { mac[5] = (uint8_t)(h2); } +/* Derive a stable MAC for a gateway LAN-side veth from its segment key + * ("{gateway}:{net}"). The gateway's LAN interface (e.g. eth1 inside OpenWrt) + * then keeps the same MAC every time the cable is (re)plugged - across gateway + * reboots and self-heal re-wiring - so netifd sees one persistent device + * instead of re-initialising a new random-MAC device each time. */ +static void ds_segment_mac(const char *key, uint8_t mac[6]) { + uint32_t h1 = ds_net_hash_string(key); + char salted[400]; + snprintf(salted, sizeof(salted), "ds-gwmac:%s", key ? key : ""); + uint32_t h2 = ds_net_hash_string(salted); + mac[0] = 0x02; /* locally administered, unicast */ + mac[1] = (uint8_t)(h1 >> 24); + mac[2] = (uint8_t)(h1 >> 16); + mac[3] = (uint8_t)(h1 >> 8); + mac[4] = (uint8_t)(h1); + mac[5] = (uint8_t)(h2); +} + static void gateway_hash_key(struct ds_config *cfg, char *buf, size_t sz) { const char *gw = (cfg && cfg->gateway_container[0]) ? cfg->gateway_container : "gateway"; @@ -160,8 +191,9 @@ static int ds_netns_rename_up(const char *netns_path, const char *old_name, } if (new_name && new_name[0] && ds_nl_link_exists(ctx, new_name)) { - ds_log("[NET] Gateway: interface %s already exists inside gateway", - new_name); + /* Already present under its final name inside the gateway - the common, + * healthy case: the atomic move+rename created it, or this is an idempotent + * re-entry. Just make sure it is up; the caller logs "uplink ready". */ ds_nl_link_up(ctx, new_name); } else if (old_name && old_name[0] && new_name && new_name[0] && strcmp(old_name, new_name) != 0) { @@ -193,6 +225,8 @@ static int g_current_gw_table = 0; static pthread_mutex_t g_gw_mutex = PTHREAD_MUTEX_INITIALIZER; static int g_route_monitor_sock = -1; static volatile sig_atomic_t g_stop_monitor = 0; +static pthread_t g_route_monitor_tid; +static int g_route_monitor_started = 0; /* guarded by g_gw_mutex */ /* Returns 1 if ifname exists and is both UP and RUNNING. * On Android, the active data interface has IFF_RUNNING set; an interface @@ -252,7 +286,7 @@ static int uplink_name_excluded(const char *ifname) { void ds_net_derive_handshake(pid_t init_pid, struct ds_config *cfg, struct ds_net_handshake *hs) { - veth_peer_name(init_pid, hs->peer_name, sizeof(hs->peer_name)); + veth_peer_name(cfg, init_pid, hs->peer_name, sizeof(hs->peer_name)); /* Use the already-resolved static IP - not the PID-hash fallback. * ip_str is informational on the child side (voided in * setup_veth_child_side_named) but the boot.c log line prints it, @@ -614,8 +648,8 @@ int ds_net_disable_tx_checksum(const char *ifname) { int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid) { char veth_host[IFNAMSIZ], veth_peer[IFNAMSIZ]; - veth_host_name(child_pid, veth_host, sizeof(veth_host)); - veth_peer_name(child_pid, veth_peer, sizeof(veth_peer)); + veth_host_name(cfg, child_pid, veth_host, sizeof(veth_host)); + veth_peer_name(cfg, child_pid, veth_peer, sizeof(veth_peer)); ds_log("Setting up host-side NAT networking for %s (PID %d)...", cfg->container_name, (int)child_pid); @@ -815,147 +849,246 @@ int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid) { } /* --------------------------------------------------------------------------- - * setup_gateway_veth_side - * - * Phase-1 OpenWrt gateway mode. Droidspaces owns only the L2 plumbing: - * - create/reuse a bridge-only LAN with no IP address - * - attach one veth peer to the designated gateway container netns - * - attach one veth peer to the application container netns + * Gateway segment lock * - * No NAT, DHCP, DNS, port-forwarding, Android policy routing, or firewall - * policy is installed here. The gateway container is expected to run the - * routing policy engine (OpenWrt netifd/firewall/dnsmasq/etc.). + * One advisory file lock per delegated LAN segment (keyed on the bridge name). + * Serialises (re)creation of the single shared gateway-side veth + bridge + * across the independent monitor processes that touch a segment - concurrent + * client starts, and the gateway re-wiring its clients on (re)boot - so they + * cannot race each other into EEXIST half-states. * ---------------------------------------------------------------------------*/ -int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { - if (!cfg || !cfg->gateway_container[0]) { - ds_warn("[NET] Gateway: no gateway container configured"); +static int gateway_segment_lock(const char *bridge) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/gw_%s.lock", get_net_dir(), bridge); + int fd = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (fd < 0) { + ds_warn("[NET] Gateway: could not open segment lock %s: %s", path, + strerror(errno)); return -1; } - - if (strcmp(cfg->gateway_container, cfg->container_name) == 0) { - ds_warn("[NET] Gateway: refusing to attach container to itself"); + if (flock(fd, LOCK_EX) < 0) { + ds_warn("[NET] Gateway: flock failed on %s: %s", path, strerror(errno)); + close(fd); return -1; } + return fd; +} - const char *gw_if = gateway_lan_ifname(cfg); - if (strlen(gw_if) >= IFNAMSIZ || !gateway_ifname_component_ok(gw_if)) { - ds_warn("[NET] Gateway: invalid gateway interface name '%s'", gw_if); - return -1; - } +static void gateway_segment_unlock(int fd) { + if (fd < 0) + return; + flock(fd, LOCK_UN); + close(fd); +} - struct ds_config gw_cfg; - memset(&gw_cfg, 0, sizeof(gw_cfg)); - gw_cfg.net_ready_pipe[0] = gw_cfg.net_ready_pipe[1] = -1; - gw_cfg.net_done_pipe[0] = gw_cfg.net_done_pipe[1] = -1; - safe_strncpy(gw_cfg.container_name, cfg->gateway_container, - sizeof(gw_cfg.container_name)); +/* --------------------------------------------------------------------------- + * Gateway liveness + * ---------------------------------------------------------------------------*/ - /* Best effort: loading the config gives UUID fallback discovery, but the - * fast pidfile path still works when the config file is unavailable. */ - (void)ds_config_load_by_name(cfg->gateway_container, &gw_cfg); +/* Resolve the gateway container's init pid, or 0 if it is not running. */ +static pid_t gateway_pid_of(const char *name) { + struct ds_config c; + memset(&c, 0, sizeof(c)); + c.net_ready_pipe[0] = c.net_ready_pipe[1] = -1; + c.net_done_pipe[0] = c.net_done_pipe[1] = -1; + safe_strncpy(c.container_name, name, sizeof(c.container_name)); + (void)ds_config_load_by_name(name, &c); + pid_t p = 0; + if (!is_container_running(&c, &p)) + p = 0; + ds_config_free(&c); + return p > 0 ? p : 0; +} - pid_t gw_pid = 0; - if (!is_container_running(&gw_cfg, &gw_pid) || gw_pid <= 0) { - ds_warn("[NET] Gateway: container '%s' is not running", - cfg->gateway_container); - ds_config_free(&gw_cfg); +/* --------------------------------------------------------------------------- + * gateway_ensure_lan_uplink_locked + * + * Ensure the shared half of a delegated LAN segment is wired into the running + * gateway container. The caller holds the segment lock and has already + * confirmed the gateway is up, passing its netns pid: + * - create/reuse the IP-less, policy-neutral bridge + * - ensure the gateway-side veth (ds-g) exists with its peer living in + * the gateway netns as gw_if (e.g. eth1) + * + * Idempotent: a live ds-g implies a live peer (veth pairs die together), + * so a repeat call is a cheap reattach. When the cable is absent we plug a + * fresh one into the gateway's (possibly just-rebooted) netns - this is what + * heals clients after a gateway restart, with no client restart. + * + * Returns 0 when the gateway-side cable is up, -1 on a netlink failure. + * ---------------------------------------------------------------------------*/ + +static int gateway_ensure_lan_uplink_locked(struct ds_config *cfg, + pid_t gw_pid) { + if (!cfg || !cfg->gateway_container[0] || gw_pid <= 0) + return -1; + + const char *gw_if = gateway_lan_ifname(cfg); + if (strlen(gw_if) >= IFNAMSIZ || !gateway_ifname_component_ok(gw_if)) { + ds_warn("[NET] Gateway: invalid gateway interface name '%s'", gw_if); return -1; } - char bridge[IFNAMSIZ]; - char gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; - char app_host[IFNAMSIZ], app_peer[IFNAMSIZ]; + char bridge[IFNAMSIZ], gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; gateway_bridge_name(cfg, bridge, sizeof(bridge)); gateway_veth_names(cfg, gw_host, sizeof(gw_host), gw_peer, sizeof(gw_peer)); - veth_host_name(child_pid, app_host, sizeof(app_host)); - veth_peer_name(child_pid, app_peer, sizeof(app_peer)); - - ds_log("[NET] Gateway: wiring %s to gateway %s via bridge %s", - cfg->container_name, cfg->gateway_container, bridge); ds_nl_ctx_t *ctx = ds_nl_open(); if (!ctx) { ds_warn("[NET] Gateway: failed to open RTNETLINK socket"); - ds_config_free(&gw_cfg); return -1; } + /* Bridge first, unconditionally: an IP-less, policy-neutral switch. Disable + * bridge netfilter calls so OpenWrt's own firewalling stays the only policy + * authority on the delegated network. */ if (!ds_nl_link_exists(ctx, bridge)) { ds_log("[NET] Gateway: creating delegated LAN bridge %s", bridge); if (ds_nl_create_bridge(ctx, bridge) < 0) { ds_warn("[NET] Gateway: failed to create bridge %s", bridge); ds_nl_close(ctx); - ds_config_free(&gw_cfg); return -1; } } - if (ds_nl_link_up(ctx, bridge) < 0) ds_warn("[NET] Gateway: failed to bring up bridge %s", bridge); - - /* Keep the bridge policy-neutral: no address, no DHCP, no NAT. Disable - * bridge netfilter calls so OpenWrt's own firewalling remains the visible - * policy authority inside the delegated network. */ write_file("/proc/sys/net/bridge/bridge-nf-call-iptables", "0"); write_file("/proc/sys/net/bridge/bridge-nf-call-ip6tables", "0"); - /* Lazily attach a LAN-side interface to the gateway if it is not already - * present. This allows the gateway container to boot with its WAN side - * first, then receive its LAN cable when the first client is attached. */ - if (!ds_nl_link_exists(ctx, gw_host)) { - char gw_netns[PATH_MAX]; - snprintf(gw_netns, sizeof(gw_netns), "/proc/%d/ns/net", (int)gw_pid); + /* Cable already present → live gateway peer → just re-assert master + up. + * This is the idempotent no-op path when the segment is already healthy. */ + if (ds_nl_link_exists(ctx, gw_host)) { + if (ds_nl_set_master(ctx, gw_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to reattach %s to %s", gw_host, bridge); + ds_nl_link_up(ctx, gw_host); + ds_nl_close(ctx); + return 0; + } - ds_log("[NET] Gateway: creating gateway veth %s <-> %s", gw_host, gw_peer); - if (ds_nl_create_veth(ctx, gw_host, gw_peer) < 0) { - ds_warn("[NET] Gateway: failed to create gateway veth pair"); - ds_nl_close(ctx); - ds_config_free(&gw_cfg); - return -1; - } + char gw_netns[PATH_MAX]; + snprintf(gw_netns, sizeof(gw_netns), "/proc/%d/ns/net", (int)gw_pid); - ds_net_disable_tx_checksum(gw_host); - if (ds_nl_set_master(ctx, gw_host, bridge) < 0) - ds_warn("[NET] Gateway: failed to attach %s to %s", gw_host, bridge); - if (ds_nl_link_up(ctx, gw_host) < 0) - ds_warn("[NET] Gateway: failed to bring up %s", gw_host); + ds_log("[NET] Gateway: creating gateway veth %s <-> %s", gw_host, gw_peer); + if (ds_nl_create_veth(ctx, gw_host, gw_peer) < 0) { + ds_warn("[NET] Gateway: failed to create gateway veth pair"); + ds_nl_close(ctx); + return -1; + } - int gw_netns_fd = open(gw_netns, O_RDONLY | O_CLOEXEC); - if (gw_netns_fd < 0) { - ds_warn("[NET] Gateway: failed to open %s: %s", gw_netns, - strerror(errno)); - ds_nl_close(ctx); - ds_config_free(&gw_cfg); - return -1; - } + /* Pin a stable, segment-derived MAC on the gateway-facing peer (becomes gw_if + * inside the gateway) so netifd sees one persistent device across every + * re-plug. Set while down, before the move. */ + { + char key[384]; + uint8_t mac[6]; + gateway_hash_key(cfg, key, sizeof(key)); + ds_segment_mac(key, mac); + if (ds_nl_set_mac(ctx, gw_peer, mac) < 0) + ds_warn("[NET] Gateway: failed to pin MAC on %s", gw_peer); + } + + ds_net_disable_tx_checksum(gw_host); + if (ds_nl_set_master(ctx, gw_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to attach %s to %s", gw_host, bridge); + if (ds_nl_link_up(ctx, gw_host) < 0) + ds_warn("[NET] Gateway: failed to bring up %s", gw_host); + + int gw_netns_fd = open(gw_netns, O_RDONLY | O_CLOEXEC); + if (gw_netns_fd < 0) { + ds_warn("[NET] Gateway: failed to open %s: %s", gw_netns, strerror(errno)); + ds_nl_del_link(ctx, gw_host); /* drop the half-built pair */ + ds_nl_close(ctx); + return -1; + } - int r = ds_nl_move_to_netns(ctx, gw_peer, gw_netns_fd); - close(gw_netns_fd); - if (r < 0) { - ds_warn("[NET] Gateway: failed to move %s into gateway netns", gw_peer); + /* Atomic move+rename: the peer appears inside the gateway already named + * gw_if, so there is no transient raw-name device for netifd to race against + * ("device initialization failed"). Fall back to a plain move on failure. */ + if (ds_nl_move_to_netns_named(ctx, gw_peer, gw_netns_fd, gw_if) != 0) { + ds_warn("[NET] Gateway: atomic move+rename of %s failed - falling back", + gw_peer); + if (ds_nl_move_to_netns(ctx, gw_peer, gw_netns_fd) < 0) { + ds_warn("[NET] Gateway: fallback move of %s into gateway netns failed", + gw_peer); + close(gw_netns_fd); ds_nl_close(ctx); - ds_config_free(&gw_cfg); return -1; } + } + close(gw_netns_fd); + ds_nl_close(ctx); - if (ds_netns_rename_up(gw_netns, gw_peer, gw_if) < 0) - ds_warn("[NET] Gateway: moved %s but could not rename/up it as %s", - gw_peer, gw_if); - } else { - if (ds_nl_set_master(ctx, gw_host, bridge) < 0) - ds_warn("[NET] Gateway: failed to reattach %s to %s", gw_host, bridge); - ds_nl_link_up(ctx, gw_host); + /* Bring it up inside the gateway under its final name (the atomic path + * already renamed it; the fallback path renames here). */ + if (ds_netns_rename_up(gw_netns, gw_peer, gw_if) < 0) + ds_warn("[NET] Gateway: moved %s but could not bring it up as %s", gw_peer, + gw_if); + + ds_log("[NET] Gateway: LAN uplink ready on %s -> %s (%s)", bridge, + cfg->gateway_container, gw_if); + return 0; +} + +/* --------------------------------------------------------------------------- + * gateway_wire_client + * + * Fully wire ONE gateway-mode client into its delegated LAN, entirely from the + * host side. Because the host owns every step - including renaming the peer to + * eth0 and bringing it up *inside* the client netns - this works identically + * whether the client is just starting (its netns fresh, child blocked on the + * handshake) or already running (the gateway came up later and is re-wiring + * it). The gateway-mode child only brings up lo; it never touches eth0. + * + * Under the segment lock: ensure bridge + gateway uplink, create the app veth, + * pin the client's stable MAC, attach the host end to the bridge, then + * move+rename the peer into the client netns as eth0 (up). Returns 0 on + * success. Caller passes the client's init pid and the confirmed-running + * gateway's init pid. + * ---------------------------------------------------------------------------*/ + +static int gateway_wire_client(struct ds_config *cfg, pid_t client_pid, + pid_t gateway_pid) { + if (!cfg || client_pid <= 0 || gateway_pid <= 0) + return -1; + + char bridge[IFNAMSIZ], app_host[IFNAMSIZ], app_peer[IFNAMSIZ]; + gateway_bridge_name(cfg, bridge, sizeof(bridge)); + veth_host_name(cfg, client_pid, app_host, sizeof(app_host)); + veth_peer_name(cfg, client_pid, app_peer, sizeof(app_peer)); + + /* One lock spans the shared uplink AND this client's app-veth attach, so a + * concurrent client's cleanup cannot reap the bridge between them. */ + int lock = gateway_segment_lock(bridge); + int ret = -1; + + if (gateway_ensure_lan_uplink_locked(cfg, gateway_pid) < 0) { + ds_warn("[NET] Gateway: uplink for %s not ready - cannot wire '%s'", bridge, + cfg->container_name); + goto out; } - /* Wire the application container into the delegated LAN bridge. */ - ds_nl_del_link(ctx, app_host); + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ds_warn("[NET] Gateway: failed to open RTNETLINK socket"); + goto out; + } + + ds_nl_del_link(ctx, app_host); /* drop any stale half from a prior wiring */ ds_log("[NET] Gateway: creating app veth %s <-> %s", app_host, app_peer); if (ds_nl_create_veth(ctx, app_host, app_peer) < 0) { ds_warn("[NET] Gateway: failed to create app veth pair"); ds_nl_close(ctx); - ds_config_free(&gw_cfg); - return -1; + goto out; + } + + /* Pin the client's stable, name-derived MAC on the peer (becomes eth0) before + * the move, so the gateway's DHCP leases see one host across reboots. */ + { + uint8_t mac[6]; + ds_container_mac(cfg->container_name, mac); + if (ds_nl_set_mac(ctx, app_peer, mac) < 0) + ds_warn("[NET] Gateway: failed to pin MAC on %s", app_peer); } ds_net_disable_tx_checksum(app_host); @@ -964,32 +1097,130 @@ int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { if (ds_nl_link_up(ctx, app_host) < 0) ds_warn("[NET] Gateway: failed to bring up %s", app_host); - char child_netns[PATH_MAX]; - snprintf(child_netns, sizeof(child_netns), "/proc/%d/ns/net", (int)child_pid); - int child_netns_fd = open(child_netns, O_RDONLY | O_CLOEXEC); - if (child_netns_fd < 0) { - ds_warn("[NET] Gateway: failed to open container netns %s: %s", child_netns, + char netns[PATH_MAX]; + snprintf(netns, sizeof(netns), "/proc/%d/ns/net", (int)client_pid); + int netns_fd = open(netns, O_RDONLY | O_CLOEXEC); + if (netns_fd < 0) { + ds_warn("[NET] Gateway: failed to open client netns %s: %s", netns, strerror(errno)); + ds_nl_del_link(ctx, app_host); ds_nl_close(ctx); - ds_config_free(&gw_cfg); + goto out; + } + + /* Atomic move+rename into the client as eth0 (no transient raw-name device + * for the container's own DHCP/networkd to race), with a plain-move fallback. + */ + if (ds_nl_move_to_netns_named(ctx, app_peer, netns_fd, "eth0") != 0) { + ds_warn("[NET] Gateway: atomic move+rename of %s failed - falling back", + app_peer); + if (ds_nl_move_to_netns(ctx, app_peer, netns_fd) < 0) { + ds_warn("[NET] Gateway: move of %s into client netns failed", app_peer); + close(netns_fd); + ds_nl_del_link(ctx, app_host); + ds_nl_close(ctx); + goto out; + } + } + close(netns_fd); + ds_nl_close(ctx); + + if (ds_netns_rename_up(netns, app_peer, "eth0") < 0) + ds_warn("[NET] Gateway: wired '%s' but could not bring up its eth0", + cfg->container_name); + + ret = 0; + ds_log("Gateway: wiring complete for '%s': %s -> %s", cfg->container_name, + bridge, cfg->gateway_container); + +out: + gateway_segment_unlock(lock); + return ret; +} + +/* --------------------------------------------------------------------------- + * setup_gateway_veth_side + * + * Called at client start. OpenWrt gateway mode: Droidspaces owns only the L2 + * plumbing (bridge + veths), never NAT/DHCP/DNS/firewall - the gateway + * container runs that policy. + * + * If the gateway is already running we wire this client now. If it is NOT + * running we skip ALL networking and return: the gateway wires every running + * client itself when it (re)boots (ds_net_rewire_gateway_clients). Deferring + * instead of half-wiring also closes a race - a client started before its + * gateway must not bake in gateway/LAN settings the user may still edit before + * the gateway comes up. + * ---------------------------------------------------------------------------*/ + +int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { + if (!cfg || !cfg->gateway_container[0]) { + ds_warn("[NET] Gateway: no gateway container configured"); return -1; } - int r = ds_nl_move_to_netns(ctx, app_peer, child_netns_fd); - close(child_netns_fd); - if (r < 0) { - ds_warn("[NET] Gateway: failed to move %s into container netns", app_peer); - ds_nl_close(ctx); - ds_config_free(&gw_cfg); + if (strcmp(cfg->gateway_container, cfg->container_name) == 0) { + ds_warn("[NET] Gateway: refusing to attach container to itself"); return -1; } - ds_nl_close(ctx); - ds_config_free(&gw_cfg); + pid_t gw_pid = gateway_pid_of(cfg->gateway_container); + if (gw_pid <= 0) { + ds_warn("Gateway: '%s' not running - deferring all networking for " + "'%s'; the gateway will wire it when it starts", + cfg->gateway_container, cfg->container_name); + return 0; + } - ds_log("[NET] Gateway: delegated LAN ready: %s -> %s -> %s", - cfg->container_name, bridge, cfg->gateway_container); - return 0; + ds_log("[NET] Gateway: wiring '%s' to gateway '%s'", cfg->container_name, + cfg->gateway_container); + return gateway_wire_client(cfg, child_pid, gw_pid); +} + +/* --------------------------------------------------------------------------- + * ds_net_rewire_gateway_clients + * + * Gateway self-heal, driven by the gateway itself. On every boot the gateway + * container's monitor calls this: it scans the running containers for the ones + * that delegate to this gateway and (re)wires each into the gateway's current + * netns via gateway_wire_client. That one function also idempotently ensures + * the shared LAN uplink, so a single pass covers the gateway-side cable and + * every client's app veth. + * + * This restores clients after the gateway (re)boots - its old netns died and + * took the LAN cable with it - and wires clients that were started while the + * gateway was down. One actor, no client restart. + * ---------------------------------------------------------------------------*/ +void ds_net_rewire_gateway_clients(const char *gateway_name, + pid_t gateway_pid) { + if (!gateway_name || !gateway_name[0] || gateway_pid <= 0) + return; + + char containers_dir[PATH_MAX]; + snprintf(containers_dir, sizeof(containers_dir), "%s/Containers", + get_workspace_dir()); + DIR *d = opendir(containers_dir); + if (!d) + return; + + struct dirent *ent; + while ((ent = readdir(d)) != NULL) { + if (ent->d_name[0] == '.') + continue; + + struct ds_config c = {0}; + if (ds_config_load_by_name(ent->d_name, &c) != 0) + continue; + + pid_t p = 0; + if (c.net_mode == DS_NET_GATEWAY && c.gateway_container[0] && + strcmp(c.gateway_container, gateway_name) == 0 && + is_container_running(&c, &p) && p > 0) + gateway_wire_client(&c, p, gateway_pid); + + ds_config_free(&c); + } + closedir(d); } /* --------------------------------------------------------------------------- @@ -1000,11 +1231,7 @@ int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, const char *ip_str) { - (void)cfg; (void)ip_str; /* IP is now assigned by the container's own DHCP client */ - ds_log("[DEBUG] Child: Configuring isolated networking. Local PID: %d, " - "Peer: %s", - (int)getpid(), peer_name ? peer_name : "(null)"); ds_nl_ctx_t *ctx = ds_nl_open(); if (!ctx) { @@ -1012,6 +1239,26 @@ int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, return -1; } + /* Gateway mode: the host monitor owns the entire app-veth wiring - it renames + * the peer to eth0, pins the MAC, and brings it up *inside* this netns - so + * the child only needs loopback. This lets the gateway wire us identically + * whether we are starting now or already running when it (re)boots, and means + * a client started before its gateway simply has no eth0 until the gateway + * comes up and wires it. */ + if (cfg && cfg->net_mode == DS_NET_GATEWAY) { + ds_nl_link_up(ctx, "lo"); + ds_nl_close(ctx); + ds_log("[NET] Child: gateway mode - lo up; gateway container owns " + "eth0/DHCP/routing"); + return 0; + } + + /* NAT mode: the monitor moved the veth peer into this netns under its raw + * name; we rename it to eth0, pin the MAC, and bring it up. */ + ds_log("[DEBUG] Child: configuring container veth (peer %s -> eth0, local " + "PID %d)", + peer_name ? peer_name : "(null)", (int)getpid()); + /* 0. Rename interface to eth0 */ if (peer_name && peer_name[0] && strcmp(peer_name, "eth0") != 0) { ds_log("[DEBUG] Renaming %s to eth0...", peer_name); @@ -1039,10 +1286,7 @@ int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, ds_nl_link_up(ctx, "eth0"); ds_nl_close(ctx); - if (cfg && cfg->net_mode == DS_NET_GATEWAY) - ds_log("[NET] Child: eth0 UP - gateway container owns DHCP/routing"); - else - ds_log("[NET] Child: eth0 UP - awaiting DHCP lease from monitor"); + ds_log("[NET] Child: eth0 UP - awaiting DHCP lease from monitor"); return 0; } @@ -1070,9 +1314,23 @@ int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, static void setup_resolv_conf(struct ds_config *cfg) { const char *target; - if (cfg->net_mode == DS_NET_GATEWAY && !cfg->dns_servers[0] && - is_systemd_rootfs("/")) { - target = "/run/systemd/resolve/resolv.conf"; + /* Gateway mode with no explicit --dns: DNS belongs to the gateway (OpenWrt + * dnsmasq), advertised in the DHCP lease. Droidspaces must NOT write a + * static resolv.conf or it would bypass the gateway's DNS filtering/caching. + */ + if (cfg->net_mode == DS_NET_GATEWAY && !cfg->dns_servers[0]) { + if (is_systemd_rootfs("/")) { + /* systemd-resolved consumes the lease and publishes the real resolver. */ + target = "/run/systemd/resolve/resolv.conf"; + } else { + /* Non-systemd: leave /etc/resolv.conf to the container's own DHCP client, + * which writes the gateway-supplied nameserver from the lease. Writing a + * hardcoded 1.1.1.1/8.8.8.8 here would silently defeat the gateway's DNS + * (adblock, split-horizon, etc.). Pass --dns to override. */ + ds_log("[NET] Gateway: leaving /etc/resolv.conf to the container's DHCP " + "client (gateway owns DNS)"); + return; + } } else { mkdir("/run/droidspaces", 0755); write_file("/run/droidspaces/resolv.conf", cfg->dns_server_content); @@ -1519,28 +1777,48 @@ static void *route_monitor_loop(void *arg) { } void ds_net_stop_route_monitor(void) { - g_stop_monitor = 1; pthread_mutex_lock(&g_gw_mutex); + int started = g_route_monitor_started; + pthread_t tid = g_route_monitor_tid; + g_stop_monitor = 1; if (g_route_monitor_sock >= 0) shutdown(g_route_monitor_sock, SHUT_RDWR); pthread_mutex_unlock(&g_gw_mutex); + + /* Join so the monitor is fully stopped before cleanup removes the shared + * MASQUERADE / FIB policy rules - otherwise an in-flight do_uplink_reprobe() + * could re-add a rule we just deleted, or rewrite ip_forward after teardown. + */ + if (started) { + pthread_join(tid, NULL); + pthread_mutex_lock(&g_gw_mutex); + g_route_monitor_started = 0; + pthread_mutex_unlock(&g_gw_mutex); + } } void ds_net_start_route_monitor(void) { if (!is_android()) return; + pthread_mutex_lock(&g_gw_mutex); + /* Idempotent: exactly one monitor thread per process. setup_veth_host_side + * calls this on every boot cycle (including reboots, which never stop the + * monitor); without this guard each reboot would spawn another thread, all + * racing the same FIB policy rule. The monitor tracks the host uplink, which + * is independent of the container PID, so a single instance rightly persists + * across the container's reboots. Joinable (default attr) so stop can join. + */ + if (g_route_monitor_started) { + pthread_mutex_unlock(&g_gw_mutex); + return; + } g_stop_monitor = 0; - - pthread_t tid; - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - - if (pthread_create(&tid, &attr, route_monitor_loop, NULL) != 0) + if (pthread_create(&g_route_monitor_tid, NULL, route_monitor_loop, NULL) != 0) ds_warn("[NET] Failed to start route monitor thread: %s", strerror(errno)); - - pthread_attr_destroy(&attr); + else + g_route_monitor_started = 1; + pthread_mutex_unlock(&g_gw_mutex); } /* --------------------------------------------------------------------------- @@ -1557,37 +1835,43 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { pid_t effective_pid = container_pid > 0 ? container_pid : cfg->container_pid; if (effective_pid > 0) { - veth_host_name(effective_pid, veth_host, sizeof(veth_host)); + veth_host_name(cfg, effective_pid, veth_host, sizeof(veth_host)); + + char bridge[IFNAMSIZ]; + gateway_bridge_name(cfg, bridge, sizeof(bridge)); + + /* Hold the segment lock across our veth removal + the client count + + * reap, so a concurrent client's setup (which holds the same lock while + * attaching its veth) cannot slip in between our count and our reap. */ + int lock = gateway_segment_lock(bridge); + ds_nl_del_link(ctx, veth_host); ds_log("[NET] Gateway cleanup: removed %s", veth_host); - /* Refcount the delegated bridge: once the last client veth has left, - * reap the now-empty bridge so idle segments don't linger. */ - char bridge[IFNAMSIZ], gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; - gateway_bridge_name(cfg, bridge, sizeof(bridge)); - gateway_veth_names(cfg, gw_host, sizeof(gw_host), gw_peer, - sizeof(gw_peer)); - int clients = ds_nl_count_bridge_members_with_prefix(ctx, bridge, "ds-v"); + int clients = ds_nl_count_bridge_members_with_prefix( + ctx, bridge, app_veth_host_prefix(cfg)); if (clients > 0) { ds_log("[NET] Gateway cleanup: %d client(s) still on %s - keeping " "delegated LAN", clients, bridge); + } else if (gateway_pid_of(cfg->gateway_container) > 0) { + /* No clients left, but the gateway is still up. Do NOT reap the + * bridge: deleting it flaps the gateway's live LAN iface (eth1) + * carrier, and netifd occasionally fails to re-init it ("device + * initialization failed"). The IP-less, policy-free bridge is harmless + * idle and the next client reuses it; the gateway veth stays put. */ + ds_log("[NET] Gateway cleanup: no clients on %s but gateway '%s' is up " + "- keeping bridge to avoid flapping its LAN iface", + bridge, cfg->gateway_container); } else { - /* Reap the empty bridge, but NEVER delete the gateway veth (gw_host): - * its peer is the gateway container's live LAN interface (e.g. eth1), - * and deleting either end destroys the pair - ripping the cable out of - * a still-running gateway. OpenWrt's netifd will not re-init a brand - * new eth1 that reappears under it, so its DHCP/LAN stays dead until - * the gateway is rebooted. Leaving gw_host masterless keeps eth1 - * alive; the next client recreates the bridge and reattaches gw_host - * (setup_gateway_veth_side else-branch). When the gateway container - * itself stops, its netns dies and gw_host vanishes with its peer, - * leaving zero residue. */ + /* No clients and the gateway is gone (its netns death already took the + * gateway veth with its peer) - safe to reap the now-idle bridge. */ ds_nl_del_link(ctx, bridge); - ds_log("[NET] Gateway cleanup: reaped idle delegated LAN bridge %s " - "(kept gateway veth %s)", - bridge, gw_host); + ds_log("[NET] Gateway cleanup: reaped idle delegated LAN bridge %s", + bridge); } + + gateway_segment_unlock(lock); } else { ds_warn("[NET] Gateway cleanup: cannot derive veth name - no valid PID"); } @@ -1612,7 +1896,7 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { ds_warn("[NET] cleanup: cannot derive veth name - no valid PID"); /* still proceed with iptables cleanup */ } else { - veth_host_name(effective_pid, veth_host, sizeof(veth_host)); + veth_host_name(cfg, effective_pid, veth_host, sizeof(veth_host)); ds_nl_del_link(ctx, veth_host); } From d035ce8d6a7e40ac668da69feb586c5ff556cf9f Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Mon, 29 Jun 2026 22:53:56 +0530 Subject: [PATCH 074/211] mount: don't force a full e2fsck on every container start Drop -f from e2fsck: it now exits fast on a clean image and only runs a full repair when the fs is actually marked unclean. Signed-off-by: ravindu644 --- src/mount.c | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/mount.c b/src/mount.c index 0aa3347a..583d2b2f 100644 --- a/src/mount.c +++ b/src/mount.c @@ -1018,10 +1018,14 @@ int mount_rootfs_img(const char *img_path, char *mount_point, size_t mp_size, return -1; } - /* e2fsck: only for ext images */ + /* e2fsck: only for ext images. No -f: e2fsck consults the superblock and + * exits in milliseconds when the fs was unmounted cleanly, replays the + * journal when it was not, and runs a full repair only when the kernel + * flagged an error (errors=remount-ro) or the fs is otherwise marked unclean + * - so a clean start pays nothing instead of a forced full scan every boot. + */ if (strcmp(fstype, "ext4") == 0) { - char *e2fsck_argv[] = {"e2fsck", "-f", "-y", (char *)(uintptr_t)img_path, - NULL}; + char *e2fsck_argv[] = {"e2fsck", "-y", (char *)(uintptr_t)img_path, NULL}; if (run_command_quiet(e2fsck_argv) == 0) ds_log("Image checked and repaired successfully."); } From f702611bf59e643071aae20ea583a35797d90cca Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 00:35:58 +0530 Subject: [PATCH 075/211] net: add --upstream to pin NAT WAN to specific interface(s) Optional manual override for NAT mode. When --upstream is set, the WAN is forced through the listed interface(s) and automatic uplink detection is disabled entirely - it never hops to whatever interface netd marks as the active default network. Within the pinned list it does still fail over in priority (list) order: the route monitor re-resolves on every link/route change and selects the first listed interface that is up and has a default route. So a pin like "wlan0,rmnet*" prefers Wi-Fi and falls back to mobile data when Wi-Fi drops - the hop stays inside the user's chosen set, never outside it. Literals and wildcards are both supported (use rmnet* for mobile data, whose interface number is not stable across reconnects). If no pinned interface is up at start, or the active one disappears mid-run, the FROM rule is removed (so traffic can't leak onto a recycled table) and reinstalled automatically when one returns. Unset = today's automatic detection, unchanged. The CSV parser is shared between the CLI and config loader; the app restores the upstream-interface picker as optional with a pinning hint (string keys renamed to upstream_interface_title/_hint across locales). Signed-off-by: ravindu644 --- .../app/ui/component/UpstreamInterfaceList.kt | 262 ++++++++++++++++++ .../ui/navigation/DroidspacesNavigation.kt | 5 +- .../app/ui/screen/ContainerConfigScreen.kt | 24 +- .../app/ui/screen/EditContainerScreen.kt | 24 ++ .../ui/screen/InstallationSummaryScreen.kt | 6 +- .../ContainerInstallationViewModel.kt | 7 + .../droidspaces/app/util/ContainerManager.kt | 31 +++ .../app/src/main/res/values-de/strings.xml | 6 + .../app/src/main/res/values-in/strings.xml | 6 + .../app/src/main/res/values-it/strings.xml | 6 + .../app/src/main/res/values-ja/strings.xml | 6 + .../app/src/main/res/values-km/strings.xml | 6 + .../app/src/main/res/values-ro/strings.xml | 6 + .../app/src/main/res/values-si/strings.xml | 6 + .../app/src/main/res/values-tr/strings.xml | 6 + .../src/main/res/values-zh-rCN/strings.xml | 6 + .../src/main/res/values-zh-rTW/strings.xml | 6 + Android/app/src/main/res/values/strings.xml | 6 + .../app/src/main/res/xml/locales_config.xml | 1 + src/config.c | 20 +- src/container.c | 17 ++ src/documentation.c | 6 +- src/include/droidspace.h | 10 + src/main.c | 26 +- src/net/network.c | 147 +++++++++- src/utils.c | 31 +++ 26 files changed, 661 insertions(+), 22 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt new file mode 100644 index 00000000..60e8323f --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt @@ -0,0 +1,262 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droidspaces.app.R +import com.droidspaces.app.util.ContainerManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun UpstreamInterfaceList( + upstreamInterfaces: List, + onInterfacesChange: (List) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var showUpstreamDialog by remember { mutableStateOf(false) } + var availableUpstreams by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + availableUpstreams = ContainerManager.listUpstreamInterfaces() + } + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Selected interfaces + upstreamInterfaces.forEach { iface -> + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = iface, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + IconButton(onClick = { onInterfacesChange(upstreamInterfaces - iface) }) { + Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + } + } + } + } + + // Add Button + if (upstreamInterfaces.size < 8) { + val addBtnShape = RoundedCornerShape(16.dp) + Surface( + modifier = Modifier.fillMaxWidth().clip(addBtnShape).clickable( + onClick = { showUpstreamDialog = true } + ), + shape = addBtnShape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.width(8.dp)) + Text( + context.getString(R.string.add_upstream_interface), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + if (showUpstreamDialog) { + AddUpstreamDialog( + availableUpstreams = availableUpstreams, + selectedInterfaces = upstreamInterfaces, + onDismiss = { showUpstreamDialog = false }, + onRefresh = { + val newOnes = ContainerManager.listUpstreamInterfaces() + availableUpstreams = newOnes + }, + onAdd = { iface -> + if (!upstreamInterfaces.contains(iface)) { + onInterfacesChange(upstreamInterfaces + iface) + } + showUpstreamDialog = false + } + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AddUpstreamDialog( + availableUpstreams: List, + selectedInterfaces: List, + onDismiss: () -> Unit, + onRefresh: suspend () -> Unit, + onAdd: (String) -> Unit +) { + val context = LocalContext.current + var customIface by remember { mutableStateOf("") } + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + val rotation by animateFloatAsState( + targetValue = if (isRefreshing) 360f else 0f, + animationSpec = if (isRefreshing) { + tween(durationMillis = 600, easing = LinearEasing) + } else { + tween(durationMillis = 0, easing = LinearEasing) + }, + label = "refresh_rotation" + ) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.fillMaxWidth(0.92f).wrapContentHeight(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = context.getString(R.string.add_upstream_interface), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + IconButton( + onClick = { + if (!isRefreshing) { + isRefreshing = true + scope.launch { + val startTime = System.currentTimeMillis() + onRefresh() + val elapsed = System.currentTimeMillis() - startTime + if (elapsed < 600L) delay(600L - elapsed) + isRefreshing = false + } + } + }, + enabled = !isRefreshing + ) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(20.dp).graphicsLayer { rotationZ = rotation } + ) + } + } + + if (availableUpstreams.isNotEmpty()) { + Text(context.getString(R.string.available_system_interfaces), style = MaterialTheme.typography.labelMedium) + Box(modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp)) { + FlowRow( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + availableUpstreams.forEach { iface -> + OutlinedButton( + onClick = { onAdd(iface) }, + enabled = !selectedInterfaces.contains(iface), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) + ) { + Text(iface) + } + } + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + Text(context.getString(R.string.enter_manually), style = MaterialTheme.typography.labelMedium) + OutlinedTextField( + value = customIface, + onValueChange = { customIface = it }, + label = { Text(context.getString(R.string.interface_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + } + } + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( + enabled = customIface.isNotBlank() && selectedInterfaces.size < 8, + onClick = { onAdd(customIface.trim()) } + ), + shape = RoundedCornerShape(14.dp), + color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text( + context.getString(R.string.add), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index b55bc7c1..e9cc2f9a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -327,6 +327,7 @@ fun DroidspacesNavigation( initialBlockNestedNs = viewModel.blockNestedNs, initialPrivileged = viewModel.privileged, initialEnvFileContent = viewModel.envFileContent ?: "", + initialUpstreamInterfaces = viewModel.upstreamInterfaces, initialPortForwards = viewModel.portForwards, initialGatewayContainer = viewModel.gatewayContainer, initialGatewayNet = viewModel.gatewayNet, @@ -334,8 +335,8 @@ fun DroidspacesNavigation( initialGatewayBridge = viewModel.gatewayBridge, containerName = viewModel.containerName, installedContainers = sharedContainerViewModel.containerList, - onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge -> - viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, portForwards, forceCgroupv1, blockNestedNs, privileged, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) + onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge -> + viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, upstreamInterfaces, portForwards, forceCgroupv1, blockNestedNs, privileged, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) navController.navigate(Screen.SparseImageConfig.route) }, onBack = { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index fd97deb9..0a253ff8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import com.droidspaces.app.ui.component.ToggleCard import com.droidspaces.app.ui.component.DsDropdown import androidx.compose.material.icons.filled.Public +import com.droidspaces.app.ui.component.UpstreamInterfaceList import com.droidspaces.app.ui.component.PortForwardingList import androidx.compose.ui.platform.LocalContext import com.droidspaces.app.R @@ -75,6 +76,7 @@ fun ContainerConfigScreen( initialBlockNestedNs: Boolean = false, initialPrivileged: String = "", initialEnvFileContent: String = "", + initialUpstreamInterfaces: List = emptyList(), initialPortForwards: List = emptyList(), initialGatewayContainer: String = "", initialGatewayNet: String = "", @@ -104,6 +106,7 @@ fun ContainerConfigScreen( blockNestedNs: Boolean, privileged: String, envFileContent: String?, + upstreamInterfaces: List, portForwards: List, gatewayContainer: String, gatewayNet: String, @@ -132,6 +135,7 @@ fun ContainerConfigScreen( var forceCgroupv1 by remember { mutableStateOf(initialForceCgroupv1) } var blockNestedNs by remember { mutableStateOf(initialBlockNestedNs) } var envFileContent by remember { mutableStateOf(initialEnvFileContent) } + var upstreamInterfaces by remember { mutableStateOf(initialUpstreamInterfaces) } var portForwards by remember { mutableStateOf(initialPortForwards) } var privileged by remember { mutableStateOf(initialPrivileged) } var gatewayContainer by remember { mutableStateOf(initialGatewayContainer) } @@ -319,7 +323,7 @@ fun ContainerConfigScreen( .clickable( enabled = canProceed, onClick = { - onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) + onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) }, indication = androidx.compose.material.ripple.rememberRipple(bounded = true), interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } @@ -520,6 +524,24 @@ fun ContainerConfigScreen( ) } + // Upstream Interface (optional pin) + Text( + text = context.getString(R.string.upstream_interface_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = context.getString(R.string.upstream_interface_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + UpstreamInterfaceList( + upstreamInterfaces = upstreamInterfaces, + onInterfacesChange = { upstreamInterfaces = it } + ) + // Port Forwards Text( text = context.getString(R.string.port_forwarding), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index 5380574b..bc31bb1d 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -53,6 +53,7 @@ import com.droidspaces.app.ui.component.HardwareAccessDialog import com.droidspaces.app.ui.component.DsDropdown import com.droidspaces.app.ui.component.GatewaySettingsSection import androidx.compose.material.icons.filled.Public +import com.droidspaces.app.ui.component.UpstreamInterfaceList import com.droidspaces.app.ui.component.PortForwardingList import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -106,6 +107,7 @@ fun EditContainerScreen( var dnsServers by remember { mutableStateOf(container.dnsServers) } var runAtBoot by remember { mutableStateOf(container.runAtBoot) } var envFileContent by remember { mutableStateOf(container.envFileContent ?: "") } + var upstreamInterfaces by remember { mutableStateOf(container.upstreamInterfaces) } var portForwards by remember { mutableStateOf(container.portForwards) } var forceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } var blockNestedNs by remember { mutableStateOf(container.blockNestedNs) } @@ -145,6 +147,7 @@ fun EditContainerScreen( var savedDnsServers by remember { mutableStateOf(container.dnsServers) } var savedRunAtBoot by remember { mutableStateOf(container.runAtBoot) } var savedEnvFileContent by remember { mutableStateOf(container.envFileContent ?: "") } + var savedUpstreamInterfaces by remember { mutableStateOf(container.upstreamInterfaces) } var savedPortForwards by remember { mutableStateOf(container.portForwards) } var savedForceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } var savedBlockNestedNs by remember { mutableStateOf(container.blockNestedNs) } @@ -186,6 +189,7 @@ fun EditContainerScreen( dnsServers != savedDnsServers || runAtBoot != savedRunAtBoot || envFileContent != savedEnvFileContent || + upstreamInterfaces != savedUpstreamInterfaces || portForwards != savedPortForwards || forceCgroupv1 != savedForceCgroupv1 || blockNestedNs != savedBlockNestedNs || @@ -233,6 +237,7 @@ fun EditContainerScreen( dnsServers = dnsServers, runAtBoot = runAtBoot, envFileContent = if (envFileContent.isBlank()) null else envFileContent, + upstreamInterfaces = upstreamInterfaces, portForwards = portForwards, forceCgroupv1 = forceCgroupv1, blockNestedNs = blockNestedNs, @@ -271,6 +276,7 @@ fun EditContainerScreen( savedDnsServers = dnsServers savedRunAtBoot = runAtBoot savedEnvFileContent = envFileContent + savedUpstreamInterfaces = upstreamInterfaces savedPortForwards = portForwards savedForceCgroupv1 = forceCgroupv1 savedBlockNestedNs = blockNestedNs @@ -778,6 +784,24 @@ fun EditContainerScreen( ) } + // Upstream Interface (optional pin) + Text( + text = context.getString(R.string.upstream_interface_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = context.getString(R.string.upstream_interface_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + UpstreamInterfaceList( + upstreamInterfaces = upstreamInterfaces, + onInterfacesChange = { upstreamInterfaces = it } + ) + // Port Forwards Text( text = context.getString(R.string.port_forwarding), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index 578ddd70..94b91089 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -195,6 +195,10 @@ fun InstallationSummaryScreen( } } + if (config.upstreamInterfaces.isNotEmpty()) { + SummaryItem(stringResource(R.string.upstream_interface_title), config.upstreamInterfaces.joinToString(", "), Icons.Default.Public) + } + if (config.portForwards.isNotEmpty()) { config.portForwards.forEach { forward -> SummaryItem(stringResource(R.string.port_forwarding), "${forward.hostPort} → ${forward.containerPort ?: forward.hostPort} (${forward.proto})", Icons.AutoMirrored.Filled.ArrowForward) @@ -207,7 +211,7 @@ fun InstallationSummaryScreen( !config.runAtBoot && !config.disableIPv6 && !config.enableTermuxX11 && !config.enableVirgl && !config.enablePulseaudio && !config.forceCgroupv1 && !config.blockNestedNs && - config.portForwards.isEmpty() && + config.upstreamInterfaces.isEmpty() && config.portForwards.isEmpty() && config.envFileContent.isNullOrBlank()) { Text( text = stringResource(R.string.no_options_enabled), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt index 06fbcdf2..9165f6f3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt @@ -96,6 +96,9 @@ class ContainerInstallationViewModel : ViewModel() { var sparseImageSizeGB: Int by mutableStateOf(8) private set + var upstreamInterfaces: List by mutableStateOf(emptyList()) + private set + var portForwards: List by mutableStateOf(emptyList()) private set @@ -141,6 +144,7 @@ class ContainerInstallationViewModel : ViewModel() { customInit: String, staticNatIp: String, envFileContent: String?, + upstreamInterfaces: List, portForwards: List, forceCgroupv1: Boolean, blockNestedNs: Boolean, @@ -168,6 +172,7 @@ class ContainerInstallationViewModel : ViewModel() { this.customInit = customInit this.staticNatIp = staticNatIp this.envFileContent = envFileContent + this.upstreamInterfaces = upstreamInterfaces this.portForwards = portForwards this.forceCgroupv1 = forceCgroupv1 this.blockNestedNs = blockNestedNs @@ -215,6 +220,7 @@ class ContainerInstallationViewModel : ViewModel() { status = ContainerStatus.STOPPED, // Default status for new container useSparseImage = useSparseImage, sparseImageSizeGB = if (useSparseImage) sparseImageSizeGB else null, + upstreamInterfaces = upstreamInterfaces, portForwards = portForwards, forceCgroupv1 = forceCgroupv1, blockNestedNs = blockNestedNs, @@ -250,6 +256,7 @@ class ContainerInstallationViewModel : ViewModel() { envFileContent = null useSparseImage = true sparseImageSizeGB = 8 + upstreamInterfaces = emptyList() portForwards = emptyList() forceCgroupv1 = false blockNestedNs = false diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index 31b89878..3c006238 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -50,6 +50,7 @@ data class ContainerInfo( val useSparseImage: Boolean = false, val sparseImageSizeGB: Int? = null, val envFileContent: String? = null, + val upstreamInterfaces: List = emptyList(), val portForwards: List = emptyList(), val forceCgroupv1: Boolean = false, val blockNestedNs: Boolean = false, @@ -87,6 +88,9 @@ data class ContainerInfo( if (bindMounts.isNotEmpty()) { appendLine("bind_mounts=${bindMounts.joinToString(",") { "${it.src}:${it.dest}${if (it.ro) ":ro" else ""}" }}") } + if (netMode == "nat" && upstreamInterfaces.isNotEmpty()) { + appendLine("upstream_interfaces=${upstreamInterfaces.joinToString(",")}") + } if (netMode == "nat" && portForwards.isNotEmpty()) { appendLine("port_forwards=${portForwards.joinToString(",") { val mapping = if (it.containerPort != null) "${it.hostPort}:${it.containerPort}" else it.hostPort @@ -280,6 +284,9 @@ object ContainerManager { } } ?: emptyList() + // Parse upstream interfaces + val upstreamInterfaces = configMap["upstream_interfaces"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + // Parse port forwards: 8080:80/tcp, 9090:90/udp, 1000-2000/tcp (shorthand) val portForwards = configMap["port_forwards"]?.split(",")?.mapNotNull { pfStr -> try { @@ -323,6 +330,7 @@ object ContainerManager { useSparseImage = useSparseImage, sparseImageSizeGB = sparseImageSizeGB, envFileContent = loadEnvFileContent(containerName), + upstreamInterfaces = upstreamInterfaces, portForwards = portForwards, forceCgroupv1 = configMap["force_cgroupv1"] == "1", blockNestedNs = configMap["block_nested_ns"] == "1", @@ -410,6 +418,29 @@ object ContainerManager { } } + /** + * List active upstream interfaces by scanning all routing tables. + * + * Uses `table all` instead of the default table so that CLAT/Qualcomm + * devices are correctly detected - on these devices every interface has + * its own per-interface routing table and nothing appears in the main + * table, so `ip route show default` returns empty. + */ + suspend fun listUpstreamInterfaces(): List = withContext(Dispatchers.IO) { + try { + val busybox = Constants.BUSYBOX_BINARY_PATH + val cmd = "ip route show table all | $busybox grep '^default' | $busybox awk '{for(i=1;i<=NF;i++) if(\$i==\"dev\") print \$(i+1)}' | $busybox grep -Ev '^(ds-|dummy)' | $busybox sort -u" + val result = Shell.cmd(cmd).exec() + if (result.isSuccess) { + result.out.map { it.trim() }.filter { it.isNotEmpty() } + } else { + emptyList() + } + } catch (e: Exception) { + emptyList() + } + } + /** * Update container configuration. * Only updates the configurable options (hostname, flags), not name or rootfsPath. diff --git a/Android/app/src/main/res/values-de/strings.xml b/Android/app/src/main/res/values-de/strings.xml index 207ba370..60dfe64e 100644 --- a/Android/app/src/main/res/values-de/strings.xml +++ b/Android/app/src/main/res/values-de/strings.xml @@ -325,6 +325,12 @@ NAT Ohne NAT Einstellungen + Upstream-Schnittstelle (optional) + Internetzugang an bestimmte Schnittstelle(n) binden. Leer lassen, um den aktiven Uplink automatisch zu erkennen. + Upstream-Schnittstelle hinzufügen + Verfügbare Systemschnittstellen: + Oder manuell eingeben (Platzhalter werden ebenfalls unterstützt): + Schnittstellenname (z.B. wlan0, v4-rmnet_data*) Portweiterleitung Portweiterleitung hinzufügen Host-Port (1–65535 oder Bereich 100–200) diff --git a/Android/app/src/main/res/values-in/strings.xml b/Android/app/src/main/res/values-in/strings.xml index 1b7d7025..dc1764eb 100644 --- a/Android/app/src/main/res/values-in/strings.xml +++ b/Android/app/src/main/res/values-in/strings.xml @@ -145,6 +145,12 @@ NAT (Default) Tidak ada (terisolasi secara fisik) Pengaturan NAT + Antarmuka Upstream (Opsional) + Sematkan akses internet ke antarmuka tertentu. Kosongkan untuk mendeteksi uplink aktif secara otomatis. + Tambahkan Antarmuka Upstream + Antarmuka Sistem yang Tersedia: + Atau masukkan secara manual (karakter pengganti juga didukung): + Nama Antarmuka (misalnya wlan0, v4-rmnet_data*) Tambahkan Port Forward Port Host (1–65535 atau rentang 100–200) Port Kontainer (1–65535 atau rentang) diff --git a/Android/app/src/main/res/values-it/strings.xml b/Android/app/src/main/res/values-it/strings.xml index ec4c1808..9388075d 100644 --- a/Android/app/src/main/res/values-it/strings.xml +++ b/Android/app/src/main/res/values-it/strings.xml @@ -161,6 +161,12 @@ NAT (Predefinita) Nessuna (Air-gapped) Impostazioni NAT + Interfaccia Upstream (Opzionale) + Vincola la connessione a internet a interfacce specifiche. Lascia vuoto per rilevare automaticamente la connessione attiva. + Aggiungi interfaccia upstream + Interfacce di sistema disponibili: + O inseriscila manualmente (i wildcard sono supportati): + Nome interfaccia (es. wlan0, v4-rmnet_data*) Inoltro Porte Aggiungi Port Forward Porta Host (1-65535 o intervallo 100-200) diff --git a/Android/app/src/main/res/values-ja/strings.xml b/Android/app/src/main/res/values-ja/strings.xml index cd3da14b..98a36584 100644 --- a/Android/app/src/main/res/values-ja/strings.xml +++ b/Android/app/src/main/res/values-ja/strings.xml @@ -245,12 +245,18 @@ SELinuxを記録のみ(permissive)に設定する 旧来のCgroup V1階層構造の利用を強制する 手動デッドロック防止措置 + あるいは手動で入力してください(ワイルドカードも利用できます): + インタフェース名 (wlan0、 v4-rmnet_data*など) Androidの為のLXC風コンテナマネージャ 「%1$s」が選択されました。.tar.xz か .tar.gz 形式のファイルを選択してください。 ファイル形式を検出できませんでした。.tar.xz か .tar.gz 形式のファイルを選択してください。 ディストリビューション ネットワークの方式がNATやNoneであるときには、IPv6は常に無効です。 GPUのノードを隔離された/dev にミラーリングする (完全なるハードウェア透過はしない) + 上流インタフェース(任意) + インターネット接続を特定のインタフェースに固定します。空欄にすると、アクティブな上流回線を自動検出します。 + 上流インタフェースの追加 + 利用可能なシステム・インタフェース: 対称割当の場合は空欄にしてください 不正な範囲形式です(開始-終了) ポート番号は1から65535でなければなりません diff --git a/Android/app/src/main/res/values-km/strings.xml b/Android/app/src/main/res/values-km/strings.xml index 741afe42..71581e00 100644 --- a/Android/app/src/main/res/values-km/strings.xml +++ b/Android/app/src/main/res/values-km/strings.xml @@ -183,6 +183,12 @@ NAT គ្មាន ការកំណត់ NAT + ចំណុចប្រទាក់ខាងលើ (ស្រេចចិត្ត) + ភ្ជាប់អ៊ីនធឺណិតទៅចំណុចប្រទាក់ជាក់លាក់។ ទុកឱ្យនៅទទេ ដើម្បីរកឃើញការតភ្ជាប់សកម្មដោយស្វ័យប្រវត្តិ។ + បន្ថែមចំណុចប្រទាក់ខាងលើ + ចំណុចប្រទាក់ប្រព័ន្ធដែលអាចប្រើបាន៖ + ឬបញ្ចូលវាដោយដៃ (អក្សរជំនួសក៏ត្រូវបានគាំទ្រផងដែរ)៖ + ឈ្មោះចំណុចប្រទាក់ (ឧ. wlan0, v4-rmnet_data*) ការបញ្ជូនបន្តច្រក បន្ថែម​ច្រក​បញ្ជូនបន្ត ច្រកម៉ាស៊ីន (1-65535 ឬចន្លោះពី 100-200) diff --git a/Android/app/src/main/res/values-ro/strings.xml b/Android/app/src/main/res/values-ro/strings.xml index bccee0ad..e82e7408 100644 --- a/Android/app/src/main/res/values-ro/strings.xml +++ b/Android/app/src/main/res/values-ro/strings.xml @@ -126,6 +126,12 @@ NAT (implicit) Niciunul (fără nicio conexiune la rețea) Setări NAT + Interfață de uplink (opțională) + Fixează accesul la internet pe anumite interfețe. Lasă gol pentru a detecta automat uplink-ul activ. + Adaugă interfață de uplink + Interfețe de sistem disponibile: + Sau scrie-le manual (de asemenea, wildcard-urile sunt suportate): + Numele interfeței (de exemplu, wlan0, v4-rmnet_data*) Adaugă port forward Portul gazdei (1-65535 sau intervalul 100-200) Portul container-ului (1-65535 sau interval) diff --git a/Android/app/src/main/res/values-si/strings.xml b/Android/app/src/main/res/values-si/strings.xml index f6d39cd3..b6bb7a68 100644 --- a/Android/app/src/main/res/values-si/strings.xml +++ b/Android/app/src/main/res/values-si/strings.xml @@ -137,6 +137,12 @@ NAT (පෙරනිමිය) කිසිවක් නැත (Air-gapped) NAT සැකසුම් + ප්‍රභව අතුරුමුහුණත (විකල්ප) + අන්තර්ජාල ප්‍රවේශය නිශ්චිත අතුරුමුහුණතකට සම්බන්ධ කරන්න. සක්‍රිය ප්‍රභවය ස්වයංක්‍රීයව හඳුනා ගැනීමට හිස්ව තබන්න. + ප්‍රභව අතුරුමුහුණතක් එක් කරන්න + පද්ධතියේ පවතින අතුරුමුහුණත්: + නැතිනම් එය ඔබම ඇතුළත් කරන්න (Wildcards සඳහාද සහාය දක්වයි): + අතුරුමුහුණතේ නම (උදා: wlan0, v4-rmnet_data*) Port Forwarding Port Forward එකක් එක් කරන්න මව් පද්ධතියේ පෝට් එක (1-65535 හෝ පරාසය 100-200) diff --git a/Android/app/src/main/res/values-tr/strings.xml b/Android/app/src/main/res/values-tr/strings.xml index cd247d10..ff64bac9 100644 --- a/Android/app/src/main/res/values-tr/strings.xml +++ b/Android/app/src/main/res/values-tr/strings.xml @@ -148,6 +148,12 @@ NAT (Izole) Yok NAT Ayarları + Upstream Arayüzü (İsteğe bağlı) + İnternet erişimini belirli arayüze sabitler. Etkin bağlantıyı otomatik algılamak için boş bırakın. + Upstream Arayuzu Ekle + Kullanılabilir Sistem Arayüzleri: + Ya da manuel olarak girin (joker karakterler de desteklenmektedir): + Arayuz Adı (örn. wlan0, v4-rmnet_data*) Port Yönlendirme Port Yonlendirme Ekle Host Bağlantı Noktası (1-65535 veya 100-200 aralığı) diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index 7cb5a704..90da5ca4 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -136,6 +136,12 @@ NAT(默认) 无(完全隔离) NAT 设置 + 上游网络接口(可选) + 将网络访问固定到指定接口。留空则自动检测当前活动的上游接口。 + 添加上游网络接口 + 可用的系统接口: + 或手动输入(支持通配符): + 接口名称(如:wlan0, v4-rmnet_data*) 端口转发 添加端口转发 主机端口(1-65535 或范围 100-200) diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index 1b29168a..c79376eb 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -19,6 +19,7 @@ 留空以使用預設值 此IP 已指派給“%1$s ” 異常 + 或手動輸入(支援通配符): 後端: 未安裝 無法讀取容器資訊 使用稀疏映像檔 @@ -39,6 +40,7 @@ 守護程式模式 核心 失敗%1$s %2$s + 新增上游網路介面 容器資訊 編輯容器:%1$s 選擇使用者 @@ -163,6 +165,7 @@ 無法取得Root 權限 已存在同名容器 無(完全隔離) + 上游網路介面(可選) 停用 使用系統預設方案 輸入容器路徑 @@ -179,6 +182,7 @@ 正在移除舊模組... 強制使用舊版Cgroup V1 層級結構 Android 版本 + 可用的系統介面: 卸載容器失敗 %1$s 範圍格式無效(起始-結束) 容器連接埠(1-65535 或範圍) @@ -257,6 +261,7 @@ , 未啟用任何附加選項 未找到服務 + 介面名稱(如:wlan0, v4-rmnet_data*) 僅允許數字、點和冒號 添加 模組安裝失敗 @@ -273,6 +278,7 @@ 安裝概要 正在檢查root存取權限... 重新整理用戶 + 將網路存取固定到指定介面。留空則自動偵測目前作用中的上游介面。 屏蔽服務 留空表示對稱映射 必須指定大小 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index d6989198..3f62a305 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -256,6 +256,12 @@ Bridge already used by \'%1$s\'. Use a different LAN name. Interface already used by another segment (\'%1$s\'). Use a different one. NAT Settings + Upstream Interface (Optional) + Pin internet access to specific interface(s). Leave empty to auto-detect the active uplink. + Add Upstream Interface + Available System Interfaces: + Or enter it manually (wildcards are supported too): + Interface Name (e.g. wlan0, v4-rmnet_data*) Port Forwarding Add Port Forward Host Port (1-65535 or range 100-200) diff --git a/Android/app/src/main/res/xml/locales_config.xml b/Android/app/src/main/res/xml/locales_config.xml index f553c1ef..558b4696 100644 --- a/Android/app/src/main/res/xml/locales_config.xml +++ b/Android/app/src/main/res/xml/locales_config.xml @@ -19,6 +19,7 @@ + diff --git a/src/config.c b/src/config.c index f77785ad..5ef0158d 100644 --- a/src/config.c +++ b/src/config.c @@ -393,9 +393,15 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { else ds_warn("config: ignoring too-long gateway_lan_ifname '%s'", val); } else if (strcmp(key, "upstream_interfaces") == 0) { - /* Legacy key from pre-auto-detection builds - the active uplink is - * now detected automatically. Silently swallow it so it is neither - * applied nor re-saved as an unknown line. */ + /* Comma-separated interface names/wildcards, e.g. "wlan0,rmnet*". When + * present this pins NAT WAN to these interfaces in priority order and + * disables automatic uplink detection. */ + if (ds_parse_iface_csv(val, cfg->upstream_ifaces, + &cfg->upstream_iface_count, + DS_MAX_UPSTREAM_IFACES) > 0) + ds_warn("config: too many upstream_interfaces (max %d) - extra entries " + "ignored", + DS_MAX_UPSTREAM_IFACES); } else if (strcmp(key, "port_forwards") == 0) { /* Comma-separated HOST:CONTAINER[/proto], supporting both single ports * and ranges. Accepted formats: @@ -700,6 +706,14 @@ static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "gateway_lan_ifname=%s\n", cfg->gateway_lan_ifname); } + if (cfg->net_mode == DS_NET_NAT && cfg->upstream_iface_count > 0) { + fprintf(f, "upstream_interfaces="); + for (int i = 0; i < cfg->upstream_iface_count; i++) + fprintf(f, "%s%s", cfg->upstream_ifaces[i], + (i < cfg->upstream_iface_count - 1) ? "," : ""); + fprintf(f, "\n"); + } + if (cfg->net_mode == DS_NET_NAT && cfg->port_forward_count > 0) { fprintf(f, "port_forwards="); for (int i = 0; i < cfg->port_forward_count; i++) { diff --git a/src/container.c b/src/container.c index 7c0be418..5e2b10e1 100644 --- a/src/container.c +++ b/src/container.c @@ -1566,6 +1566,14 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { cfg->static_nat_ip[0] ? cfg->static_nat_ip : cfg->nat_container_ip; if (ip[0]) printf("NAT_IP=%s\n", ip); + + if (cfg->upstream_iface_count > 0) { + printf("UPSTREAM_INTERFACES="); + for (int i = 0; i < cfg->upstream_iface_count; i++) + printf("%s%s", cfg->upstream_ifaces[i], + (i < cfg->upstream_iface_count - 1) ? "," : ""); + printf("\n"); + } } else if (cfg->net_mode == DS_NET_GATEWAY) { printf("GATEWAY_CONTAINER=%s\n", cfg->gateway_container); printf("GATEWAY_NET=%s\n", @@ -1729,6 +1737,15 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { feat_count++; } + if (cfg->upstream_iface_count > 0) { + printf(" Upstream (pinned): "); + for (int i = 0; i < cfg->upstream_iface_count; i++) + printf("%s%s", cfg->upstream_ifaces[i], + (i < cfg->upstream_iface_count - 1) ? ", " : ""); + printf("\n"); + feat_count++; + } + if (cfg->port_forward_count > 0) { printf(" Port forwards: "); for (int i = 0; i < cfg->port_forward_count; i++) { diff --git a/src/documentation.c b/src/documentation.c index 0c2754b4..9db66e2f 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -292,7 +292,11 @@ static void print_page(int page, const char *bin) { "start\n", bin); printf(" The internet uplink is detected automatically and tracked " - "in real time.\n\n"); + "in real time.\n"); + printf(" Use --upstream IFACE to pin WAN to a specific interface " + "(disables auto-detect),\n"); + printf(" e.g. --upstream wlan0 to route the container over Wi-Fi while " + "the phone uses mobile data.\n\n"); printf("%sPort Forwarding (NAT only):%s\n", bold, reset); printf(" --port=8080:80 Single port\n"); diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 98792b5d..66e75f30 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -305,6 +305,7 @@ struct ds_tty_info { * ---------------------------------------------------------------------------*/ #define DS_MAX_PORT_FORWARDS 32 +#define DS_MAX_UPSTREAM_IFACES 32 struct ds_port_forward { uint16_t host_port; /* port on the Android/Linux host */ @@ -429,6 +430,13 @@ struct ds_config { * instead of re-deriving a PID-hash IP. Plain dotted-decimal, no CIDR. */ char static_nat_ip[INET_ADDRSTRLEN]; + /* User-pinned upstream interfaces (--upstream wlan0,rmnet*,...). When set, + * NAT WAN is forced through this list (priority order, literals + wildcards) + * and automatic uplink detection is disabled entirely. Empty = auto-detect. + */ + char upstream_ifaces[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; + int upstream_iface_count; + /* Resource limits (0 = unlimited) */ long long memory_limit; /* bytes */ long long cpu_quota; /* us per period */ @@ -445,6 +453,8 @@ struct ds_config { * ---------------------------------------------------------------------------*/ void safe_strncpy(char *dst, const char *src, size_t size); +int ds_parse_iface_csv(const char *val, char ifaces[][IFNAMSIZ], int *count, + int max); char *ds_resolve_path_arg(const char *path); void ds_resolve_argv_paths(int argc, char **argv); long ds_get_container_uptime(pid_t pid); diff --git a/src/main.c b/src/main.c index da191abd..e5484659 100644 --- a/src/main.c +++ b/src/main.c @@ -65,6 +65,10 @@ void print_usage(void) { "ds-NAME)\n" " --nat-ip=IP Assign a fixed IP in 172.28.*.* range (nat " "mode)\n" + " --upstream IFACE Pin NAT WAN to interface(s); disables " + "auto-detect\n" + " comma-separated, wildcards ok, e.g. " + "--upstream wlan0 or wlan0,rmnet*\n" " --port [H:]C[/P] Forward ports (supports ranges and " "symmetric ports)\n" " e.g. --port 22, 80:80/tcp, " @@ -314,7 +318,12 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { } } - /* --port is only meaningful with --net=nat */ + /* --upstream and --port are only meaningful with --net=nat. --upstream is an + * optional override (auto-detection is the default) - never mandatory. */ + if (cfg->upstream_iface_count > 0 && cfg->net_mode != DS_NET_NAT) { + ds_warn("--upstream is only valid with --net=nat - ignoring"); + cfg->upstream_iface_count = 0; + } if (cfg->port_forward_count > 0 && cfg->net_mode != DS_NET_NAT) { ds_warn("--port is only valid with --net=nat - ignoring"); cfg->port_forward_count = 0; @@ -395,6 +404,7 @@ int main(int argc, char **argv) { {"user", required_argument, 0, 'u'}, {"net", required_argument, 0, 257}, {"port", required_argument, 0, 258}, + {"upstream", required_argument, 0, 259}, {"force-cgroupv1", no_argument, 0, 260}, {"block-nested-namespaces", no_argument, 0, 261}, {"privileged", required_argument, 0, 264}, @@ -943,6 +953,20 @@ int main(int argc, char **argv) { break; } + case 259: { + /* --upstream wlan0,rmnet* (comma-separated, wildcards allowed). Pins + * NAT WAN to this list in priority order and disables auto-detection. */ + if (ds_parse_iface_csv(optarg, cfg.upstream_ifaces, + &cfg.upstream_iface_count, + DS_MAX_UPSTREAM_IFACES) > 0) { + ds_error("Too many --upstream interfaces (max %d)", + DS_MAX_UPSTREAM_IFACES); + ret = 1; + goto cleanup; + } + break; + } + case 260: /* --force-cgroupv1: escape hatch to legacy hierarchy */ cfg.force_cgroupv1 = 1; diff --git a/src/net/network.c b/src/net/network.c index ee68d3d6..111b894f 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -228,6 +228,14 @@ static volatile sig_atomic_t g_stop_monitor = 0; static pthread_t g_route_monitor_tid; static int g_route_monitor_started = 0; /* guarded by g_gw_mutex */ +/* User-pinned upstream interfaces (--upstream). When non-empty, the uplink is + * resolved ONLY from this list (priority order, literals + wildcards) and all + * automatic detection is disabled - an explicit manual override. Copied from + * cfg in setup_veth_host_side(), before routing setup and the monitor read it. + */ +static char g_upstream_ifaces[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; +static int g_upstream_count = 0; + /* Returns 1 if ifname exists and is both UP and RUNNING. * On Android, the active data interface has IFF_RUNNING set; an interface * that is physically present but not carrying data loses IFF_RUNNING. */ @@ -580,11 +588,11 @@ static void ds_net_setup_android_routing(ds_nl_ctx_t *ctx) { ds_warn("[NET] Android routing: failed to add tether-return rule (%d)", DS_RULE_PRIO_TETHER); - if (!active_iface[0]) { - ds_warn("[NET] Android routing: no active uplink detected yet - " - "route monitor will install rule when one comes up"); + /* No uplink yet: find_active_uplink() already logged the single "no WAN" + * line. The TO_SUBNET/tether rules above are in place; the route monitor + * installs the FROM rule once an uplink appears. */ + if (!active_iface[0]) return; - } ds_log("[NET] Android routing: active uplink %s → table %d", active_iface, gw_table); @@ -800,7 +808,17 @@ int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid) { } ds_log("[DEBUG] Successfully moved %s to PID %d", veth_peer, (int)child_pid); - /* 7. Android policy routing - uplink is detected automatically */ + /* Cache the user-pinned upstream list (if any) into the globals that the + * routing setup and the route monitor read. Empty list = auto-detect. This + * runs before ds_net_setup_android_routing() and + * ds_net_start_route_monitor(), which both consult g_upstream_*. The count + * is already capped at DS_MAX_UPSTREAM_IFACES by the parsers. */ + g_upstream_count = cfg->upstream_iface_count; + for (int _i = 0; _i < g_upstream_count; _i++) + safe_strncpy(g_upstream_ifaces[_i], cfg->upstream_ifaces[_i], IFNAMSIZ); + + /* 7. Android policy routing - uplink is auto-detected, or pinned via + * --upstream. */ if (is_android()) ds_net_setup_android_routing(ctx); @@ -1473,6 +1491,24 @@ static void log_uplink_change(const char *ifname, int table, g_uplink_fail_warned = 0; /* reset so next failure logs once */ } +/* Single "no uplink" outcome for both modes: warn once (until the next success) + * and clear the last-seen cache. This is the one place the condition is + * reported - callers must not log it again. */ +static void log_no_uplink(void) { + if (!g_uplink_fail_warned) { + if (g_upstream_count > 0) + ds_warn("[NET] Uplink: no pinned --upstream interface is up - container " + "has no WAN; the route monitor will " + "wire it when one appears"); + else + ds_warn( + "[NET] Uplink: no active internet interface found - container has " + "no WAN; the route monitor will wire it when one appears"); + g_uplink_fail_warned = 1; + } + g_last_uplink_iface[0] = '\0'; +} + /* Tier 3 (last resort): scan all interfaces against the built-in uplink * whitelist in priority order; return the first that is RUNNING and has * an IPv4 default route in some table. */ @@ -1501,8 +1537,60 @@ static int scan_uplink_whitelist(ds_nl_ctx_t *ctx, char *iface_out, return -ENOENT; } +/* Manual override: resolve the user-pinned --upstream list in priority order. + * The first entry that is RUNNING and has an IPv4 default route wins. Literal + * entries are checked directly; wildcard entries (containing * or ?) are + * matched with fnmatch() against the live interface list (handles dynamic names + * like rmnet_dataX or v4-rmnet_dataX whose number changes across reconnects). + * No exclude heuristics apply here - the user picked the interface + * deliberately. Returns 0 + fills iface/table, or -ENOENT when none are + * currently available. */ +static int resolve_pinned_uplink(ds_nl_ctx_t *ctx, char *iface_out, + int *table_out) { + char all_ifaces[64][IFNAMSIZ]; + int all_count = -1; /* enumerated lazily, only if a wildcard needs it */ + + for (int i = 0; i < g_upstream_count; i++) { + const char *pat = g_upstream_ifaces[i]; + int is_wild = (strchr(pat, '*') != NULL || strchr(pat, '?') != NULL); + + if (!is_wild) { + int tbl = 0; + if (iface_is_running(pat) && ds_nl_get_iface_table(ctx, pat, &tbl) == 0) { + if (iface_out) + safe_strncpy(iface_out, pat, IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + continue; + } + + if (all_count < 0) + all_count = ds_nl_list_ifaces(ctx, all_ifaces, 64); + for (int j = 0; j < all_count; j++) { + if (fnmatch(pat, all_ifaces[j], 0) != 0) + continue; + int tbl = 0; + if (!iface_is_running(all_ifaces[j]) || + ds_nl_get_iface_table(ctx, all_ifaces[j], &tbl) != 0) + continue; + if (iface_out) + safe_strncpy(iface_out, all_ifaces[j], IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + } + return -ENOENT; +} + /* Find the interface/table currently providing internet access. - * Fully automatic, three tiers, first hit wins: + * + * If --upstream is set this is a pure manual override: resolve ONLY from that + * list (resolve_pinned_uplink) and disable all auto-detection - the WAN never + * hops to whatever netd marks active. Otherwise it is fully automatic, three + * tiers, first hit wins: * * 1. Android netd default-network FIB rule (fwmark 0x0/0xffff iif lo). * The kernel's ground truth - swapped atomically on every handoff, @@ -1520,6 +1608,21 @@ static int find_active_uplink(ds_nl_ctx_t *ctx, char *iface_out, char name[IFNAMSIZ] = {0}; int tbl = 0; + /* Manual override: pinned --upstream list only, no fallback to auto-detect. + */ + if (g_upstream_count > 0) { + if (resolve_pinned_uplink(ctx, name, &tbl) == 0) { + log_uplink_change(name, tbl, "pinned"); + if (iface_out) + safe_strncpy(iface_out, name, IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + log_no_uplink(); + return -ENOENT; + } + /* Tier 1: netd default-network rule (Android only - the rule simply * does not exist elsewhere, but skip the dump cost off-Android). */ if (is_android() && ds_nl_get_android_default(ctx, name, &tbl) == 0 && @@ -1556,12 +1659,7 @@ static int find_active_uplink(ds_nl_ctx_t *ctx, char *iface_out, return 0; } - if (!g_uplink_fail_warned) { - ds_warn("[NET] Uplink detection: no active internet interface found - " - "will keep probing"); - g_uplink_fail_warned = 1; - } - g_last_uplink_iface[0] = '\0'; + log_no_uplink(); return -ENOENT; } @@ -1575,7 +1673,30 @@ static void do_uplink_reprobe(void) { int new_table = 0; if (find_active_uplink(ctx, new_iface, &new_table) != 0) { - /* No uplink active yet - leave current rule in place */ + /* No uplink available. */ + if (g_upstream_count > 0) { + /* Pinned mode: the forced interface is gone. Tear the FROM rule down so + * container traffic is NOT silently re-routed onto a table the kernel may + * recycle for a different network (no hopping / no leak). When the + * pinned interface returns, a later reprobe reinstalls the rule. */ + pthread_mutex_lock(&g_gw_mutex); + int old_table = g_current_gw_table; + pthread_mutex_unlock(&g_gw_mutex); + if (old_table > 0) { + uint32_t subnet_be, mask_be; + parse_cidr(DS_DEFAULT_SUBNET, &subnet_be, &mask_be); + (void)mask_be; + ds_nl_del_rule4(ctx, subnet_be, DS_NAT_PREFIX, 0, 0, old_table, + DS_RULE_PRIO_FROM_SUBNET); + pthread_mutex_lock(&g_gw_mutex); + g_current_gw_table = 0; + pthread_mutex_unlock(&g_gw_mutex); + ds_log("[NET] Route monitor: pinned uplink gone - removed FROM rule " + "(container WAN is down until it returns)"); + } + } + /* Auto mode leaves the current rule in place (avoid flapping on + * transients); pinned mode has already torn it down above. */ ds_nl_close(ctx); return; } diff --git a/src/utils.c b/src/utils.c index 75e73c1b..dfe77e80 100644 --- a/src/utils.c +++ b/src/utils.c @@ -30,6 +30,37 @@ void safe_strncpy(char *dst, const char *src, size_t size) { snprintf(dst, size, "%s", src); } +/* Append comma-separated interface names/wildcards from `val` to ifaces[], + * trimming surrounding whitespace and skipping empty or over-long (>= IFNAMSIZ) + * tokens. Duplicates are kept - a pinned list is priority-ordered and + * first-match-wins, so a repeat is a harmless re-check. *count is advanced up + * to `max`; the return value is the number of tokens dropped because the array + * was already full (0 = all fit), letting the caller warn or fail as it likes. + */ +int ds_parse_iface_csv(const char *val, char ifaces[][IFNAMSIZ], int *count, + int max) { + char copy[1024]; + safe_strncpy(copy, val, sizeof(copy)); + int dropped = 0; + char *sp; + for (char *tok = strtok_r(copy, ",", &sp); tok; + tok = strtok_r(NULL, ",", &sp)) { + while (*tok == ' ' || *tok == '\t') + tok++; + char *end = tok + strlen(tok) - 1; + while (end > tok && (*end == ' ' || *end == '\t')) + *end-- = '\0'; + if (!tok[0] || strlen(tok) >= IFNAMSIZ) + continue; + if (*count >= max) { + dropped++; + continue; + } + safe_strncpy(ifaces[(*count)++], tok, IFNAMSIZ); + } + return dropped; +} + /* Mirrors ContainerManager.sanitizeContainerName() in the Android app. * Replaces spaces with dashes so directory names are consistent. */ void sanitize_container_name(const char *name, char *out, size_t size) { From 9ac6ed3d1a6bb31b9925eb852d0ce2a7083a99b0 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 11:06:09 +0530 Subject: [PATCH 076/211] net: re-assert ip_forward on every platform, not just Android Signed-off-by: ravindu644 --- src/net/network.c | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/net/network.c b/src/net/network.c index 111b894f..cfbbc50a 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -1779,16 +1779,18 @@ static void *route_monitor_loop(void *arg) { struct pollfd pfd = {.fd = sock, .events = POLLIN}; while (!g_stop_monitor) { - /* Enforce IPv4 forwarding in real-time. Since Android kernels do not - * broadcast POLLERR/inotify events for /proc/sys/ memory variables, - * we must check it periodically. Reading a 1-byte procfs memory flag - * takes < 1 microsecond, costing 0% CPU. */ - if (is_android() && g_current_gw_table > 0) { + /* Enforce IPv4 forwarding in real-time. If ip_forward ever flips to 0 + * the NAT'd container loses all WAN traffic, so re-assert it on every + * cycle regardless of platform - Android's netd is the usual culprit, + * but a desktop firewall/sysctl reload or another tool can clear it too. + * The kernel does not broadcast POLLERR/inotify events for /proc/sys/ + * memory variables, so we must poll; reading a 1-byte procfs flag takes + * < 1 microsecond, costing 0% CPU. */ + if (g_current_gw_table > 0) { char val[4] = {0}; if (read_file("/proc/sys/net/ipv4/ip_forward", val, sizeof(val)) > 0 && val[0] == '0') { - ds_log("[NET] Route monitor: ip_forward was disabled by Android, " - "re-enabling..."); + ds_log("[NET] Route monitor: ip_forward was disabled - re-enabling..."); write_file("/proc/sys/net/ipv4/ip_forward", "1\n"); } } From 371ffd89d05d55eb965a4451e9736a66a44e86e9 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 11:17:27 +0530 Subject: [PATCH 077/211] app: block back gesture on backend install screen, force Continue button Signed-off-by: ravindu644 --- .../app/ui/screen/InstallationScreen.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt index 4790a02d..8aa66ac8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt @@ -1,6 +1,7 @@ package com.droidspaces.app.ui.screen import androidx.compose.ui.graphics.Color +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.* import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -8,6 +9,7 @@ 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.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -49,6 +51,15 @@ fun InstallationScreen( var isInstallingModule by remember { mutableStateOf(false) } var rebootRecommended by remember { mutableStateOf(false) } + // Completely block the back gesture in every state. This screen must be + // left only via the Continue button, whose handler decides the next + // destination and triggers the post-install refresh. A raw back-stack pop + // would skip that and strand the user on a stale screen (e.g. the + // "update available" card still showing after the update finished). + BackHandler(enabled = true) { + // Intentionally no-op while installing, on success and on error. + } + // Check backend status and determine what to install LaunchedEffect(Unit) { if (!isInstalling && !isSuccess) { @@ -156,7 +167,9 @@ fun InstallationScreen( Scaffold( containerColor = Color.Transparent, bottomBar = { - if (isSuccess) { + // Show the Continue button once the work is finished, whether it + // succeeded or failed - it is the only accepted way off this screen. + if (isSuccess || errorMessage != null) { val btnShape = RoundedCornerShape(20.dp) Surface( modifier = Modifier.fillMaxWidth(), @@ -186,7 +199,7 @@ fun InstallationScreen( Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon( - imageVector = Icons.Default.Check, + imageVector = if (isSuccess) Icons.Default.Check else Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onPrimary From f30f46749478678d8b8dd17e16c86cb6ff523c95 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 12:47:36 +0530 Subject: [PATCH 078/211] net: dedupe NAT/gateway helpers (iptables ACCEPT, MAC, link up/down) Signed-off-by: ravindu644 --- src/net/iptables.c | 298 ++++++++++++++++++--------------------------- src/net/netlink.c | 27 ++-- src/net/network.c | 53 ++++---- 3 files changed, 148 insertions(+), 230 deletions(-) diff --git a/src/net/iptables.c b/src/net/iptables.c index 4736d5d2..da4e9c7d 100644 --- a/src/net/iptables.c +++ b/src/net/iptables.c @@ -803,6 +803,85 @@ int ds_ipt_ensure_masquerade(const char *src_cidr) { return run_command_quiet(argv); } +/* --------------------------------------------------------------------------- + * Internal: insert_iface_accept + * + * Insert "-I 1 [-i|-o] -j ACCEPT" into the filter table, + * idempotently, via the raw socket API with a per-rule binary fallback. + * is_out selects the egress (outiface) match; otherwise it matches ingress + * (iniface). Returns 0 when the rule is present (inserted, already there, or + * added via the binary fallback), or a negative errno when the table itself + * could not be read (the caller decides whether that warrants a full binary + * fallback). + * ---------------------------------------------------------------------------*/ + +static int insert_iface_accept(int fd, unsigned int hook, const char *chain, + const char *iface, int is_out) { + struct ipt_getinfo info; + unsigned char *base = NULL; + int ret = get_table(fd, "filter", &info, &base); + if (ret < 0) + return ret; + + const char *want_in = is_out ? NULL : iface; + const char *want_out = is_out ? iface : NULL; + + if (rule_exists_in_hook(&info, ENTRIES_BLOB(base), hook, want_in, want_out, 0, + 0, "ACCEPT")) { + ds_log("[IPT] %s -%c %s ACCEPT already present", chain, is_out ? 'o' : 'i', + iface); + free(base); + return 0; + } + + unsigned char rule_buf[XT_ALIGN(sizeof(struct ipt_entry)) + + XT_ALIGN(sizeof(struct xt_standard_target))]; + memset(rule_buf, 0, sizeof(rule_buf)); + + struct ipt_entry *re = (struct ipt_entry *)rule_buf; + struct xt_standard_target *st = + (struct xt_standard_target *)(rule_buf + + XT_ALIGN(sizeof(struct ipt_entry))); + + if (is_out) { + safe_strncpy(re->ip.outiface, iface, IFNAMSIZ); + memset(re->ip.outiface_mask, 0xff, strlen(iface) + 1); + } else { + safe_strncpy(re->ip.iniface, iface, IFNAMSIZ); + memset(re->ip.iniface_mask, 0xff, strlen(iface) + 1); + } + re->target_offset = (__u16)XT_ALIGN(sizeof(struct ipt_entry)); + re->next_offset = (__u16)sizeof(rule_buf); + + st->target.u.target_size = (__u16)XT_ALIGN(sizeof(struct xt_standard_target)); + st->target.u.user.name[0] = '\0'; + st->target.u.user.revision = 0; + st->verdict = -NF_ACCEPT - 1; + + ret = insert_rule_at_hook(fd, "filter", &info, ENTRIES_BLOB(base), hook, + rule_buf, sizeof(rule_buf)); + free(base); + + if (ret == 0) { + ds_log("[IPT] %s -%c %s ACCEPT inserted", chain, is_out ? 'o' : 'i', iface); + return 0; + } + + ds_log("[IPT] Raw insert %s -%c %s failed (ret=%d), using binary", chain, + is_out ? 'o' : 'i', iface, ret); + char *a[] = {"iptables", + "-I", + (char *)(uintptr_t)chain, + "1", + is_out ? "-o" : "-i", + (char *)(uintptr_t)iface, + "-j", + "ACCEPT", + NULL}; + run_command_quiet(a); + return 0; +} + /* --------------------------------------------------------------------------- * Public API: ds_ipt_ensure_forward_accept * @@ -820,119 +899,25 @@ int ds_ipt_ensure_forward_accept(const char *iface) { if (!should_use_raw_api()) goto binary_fallback_fwd; - int fd = open_raw_socket(); - if (fd < 0) - goto binary_fallback_fwd; - - /* -i iface */ { - struct ipt_getinfo info; - unsigned char *base = NULL; - int ret = get_table(fd, "filter", &info, &base); - if (ret < 0) { - close(fd); - if (ret == -ENOENT || ret == -ENOPROTOOPT || ret == -EACCES || - ret == -EOPNOTSUPP) - goto binary_fallback_fwd; - return ret; - } + int fd = open_raw_socket(); + if (fd < 0) + goto binary_fallback_fwd; - if (!rule_exists_in_hook(&info, ENTRIES_BLOB(base), NF_INET_FORWARD, iface, - NULL, 0, 0, "ACCEPT")) { - unsigned char rule_buf[XT_ALIGN(sizeof(struct ipt_entry)) + - XT_ALIGN(sizeof(struct xt_standard_target))]; - memset(rule_buf, 0, sizeof(rule_buf)); - - struct ipt_entry *re = (struct ipt_entry *)rule_buf; - struct xt_standard_target *st = - (struct xt_standard_target *)(rule_buf + - XT_ALIGN(sizeof(struct ipt_entry))); - - safe_strncpy(re->ip.iniface, iface, IFNAMSIZ); - memset(re->ip.iniface_mask, 0xff, strlen(iface) + 1); - re->target_offset = (__u16)XT_ALIGN(sizeof(struct ipt_entry)); - re->next_offset = (__u16)sizeof(rule_buf); - - st->target.u.target_size = - (__u16)XT_ALIGN(sizeof(struct xt_standard_target)); - st->target.u.user.name[0] = '\0'; - st->target.u.user.revision = 0; - st->verdict = -NF_ACCEPT - 1; - - ret = insert_rule_at_hook(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_FORWARD, rule_buf, sizeof(rule_buf)); - if (ret == 0) { - ds_log("[IPT] FORWARD -i %s ACCEPT inserted", iface); - } else { - ds_log("[IPT] Raw insert -i %s failed (ret=%d), using binary", iface, - ret); - char *a[] = {"iptables", "-I", "FORWARD", - "1", "-i", (char *)(uintptr_t)iface, - "-j", "ACCEPT", NULL}; - run_command_quiet(a); - } - } else { - ds_log("[IPT] FORWARD -i %s ACCEPT already present", iface); - } - free(base); - } + int ret = insert_iface_accept(fd, NF_INET_FORWARD, "FORWARD", iface, 0); + if (ret == 0) + ret = insert_iface_accept(fd, NF_INET_FORWARD, "FORWARD", iface, 1); + close(fd); - /* Re-read table for the second insert */ - { - struct ipt_getinfo info; - unsigned char *base = NULL; - int ret = get_table(fd, "filter", &info, &base); if (ret < 0) { - close(fd); if (ret == -ENOENT || ret == -ENOPROTOOPT || ret == -EACCES || ret == -EOPNOTSUPP) goto binary_fallback_fwd; return ret; } - - if (!rule_exists_in_hook(&info, ENTRIES_BLOB(base), NF_INET_FORWARD, NULL, - iface, 0, 0, "ACCEPT")) { - unsigned char rule_buf[XT_ALIGN(sizeof(struct ipt_entry)) + - XT_ALIGN(sizeof(struct xt_standard_target))]; - memset(rule_buf, 0, sizeof(rule_buf)); - - struct ipt_entry *re = (struct ipt_entry *)rule_buf; - struct xt_standard_target *st = - (struct xt_standard_target *)(rule_buf + - XT_ALIGN(sizeof(struct ipt_entry))); - - safe_strncpy(re->ip.outiface, iface, IFNAMSIZ); - memset(re->ip.outiface_mask, 0xff, strlen(iface) + 1); - re->target_offset = (__u16)XT_ALIGN(sizeof(struct ipt_entry)); - re->next_offset = (__u16)sizeof(rule_buf); - - st->target.u.target_size = - (__u16)XT_ALIGN(sizeof(struct xt_standard_target)); - st->target.u.user.name[0] = '\0'; - st->target.u.user.revision = 0; - st->verdict = -NF_ACCEPT - 1; - - ret = insert_rule_at_hook(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_FORWARD, rule_buf, sizeof(rule_buf)); - if (ret == 0) { - ds_log("[IPT] FORWARD -o %s ACCEPT inserted", iface); - } else { - ds_log("[IPT] Raw insert -o %s failed (ret=%d), using binary", iface, - ret); - char *a[] = {"iptables", "-I", "FORWARD", - "1", "-o", (char *)(uintptr_t)iface, - "-j", "ACCEPT", NULL}; - run_command_quiet(a); - } - } else { - ds_log("[IPT] FORWARD -o %s ACCEPT already present", iface); - } - free(base); + return 0; } - close(fd); - return 0; - binary_fallback_fwd: ds_log("[IPT] FORWARD ACCEPT binary fallback for iface=%s", iface); { @@ -961,62 +946,23 @@ int ds_ipt_ensure_input_accept(const char *iface) { if (!should_use_raw_api()) goto binary_fallback_inp; - int fd = open_raw_socket(); - if (fd < 0) - goto binary_fallback_inp; - - struct ipt_getinfo info; - unsigned char *base = NULL; - int ret = get_table(fd, "filter", &info, &base); - if (ret < 0) { - close(fd); - if (ret == -ENOENT || ret == -ENOPROTOOPT || ret == -EACCES || - ret == -EOPNOTSUPP) + { + int fd = open_raw_socket(); + if (fd < 0) goto binary_fallback_inp; - return ret; - } - - if (!rule_exists_in_hook(&info, ENTRIES_BLOB(base), NF_INET_LOCAL_IN, iface, - NULL, 0, 0, "ACCEPT")) { - unsigned char rule_buf[XT_ALIGN(sizeof(struct ipt_entry)) + - XT_ALIGN(sizeof(struct xt_standard_target))]; - memset(rule_buf, 0, sizeof(rule_buf)); - - struct ipt_entry *re = (struct ipt_entry *)rule_buf; - struct xt_standard_target *st = - (struct xt_standard_target *)(rule_buf + - XT_ALIGN(sizeof(struct ipt_entry))); - safe_strncpy(re->ip.iniface, iface, IFNAMSIZ); - memset(re->ip.iniface_mask, 0xff, strlen(iface) + 1); - re->target_offset = (__u16)XT_ALIGN(sizeof(struct ipt_entry)); - re->next_offset = (__u16)sizeof(rule_buf); - - st->target.u.target_size = - (__u16)XT_ALIGN(sizeof(struct xt_standard_target)); - st->target.u.user.name[0] = '\0'; - st->target.u.user.revision = 0; - st->verdict = -NF_ACCEPT - 1; + int ret = insert_iface_accept(fd, NF_INET_LOCAL_IN, "INPUT", iface, 0); + close(fd); - ret = insert_rule_at_hook(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_LOCAL_IN, rule_buf, sizeof(rule_buf)); - if (ret == 0) { - ds_log("[IPT] INPUT -i %s ACCEPT inserted", iface); - } else { - ds_log("[IPT] Raw insert INPUT failed (ret=%d), binary fallback", ret); - char *a[] = {"iptables", "-I", "INPUT", - "1", "-i", (char *)(uintptr_t)iface, - "-j", "ACCEPT", NULL}; - run_command_quiet(a); + if (ret < 0) { + if (ret == -ENOENT || ret == -ENOPROTOOPT || ret == -EACCES || + ret == -EOPNOTSUPP) + goto binary_fallback_inp; + return ret; } - } else { - ds_log("[IPT] INPUT -i %s ACCEPT already present", iface); + return 0; } - free(base); - close(fd); - return 0; - binary_fallback_inp: { char *a[] = {"iptables", "-I", "INPUT", "1", "-i", (char *)(uintptr_t)iface, @@ -1328,6 +1274,25 @@ static int pf_state_remove(const char *container_ip) { return 1; } +/* Format the three iptables strings for one port-forward entry. Range entries + * (host_port_end set) use START:END for --dport and START-END for + * --to-destination; single ports use the plain number. */ +static void pf_fmt_ports(const struct ds_port_forward *pf, + const char *container_ip, char host_port_str[16], + char cont_port_str[16], char to_dest[80]) { + if (pf->host_port_end) { + snprintf(host_port_str, 16, "%u:%u", pf->host_port, pf->host_port_end); + snprintf(cont_port_str, 16, "%u:%u", pf->container_port, + pf->container_port_end); + snprintf(to_dest, 80, "%s:%u-%u", container_ip, pf->container_port, + pf->container_port_end); + } else { + snprintf(host_port_str, 16, "%u", pf->host_port); + snprintf(cont_port_str, 16, "%u", pf->container_port); + snprintf(to_dest, 80, "%s:%u", container_ip, pf->container_port); + } +} + /* --------------------------------------------------------------------------- * Public API: ds_ipt_add_portforwards * @@ -1366,20 +1331,7 @@ int ds_ipt_add_portforwards(struct ds_config *cfg, const char *container_ip) { struct ds_port_forward *pf = &cfg->port_forwards[i]; char host_port_str[16], cont_port_str[16], to_dest[80]; - if (pf->host_port_end) { - /* Range syntax: START:END for --dport, START-END for --to-destination */ - snprintf(host_port_str, sizeof(host_port_str), "%u:%u", pf->host_port, - pf->host_port_end); - snprintf(cont_port_str, sizeof(cont_port_str), "%u:%u", - pf->container_port, pf->container_port_end); - snprintf(to_dest, sizeof(to_dest), "%s:%u-%u", container_ip, - pf->container_port, pf->container_port_end); - } else { - snprintf(host_port_str, sizeof(host_port_str), "%u", pf->host_port); - snprintf(cont_port_str, sizeof(cont_port_str), "%u", pf->container_port); - snprintf(to_dest, sizeof(to_dest), "%s:%u", container_ip, - pf->container_port); - } + pf_fmt_ports(pf, container_ip, host_port_str, cont_port_str, to_dest); ds_log("portforward: %s %s -> %s", pf->proto, host_port_str, to_dest); @@ -1511,19 +1463,7 @@ int ds_ipt_remove_portforwards(struct ds_config *cfg) { struct ds_port_forward *pf = &cfg->port_forwards[i]; char host_port_str[16], cont_port_str[16], to_dest[80]; - if (pf->host_port_end) { - snprintf(host_port_str, sizeof(host_port_str), "%u:%u", pf->host_port, - pf->host_port_end); - snprintf(cont_port_str, sizeof(cont_port_str), "%u:%u", - pf->container_port, pf->container_port_end); - snprintf(to_dest, sizeof(to_dest), "%s:%u-%u", container_ip, - pf->container_port, pf->container_port_end); - } else { - snprintf(host_port_str, sizeof(host_port_str), "%u", pf->host_port); - snprintf(cont_port_str, sizeof(cont_port_str), "%u", pf->container_port); - snprintf(to_dest, sizeof(to_dest), "%s:%u", container_ip, - pf->container_port); - } + pf_fmt_ports(pf, container_ip, host_port_str, cont_port_str, to_dest); /* addrtype variant */ char *del_at[] = { diff --git a/src/net/netlink.c b/src/net/netlink.c index 22caa573..0222ac4a 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -423,7 +423,8 @@ int ds_nl_set_master(ds_nl_ctx_t *ctx, const char *ifname, const char *master) { * Bring link UP / DOWN * ---------------------------------------------------------------------------*/ -int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname) { +/* Set or clear IFF_UP on an interface (shared by link_up / link_down). */ +static int ds_nl_link_set_up(ds_nl_ctx_t *ctx, const char *ifname, int up) { int idx = ds_nl_get_ifindex(ctx, ifname); if (idx <= 0) return -ENODEV; @@ -438,29 +439,17 @@ int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname) { req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; req.i.ifi_family = AF_UNSPEC; req.i.ifi_index = idx; - req.i.ifi_flags = IFF_UP; + req.i.ifi_flags = up ? IFF_UP : 0; req.i.ifi_change = IFF_UP; return ds_nl_talk(ctx, &req.n); } -int ds_nl_link_down(ds_nl_ctx_t *ctx, const char *ifname) { - int idx = ds_nl_get_ifindex(ctx, ifname); - if (idx <= 0) - return -ENODEV; +int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname) { + return ds_nl_link_set_up(ctx, ifname, 1); +} - struct { - struct nlmsghdr n; - struct ifinfomsg i; - } req; - memset(&req, 0, sizeof(req)); - req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); - req.n.nlmsg_type = RTM_NEWLINK; - req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; - req.i.ifi_family = AF_UNSPEC; - req.i.ifi_index = idx; - req.i.ifi_flags = 0; - req.i.ifi_change = IFF_UP; - return ds_nl_talk(ctx, &req.n); +int ds_nl_link_down(ds_nl_ctx_t *ctx, const char *ifname) { + return ds_nl_link_set_up(ctx, ifname, 0); } /* --------------------------------------------------------------------------- diff --git a/src/net/network.c b/src/net/network.c index cfbbc50a..5b975bf2 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -79,34 +79,25 @@ static uint32_t ds_net_hash_string(const char *s) { return h; } -/* Derive a stable, locally-administered unicast MAC from the container name. - * The same name always yields the same address, so a gateway (e.g. OpenWrt) - * sees one persistent host across restarts instead of a fresh random MAC - - * and therefore one DHCP lease / LuCI entry - every boot. */ -static void ds_container_mac(const char *name, uint8_t mac[6]) { - uint32_t h1 = ds_net_hash_string(name); - char salted[300]; - snprintf(salted, sizeof(salted), "ds-mac:%s", name ? name : ""); - uint32_t h2 = ds_net_hash_string(salted); - mac[0] = 0x02; /* locally administered (bit1), unicast (bit0 clear) */ - mac[1] = (uint8_t)(h1 >> 24); - mac[2] = (uint8_t)(h1 >> 16); - mac[3] = (uint8_t)(h1 >> 8); - mac[4] = (uint8_t)(h1); - mac[5] = (uint8_t)(h2); -} - -/* Derive a stable MAC for a gateway LAN-side veth from its segment key - * ("{gateway}:{net}"). The gateway's LAN interface (e.g. eth1 inside OpenWrt) - * then keeps the same MAC every time the cable is (re)plugged - across gateway - * reboots and self-heal re-wiring - so netifd sees one persistent device - * instead of re-initialising a new random-MAC device each time. */ -static void ds_segment_mac(const char *key, uint8_t mac[6]) { +/* Derive a stable, locally-administered unicast MAC from a key string, salted + * with a domain prefix so the two callers never collide on the same key. + * + * "ds-mac:" - the container's own eth0. A gateway (e.g. + * OpenWrt) then sees one persistent host + * across restarts - one DHCP lease / LuCI + * entry - instead of a fresh random MAC. + * "ds-gwmac:" - a gateway LAN-side veth (becomes e.g. eth1 + * inside OpenWrt). Keeps the same MAC across + * every (re)plug so netifd sees one + * persistent device, not a new one each time. + */ +static void ds_derive_mac(const char *key, const char *salt_prefix, + uint8_t mac[6]) { uint32_t h1 = ds_net_hash_string(key); - char salted[400]; - snprintf(salted, sizeof(salted), "ds-gwmac:%s", key ? key : ""); + char salted[512]; + snprintf(salted, sizeof(salted), "%s%s", salt_prefix, key ? key : ""); uint32_t h2 = ds_net_hash_string(salted); - mac[0] = 0x02; /* locally administered, unicast */ + mac[0] = 0x02; /* locally administered (bit1), unicast (bit0 clear) */ mac[1] = (uint8_t)(h1 >> 24); mac[2] = (uint8_t)(h1 >> 16); mac[3] = (uint8_t)(h1 >> 8); @@ -456,9 +447,7 @@ int ds_net_check_ip_collision(const char *ip_str, const char *exclude_name) { * collision. Deterministic on first boot → same row every time the same * container name is used, spreading containers across the /16. */ static void ds_net_auto_assign_ip(struct ds_config *cfg) { - uint32_t hash = 5381; - for (const char *p = cfg->container_name; *p; p++) - hash = ((hash << 5) + hash) ^ (unsigned char)*p; + uint32_t hash = ds_net_hash_string(cfg->container_name); int o3 = (int)((hash >> 8) % 254) + 1; /* 1-254 */ int o4 = (int)(hash % 254) + 1; /* 1-254 */ @@ -1001,7 +990,7 @@ static int gateway_ensure_lan_uplink_locked(struct ds_config *cfg, char key[384]; uint8_t mac[6]; gateway_hash_key(cfg, key, sizeof(key)); - ds_segment_mac(key, mac); + ds_derive_mac(key, "ds-gwmac:", mac); if (ds_nl_set_mac(ctx, gw_peer, mac) < 0) ds_warn("[NET] Gateway: failed to pin MAC on %s", gw_peer); } @@ -1104,7 +1093,7 @@ static int gateway_wire_client(struct ds_config *cfg, pid_t client_pid, * the move, so the gateway's DHCP leases see one host across reboots. */ { uint8_t mac[6]; - ds_container_mac(cfg->container_name, mac); + ds_derive_mac(cfg->container_name, "ds-mac:", mac); if (ds_nl_set_mac(ctx, app_peer, mac) < 0) ds_warn("[NET] Gateway: failed to pin MAC on %s", app_peer); } @@ -1289,7 +1278,7 @@ int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, * churn) instead of a new random MAC each boot. Set while down, pre-up. */ if (cfg && cfg->container_name[0]) { uint8_t mac[6]; - ds_container_mac(cfg->container_name, mac); + ds_derive_mac(cfg->container_name, "ds-mac:", mac); if (ds_nl_set_mac(ctx, "eth0", mac) < 0) ds_warn("[NET] Child: failed to set deterministic MAC on eth0"); else From 2dfdf8945c9591b93b9f335b1d65936d9dd08fbd Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 13:00:09 +0530 Subject: [PATCH 079/211] net: log decoded target name instead of '' for standard verdicts Signed-off-by: ravindu644 --- src/net/iptables.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/net/iptables.c b/src/net/iptables.c index da4e9c7d..f1c87214 100644 --- a/src/net/iptables.c +++ b/src/net/iptables.c @@ -178,6 +178,17 @@ static int target_is_accept(const struct xt_entry_target *t) { return 0; } +/* Human-readable target name for logging. Standard targets (ACCEPT and the + * other built-in verdicts) carry an empty user.name - the verdict is encoded + * numerically - so decode the common ACCEPT case instead of printing ''. */ +static const char *target_label(const struct xt_entry_target *t) { + if (t->u.user.name[0]) + return t->u.user.name; + if (target_is_accept(t)) + return "ACCEPT"; + return "standard"; +} + /* --------------------------------------------------------------------------- * Internal: walk the blob to find an existing rule matching our fingerprint. * @@ -543,7 +554,8 @@ static int remove_matching_rules(int fd, const char *table_name, ei++; continue; } - ds_log("[IPT] remove: dropping '%s' rule at offset %u", tname, offset); + ds_log("[IPT] remove: dropping '%s' rule at offset %u", target_label(t), + offset); cumulative_gone += e->next_offset; removed_count++; } else { From e876f99e6b5ddb3c46ccbfcb10ae678e34520d80 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 13:28:36 +0530 Subject: [PATCH 080/211] net: fix DHCP source MAC for bridgeless NAT; harden gateway on no-bridge kernels Signed-off-by: ravindu644 --- src/net/dhcp.c | 13 ++++++++----- src/net/network.c | 13 +++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/net/dhcp.c b/src/net/dhcp.c index f101b555..ba13ef56 100644 --- a/src/net/dhcp.c +++ b/src/net/dhcp.c @@ -555,21 +555,24 @@ void ds_dhcp_server_start(struct ds_config *cfg, const char *veth_host, g_dhcp.offer_ip_be = offer_ip_be; g_dhcp.gw_ip_be = gw_ip_be; - /* Fetch Bridge MAC */ - /* We need the bridge MAC to spoof the source in DHCP replies. - * If we use 00:00:00... or a random MAC, the container's ARP will break. */ + /* Source MAC for DHCP replies = the MAC of the interface we transmit on + * (veth_host, the bind interface). That device egresses the reply frame, + * and in bridgeless mode it also owns the gateway IP, so the container's ARP + * for the gateway resolves to a real MAC. Looking up the bind interface + * works in both bridged and bridgeless modes - no hardcoded bridge + * dependency. Falling back to all-zeros would break the container's ARP. */ int s = socket(AF_INET, SOCK_DGRAM, 0); if (s >= 0) { struct ifreq ifr; memset(&ifr, 0, sizeof(ifr)); - safe_strncpy(ifr.ifr_name, DS_NAT_BRIDGE, IFNAMSIZ); + safe_strncpy(ifr.ifr_name, veth_host, IFNAMSIZ); if (ioctl(s, SIOCGIFHWADDR, &ifr) == 0) { memcpy(g_dhcp.server_mac, ifr.ifr_hwaddr.sa_data, 6); } else { /* Fallback to all-zeros if SIOCGIFHWADDR fails (unlikely) */ memset(g_dhcp.server_mac, 0, 6); ds_warn("[DHCP] Failed to get MAC for %s: %s. Using all-zeros.", - DS_NAT_BRIDGE, strerror(errno)); + veth_host, strerror(errno)); } close(s); } else { diff --git a/src/net/network.c b/src/net/network.c index 5b975bf2..353c2bbc 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -952,8 +952,17 @@ static int gateway_ensure_lan_uplink_locked(struct ds_config *cfg, * authority on the delegated network. */ if (!ds_nl_link_exists(ctx, bridge)) { ds_log("[NET] Gateway: creating delegated LAN bridge %s", bridge); - if (ds_nl_create_bridge(ctx, bridge) < 0) { - ds_warn("[NET] Gateway: failed to create bridge %s", bridge); + int br = ds_nl_create_bridge(ctx, bridge); + if (br < 0) { + /* Bail before any veth/netns work. This should be unreachable - the + * startup capability probe (enforce_nat_safety) refuses gateway mode on + * a kernel without CONFIG_BRIDGE - but recognise EOPNOTSUPP explicitly + * so we never cascade into further unsupported operations. */ + if (br == -EOPNOTSUPP) + ds_warn("[NET] Gateway: kernel lacks CONFIG_BRIDGE - cannot wire " + "delegated LAN (gateway mode requires bridge support)"); + else + ds_warn("[NET] Gateway: failed to create bridge %s", bridge); ds_nl_close(ctx); return -1; } From 06b1378e14717cefe5cd26bb28caecd28ebe7215 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 14:09:08 +0530 Subject: [PATCH 081/211] app: fix Panel tab polling not resuming after returning from background Signed-off-by: ravindu644 --- .../app/ui/screen/ControlPanelScreen.kt | 24 ++-- .../app/ui/viewmodel/SystemStatsViewModel.kt | 132 +++++++----------- 2 files changed, 68 insertions(+), 88 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt index d308eef4..5ee5b062 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt @@ -13,6 +13,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import com.droidspaces.app.ui.component.EmptyState import com.droidspaces.app.ui.component.ErrorState import com.droidspaces.app.ui.component.RootUnavailableState @@ -46,18 +49,23 @@ fun ControlPanelScreen( // Get running containers - derived from ViewModel state val runningContainers = containerViewModel.containerList.filter { it.isRunning } - // Start system stats monitoring (only once per screen lifetime) - // Synchronized with DisposableEffect to ensure polling stops when navigating away - DisposableEffect(Unit) { - systemStatsViewModel.startMonitoring() - onDispose { - systemStatsViewModel.stopMonitoring() + val lifecycleOwner = LocalLifecycleOwner.current + + // Poll only while this screen is composed (i.e. the Panel tab is selected) + // AND the app is in the foreground (Lifecycle STARTED). repeatOnLifecycle + // cancels the loop on background / tab-away and restarts it on return, so + // re-opening the app on the Panel tab resumes polling without a tab switch. + LaunchedEffect(Unit) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + systemStatsViewModel.monitorSystem() } } - // Start container monitoring loop; restarts if running container list changes + // Container loop restarts whenever the running container set changes. LaunchedEffect(runningContainers) { - systemStatsViewModel.startContainerMonitoring(runningContainers) + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + systemStatsViewModel.monitorContainers(runningContainers) + } } val containerUsageMap = systemStatsViewModel.containerUsageMap diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt index 07730894..a27c5ced 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt @@ -7,39 +7,24 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import com.droidspaces.app.util.AndroidSystemStatsCollector import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerOSInfoManager import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +/** + * Holds host + per-container stats and exposes the polling loops as suspend + * functions. The loops are NOT self-managed here: the screen drives them via + * repeatOnLifecycle(STARTED), so polling runs only while the Panel tab is + * actually on screen AND the app is in the foreground, and is cancelled + * structurally when either stops being true. This avoids the old + * ProcessLifecycleOwner observer, which stopped on background but never + * restarted on return (polling stayed dead until a tab switch re-composed it). + */ class SystemStatsViewModel(application: Application) : AndroidViewModel(application) { - private var isAppInForeground = true - private val lifecycleObserver = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - isAppInForeground = true - } - Lifecycle.Event.ON_STOP -> { - isAppInForeground = false - stopMonitoring() // Shutdown polling when app backgrounded - } - else -> {} - } - } - - init { - // Observe app-level lifecycle to stop polling in background - ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver) - } - companion object { private const val TAG = "SystemStatsViewModel" private const val SYSTEM_INTERVAL_MS = 2000L @@ -53,81 +38,68 @@ class SystemStatsViewModel(application: Application) : AndroidViewModel(applicat var containerUsageMap = mutableStateMapOf() private set - private var systemJob: Job? = null - private var containerJob: Job? = null - - fun startMonitoring() { - if (!isAppInForeground) return - systemJob?.cancel() - systemJob = viewModelScope.launch(Dispatchers.IO) { - while (true) { - try { - systemUsage = AndroidSystemStatsCollector.collectUsage() - } catch (e: Exception) { - Log.e(TAG, "Failed to collect system usage", e) + /** + * Poll host system usage every [SYSTEM_INTERVAL_MS] until the caller's + * coroutine is cancelled (e.g. the screen leaves STARTED). + */ + suspend fun monitorSystem() { + while (true) { + try { + systemUsage = withContext(Dispatchers.IO) { + AndroidSystemStatsCollector.collectUsage() } - delay(SYSTEM_INTERVAL_MS) + } catch (e: Exception) { + Log.e(TAG, "Failed to collect system usage", e) } + delay(SYSTEM_INTERVAL_MS) } } - fun startContainerMonitoring(containers: List) { - if (!isAppInForeground) return - containerJob?.cancel() + /** + * Poll OS info for every running container until cancelled. Returns + * immediately when nothing is running; the caller re-invokes with a fresh + * list whenever the running set changes. + */ + suspend fun monitorContainers(containers: List) { val running = containers.filter { it.isRunning } if (running.isEmpty()) return - containerJob = viewModelScope.launch(Dispatchers.IO) { - while (true) { - running.forEach { container -> - try { - val isAlive = com.droidspaces.app.util.ContainerManager + while (true) { + running.forEach { container -> + try { + val isAlive = withContext(Dispatchers.IO) { + com.droidspaces.app.util.ContainerManager .checkContainerStatus(container.name).first - if (!isAlive) { - // Kill all terminal sessions for this container - val ctx = getApplication() - ctx.startService( - android.content.Intent(ctx, com.droidspaces.app.service.TerminalSessionService::class.java).apply { - action = com.droidspaces.app.service.TerminalSessionService.ACTION_STOP_CONTAINER_SESSIONS - putExtra(com.droidspaces.app.service.TerminalSessionService.EXTRA_CONTAINER_NAME, container.name) - } - ) - containerUsageMap.remove(container.name) - return@forEach - } - val osInfo = ContainerOSInfoManager.getOSInfo( + } + if (!isAlive) { + // Kill all terminal sessions for this container + val ctx = getApplication() + ctx.startService( + android.content.Intent(ctx, com.droidspaces.app.service.TerminalSessionService::class.java).apply { + action = com.droidspaces.app.service.TerminalSessionService.ACTION_STOP_CONTAINER_SESSIONS + putExtra(com.droidspaces.app.service.TerminalSessionService.EXTRA_CONTAINER_NAME, container.name) + } + ) + containerUsageMap.remove(container.name) + return@forEach + } + val osInfo = withContext(Dispatchers.IO) { + ContainerOSInfoManager.getOSInfo( containerName = container.name, useCache = false, appContext = getApplication() ) - containerUsageMap[container.name] = osInfo - } catch (e: Exception) { - Log.e(TAG, "Failed to collect info for ${container.name}", e) } + containerUsageMap[container.name] = osInfo + } catch (e: Exception) { + Log.e(TAG, "Failed to collect info for ${container.name}", e) } - delay(CONTAINER_INTERVAL_MS) } + delay(CONTAINER_INTERVAL_MS) } } - fun stopContainerMonitoring() { - containerJob?.cancel() - containerJob = null - } - fun clearContainerUsage(containerName: String) { containerUsageMap.remove(containerName) } - - fun stopMonitoring() { - systemJob?.cancel() - systemJob = null - stopContainerMonitoring() - } - - override fun onCleared() { - super.onCleared() - ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) - stopMonitoring() - } } From c7c629620acdc0a756187a413c9ef4349c955927 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 14:10:24 +0530 Subject: [PATCH 082/211] app: drop unused host systemUsage polling and its collector Signed-off-by: ravindu644 --- .../app/ui/screen/ControlPanelScreen.kt | 8 +- .../app/ui/viewmodel/SystemStatsViewModel.kt | 29 +---- .../app/util/AndroidSystemStatsCollector.kt | 101 ------------------ 3 files changed, 3 insertions(+), 135 deletions(-) delete mode 100644 Android/app/src/main/java/com/droidspaces/app/util/AndroidSystemStatsCollector.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt index 5ee5b062..38dd32e0 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt @@ -55,13 +55,7 @@ fun ControlPanelScreen( // AND the app is in the foreground (Lifecycle STARTED). repeatOnLifecycle // cancels the loop on background / tab-away and restarts it on return, so // re-opening the app on the Panel tab resumes polling without a tab switch. - LaunchedEffect(Unit) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - systemStatsViewModel.monitorSystem() - } - } - - // Container loop restarts whenever the running container set changes. + // Restarts whenever the running container set changes. LaunchedEffect(runningContainers) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { systemStatsViewModel.monitorContainers(runningContainers) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt index a27c5ced..8ab04d8d 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt @@ -2,12 +2,8 @@ package com.droidspaces.app.ui.viewmodel import android.app.Application import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.AndroidViewModel -import com.droidspaces.app.util.AndroidSystemStatsCollector import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerOSInfoManager import kotlinx.coroutines.Dispatchers @@ -15,8 +11,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext /** - * Holds host + per-container stats and exposes the polling loops as suspend - * functions. The loops are NOT self-managed here: the screen drives them via + * Holds per-container stats and exposes the polling loop as a suspend function. + * The loop is NOT self-managed here: the screen drives it via * repeatOnLifecycle(STARTED), so polling runs only while the Panel tab is * actually on screen AND the app is in the foreground, and is cancelled * structurally when either stops being true. This avoids the old @@ -27,34 +23,13 @@ class SystemStatsViewModel(application: Application) : AndroidViewModel(applicat companion object { private const val TAG = "SystemStatsViewModel" - private const val SYSTEM_INTERVAL_MS = 2000L private const val CONTAINER_INTERVAL_MS = 2000L } - var systemUsage by mutableStateOf(AndroidSystemStatsCollector.SystemUsage()) - private set - // Per-container OS info (containerName -> OSInfo) — single source of truth for all UI var containerUsageMap = mutableStateMapOf() private set - /** - * Poll host system usage every [SYSTEM_INTERVAL_MS] until the caller's - * coroutine is cancelled (e.g. the screen leaves STARTED). - */ - suspend fun monitorSystem() { - while (true) { - try { - systemUsage = withContext(Dispatchers.IO) { - AndroidSystemStatsCollector.collectUsage() - } - } catch (e: Exception) { - Log.e(TAG, "Failed to collect system usage", e) - } - delay(SYSTEM_INTERVAL_MS) - } - } - /** * Poll OS info for every running container until cancelled. Returns * immediately when nothing is running; the caller re-invokes with a fresh diff --git a/Android/app/src/main/java/com/droidspaces/app/util/AndroidSystemStatsCollector.kt b/Android/app/src/main/java/com/droidspaces/app/util/AndroidSystemStatsCollector.kt deleted file mode 100644 index 0acd638c..00000000 --- a/Android/app/src/main/java/com/droidspaces/app/util/AndroidSystemStatsCollector.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.droidspaces.app.util - -import android.util.Log -import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** Collects real-time Android system statistics from /proc on the host. */ -object AndroidSystemStatsCollector { - private const val TAG = "AndroidSystemStatsCollector" - - data class SystemUsage( - val cpuPercent: Double = 0.0, - val ramPercent: Double = 0.0, - val activeRamKb: Long = 0, - val totalRamKb: Long = 0, - val temperature: String = "N/A" - ) - - private var prevCpuTotal = 0L - private var prevCpuIdle = 0L - - suspend fun collectUsage(): SystemUsage = withContext(Dispatchers.IO) { - try { - val (ramPercent, activeRam, totalRam) = getDetailedRamUsage() - val cpuPercent = getCpuUsage() - val temperature = getTemperature() - SystemUsage(cpuPercent, ramPercent, activeRam, totalRam, temperature) - } catch (e: Exception) { - Log.e(TAG, "Failed to collect system usage", e) - SystemUsage() - } - } - - private suspend fun getCpuUsage(): Double = withContext(Dispatchers.IO) { - try { - val result = Shell.cmd("cat /proc/stat | head -1").exec() - if (result.isSuccess && result.out.isNotEmpty()) { - val parts = result.out[0].trim().split("\\s+".toRegex()) - if (parts.size >= 8) { - val user = parts[1].toLongOrNull() ?: 0L - val nice = parts[2].toLongOrNull() ?: 0L - val system = parts[3].toLongOrNull() ?: 0L - val idle = parts[4].toLongOrNull() ?: 0L - val iowait = parts[5].toLongOrNull() ?: 0L - val total = user + nice + system + idle + iowait - if (prevCpuTotal > 0 && total > prevCpuTotal) { - val totalDelta = total - prevCpuTotal - val idleDelta = idle - prevCpuIdle - val percent = ((totalDelta - idleDelta).toDouble() / totalDelta * 100.0) - .coerceIn(0.0, 100.0) - prevCpuTotal = total; prevCpuIdle = idle - return@withContext percent - } - prevCpuTotal = total; prevCpuIdle = idle - } - } - } catch (e: Exception) { Log.e(TAG, "Error getting CPU usage", e) } - 0.0 - } - - private suspend fun getDetailedRamUsage(): Triple = withContext(Dispatchers.IO) { - try { - val result = Shell.cmd("cat /proc/meminfo | grep -E 'MemTotal|MemAvailable'").exec() - if (result.isSuccess && result.out.size >= 2) { - var memTotal = 0L; var memAvailable = 0L - result.out.forEach { line -> - val parts = line.trim().split("\\s+".toRegex()) - if (parts.size >= 2) when { - line.contains("MemTotal") -> memTotal = parts[1].toLongOrNull() ?: 0L - line.contains("MemAvailable") -> memAvailable = parts[1].toLongOrNull() ?: 0L - } - } - if (memTotal > 0) { - val memUsed = memTotal - memAvailable - val percent = (memUsed.toDouble() / memTotal * 100.0).coerceIn(0.0, 100.0) - return@withContext Triple(percent, memUsed, memTotal) - } - } - } catch (e: Exception) { Log.e(TAG, "Error getting RAM usage", e) } - Triple(0.0, 0L, 0L) - } - - private suspend fun getTemperature(): String = withContext(Dispatchers.IO) { - val paths = listOf( - "/sys/class/thermal/thermal_zone0/temp", - "/sys/class/thermal/thermal_zone1/temp", - "/sys/devices/virtual/thermal/thermal_zone0/temp" - ) - for (path in paths) { - try { - val result = Shell.cmd("cat $path 2>/dev/null").exec() - if (result.isSuccess && result.out.isNotEmpty()) { - val mC = result.out[0].trim().toLongOrNull() - if (mC != null && mC > 0) return@withContext String.format("%.1f°C", mC / 1000.0) - } - } catch (_: Exception) {} - } - "N/A" - } -} From e50ebe86040aec0e6209c6ddbb14734d4140f07b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 14:42:06 +0530 Subject: [PATCH 083/211] docs: document gateway mode, automatic uplink monitor, and --upstream pinning Signed-off-by: ravindu644 --- Documentation/Features.md | 39 ++++++++++++++--- Documentation/Linux-CLI.md | 35 +++++++++++++++- Documentation/Networking-From-Zero.md | 60 +++++++++++++++++++++++++++ Documentation/Usage-Android-App.md | 12 +++++- README.md | 2 +- 5 files changed, 140 insertions(+), 8 deletions(-) diff --git a/Documentation/Features.md b/Documentation/Features.md index 1dab0450..c48488e1 100644 --- a/Documentation/Features.md +++ b/Documentation/Features.md @@ -29,14 +29,16 @@ Linux namespaces are a kernel feature that partitions system resources so that e ### Network Namespace Isolation (`--net`) -Droidspaces supports three networking modes that determine whether a network namespace (`CLONE_NEWNET`) is used: +Droidspaces supports four networking modes that determine whether a network namespace (`CLONE_NEWNET`) is used: 1. **Host Mode (`--net=host`) - Default**: Droidspaces deliberately does **not** unshare the network namespace. The container shares the host's network stack. This greatly simplifies setup: containers get internet access immediately without virtual bridges, NAT, or firewall rules. On Android, where networking is already complex (cellular, Wi-Fi, VPN), this avoids a whole category of connectivity issues. -2. **NAT Mode (`--net=nat`)**: The container is placed in a private network namespace. It is connected to the host via a virtual bridge or veth pair, providing **Pure Network Isolation** while maintaining internet access through the host's active internet uplink, which is detected automatically. Compatible with the vast majority of Android devices. +2. **NAT Mode (`--net=nat`)**: The container is placed in a private network namespace. It is connected to the host via a virtual bridge or veth pair, providing **Pure Network Isolation** while maintaining internet access through the host's active internet uplink, which is detected automatically (or pinned manually with `--upstream`, see below). Compatible with the vast majority of Android devices. 3. **None Mode (`--net=none`)**: The container is placed in a private, air-gapped network namespace with only the loopback interface enabled for maximum security. +4. **Gateway Mode (`--net=gateway`)**: The container's LAN is delegated to *another* running container (typically OpenWRT). Droidspaces does only the L2 plumbing (bridge + veth pairs) and lets the gateway container own all policy - DHCP, DNS, firewall, routing, VPN. Ideal for VPN killswitches, segmented LANs, and traffic analysis. See the dedicated [Networking From Zero](Networking-From-Zero.md) guide for the full deep dive. + ### How It Compares to Chroot A `chroot` only changes the apparent root directory for a process. It provides no process isolation, no mount isolation, no hostname isolation, and no IPC isolation. Any process inside a chroot shares the host's PID space, can see and signal other processes, and cannot run an init system like systemd. @@ -249,9 +251,9 @@ Droidspaces validates bind mount targets with two protections: --- -## Network Isolation (3 Modes) +## Network Isolation (4 Modes) -Droidspaces provides three distinct networking modes to balance ease-of-use with advanced isolation. +Droidspaces provides four distinct networking modes to balance ease-of-use with advanced isolation. ### 1. Host Mode (`--net=host`) - Default The container shares the host's network namespace. @@ -272,6 +274,14 @@ The container is placed in a private network namespace (`CLONE_NEWNET`) and conn The container gets a private network namespace with only the loopback (`lo`) interface enabled. - **Use Case**: Maximum security for offline tasks. +### 4. Gateway Mode (`--net=gateway`) +The container is placed on an isolated L2 bridge whose **policy is owned by another running container** (typically OpenWRT) instead of by Droidspaces. Droidspaces does only the plumbing - it creates the bridge and the veth pairs and moves them into place; the gateway container provides DHCP, DNS, firewall, routing and VPN. +- **Required flag**: `--gateway=NAME` names the running container that acts as the router. +- **Segments**: `--gateway-net=NAME` (default `lan`) selects which bridge/segment the client lands on. Multiple clients sharing a `--gateway-net` share a LAN; different `--gateway-net` values are isolated segments through the same gateway. +- **Interface naming**: `--gateway-iface=IFACE` (default `eth1`) controls what the LAN interface is called *inside* the gateway container, so it matches the gateway's own config. +- **Self-healing**: wiring is driven entirely from the host side, so clients are (re)wired automatically when the gateway container starts or reboots - no client restart needed. +- **Use Cases**: VPN killswitch for selected containers, VLAN-style segmented LANs, single-chokepoint traffic analysis, gateway-wide DNS filtering. See [Networking From Zero](Networking-From-Zero.md) for the complete walkthrough. + ### Port Forwarding (NAT Mode) In NAT mode, you can expose container services to the host or local network using the `--port` flag. Supported formats: @@ -294,7 +304,26 @@ Forwarded ports are reachable from any network the host belongs to - including c ### Real-Time Uplink Monitoring -On Android, the connection often hops between Wi-Fi and Mobile Data. Droidspaces includes a **Route Monitor** that subscribes to kernel routing events (FIB rules, routes, links, addresses). The moment Android switches its default network (e.g., you walk out of Wi-Fi range), the monitor updates the kernel's policy routing to keep the container connected - no configuration, no restart. +On Android, the connection often hops between Wi-Fi and Mobile Data. Droidspaces includes a **Route Monitor** that subscribes to kernel routing events (FIB rules, routes, links, addresses). The moment Android switches its default network (e.g., you walk out of Wi-Fi range), the monitor updates the kernel's policy routing to keep the container connected - no configuration, no restart. The same monitor works on desktop Linux (e.g. a Wi-Fi to ethernet handoff), where it follows the main routing table's default route. + +### Manual Uplink Pinning (`--upstream`) +By default the uplink is fully automatic. When you want the container's WAN to **ignore the host's active network** and go out through a specific interface instead, pin it with `--upstream`. This switches auto-detection off entirely - the listed interface(s) become the *only* WAN candidates. + +```bash +# Single interface +--upstream=wlan0 + +# Priority-ordered list with wildcards (comma-separated) +--upstream=wlan0,rmnet* +``` + +- **Authoritative, not a fallback**: traffic never hops to whatever `netd` marks active - only to interfaces you listed. +- **Priority failover *within* the list**: the Route Monitor re-resolves on every link/route change and uses the first listed interface that is up and has internet. `wlan0,rmnet*` prefers Wi-Fi and falls back to mobile data, then back to Wi-Fi when it returns. +- **Literals and wildcards** (`*`, `?`): use `rmnet*` for mobile data, whose interface number is not stable across reconnects. +- **Disappear/reappear** mid-session is handled: no WAN until a pinned interface is up, then it wires automatically. +- **Use cases**: pin `tun0` to route the container exclusively through a phone-side VPN (a free killswitch), or pin `rmnet*` (with "Mobile data always active") to keep the container on cellular while the phone stays on Wi-Fi. + +> `--upstream` is only valid with `--net=nat`; it is ignored (with a warning) in other modes. --- diff --git a/Documentation/Linux-CLI.md b/Documentation/Linux-CLI.md index 79546692..20c3ef4d 100644 --- a/Documentation/Linux-CLI.md +++ b/Documentation/Linux-CLI.md @@ -110,11 +110,23 @@ sudo droidspaces --name=web,db,app stop | Option | Short | Description | |--------|-------|-------------| -| `--net=MODE` | | Networking mode: `host` (default), `nat`, or `none`. | +| `--net=MODE` | | Networking mode: `host` (default), `nat`, `none`, or `gateway`. | +| `--upstream=IFACE` | | Pin the NAT WAN to specific interface(s); disables automatic uplink detection. Comma-separated, priority-ordered, supports wildcards. Example: `--upstream=wlan0,rmnet*`. NAT mode only. | | `--port HOST:CONT[/proto]` | | Forward host port to container (NAT mode). Supports TCP/UDP. | | `--dns=SERVERS` | `-d` | Custom DNS servers, comma-separated. Example: `--dns=1.1.1.1,8.8.8.8` | | `--disable-ipv6` | | Disable IPv6 networking support (Host mode only). | +#### Gateway Mode + +Delegate a container's LAN to another running container (e.g. OpenWRT), which then owns DHCP, DNS, firewall and routing. Droidspaces only does the L2 plumbing. See [Networking From Zero](Networking-From-Zero.md) for the full guide. + +| Option | Short | Description | +|--------|-------|-------------| +| `--gateway=NAME` | | **Required** for `--net=gateway`. The running container that acts as the router. | +| `--gateway-net=NAME` | | LAN segment name / host bridge suffix (default: `lan`). Clients sharing a value share a LAN; different values are isolated segments. | +| `--gateway-iface=IFACE` | | Interface name as seen *inside* the gateway container (default: `eth1`). Each segment needs a unique name. | +| `--gateway-bridge=BR` | | Override the host bridge name (default: `ds-{gateway-net}`). | + ### Feature Flags | Option | Short | Description | @@ -240,6 +252,27 @@ sudo droidspaces \ start ``` +### NAT with a Pinned Uplink +Force the container's internet out through a specific interface instead of following the host's active network. Pin a VPN tunnel (`tun0`) as a killswitch, or `rmnet*` to stay on mobile data while the phone uses Wi-Fi: +```bash +sudo droidspaces \ + --name=vpnbox \ + --rootfs-img=/path/to/rootfs.img \ + --net=nat \ + --upstream=tun0 \ + start +``` + +### Gateway Mode (LAN owned by OpenWRT) +Start the router container first (in NAT mode), then attach clients to it: +```bash +# 1. the router +sudo droidspaces --name=openwrt --rootfs=/data/openwrt --net=nat start + +# 2. a client whose LAN/DHCP/firewall is owned by openwrt +sudo droidspaces --name=kali --rootfs=/data/kali --net=gateway --gateway=openwrt start +``` + ### Ephemeral Testing ```bash sudo droidspaces --name=test --rootfs=/path/to/rootfs --volatile start diff --git a/Documentation/Networking-From-Zero.md b/Documentation/Networking-From-Zero.md index 9e005ae3..05a6e2e6 100644 --- a/Documentation/Networking-From-Zero.md +++ b/Documentation/Networking-From-Zero.md @@ -23,6 +23,9 @@ keywords: droidspaces, networking, gateway, openwrt, nat, dhcp, dns, lan, wan, v - [Part 4: DNS (How Names Become Addresses)](#part-4-dns-how-names-become-addresses) - [Part 5: NAT (The Magic Your Router Does)](#part-5-nat-the-magic-your-router-does) - [NAT in Droidspaces](#nat-in-droidspaces) + - [How NAT mode picks the WAN uplink (automatic)](#how-nat-mode-picks-the-wan-uplink-automatic) + - [Pinning the uplink manually with --upstream](#pinning-the-uplink-manually-with---upstream) + - [Use cases for --upstream](#use-cases-for---upstream) - [Part 6: Bridges and Virtual Cables (Linux Plumbing)](#part-6-bridges-and-virtual-cables-linux-plumbing) - [What is a network bridge?](#what-is-a-network-bridge) - [What is a veth pair?](#what-is-a-veth-pair) @@ -240,6 +243,63 @@ In Droidspaces NAT mode, Droidspaces acts *exactly like your home router* - but - Droidspaces also runs an embedded DHCP server for the container and configures its DNS - On Android, a background route monitor detects the active internet uplink automatically (by reading the kernel's routing rules) and re-points container traffic the moment the active network changes (for example, Wi-Fi to mobile data handoff) +### How NAT mode picks the WAN uplink (automatic) + +A NAT container has to know *which* of Android's real interfaces currently has internet, so it can MASQUERADE through it. Phones make this hard: the active network hops between Wi-Fi, mobile data, USB-ethernet and VPN tunnels, and the interface names themselves are unstable (the mobile-data interface might be `rmnet0` one minute and `rmnet8` after a reconnect). + +By default Droidspaces handles all of this for you - **there is nothing to configure**: + +- It reads the kernel's *own* ground truth for "what is the internet interface right now." On Android that is the policy-routing rule `netd` installs for the active default network; on desktop Linux it is the main routing table's default route. +- A background **route monitor** subscribes to kernel routing events (rule, route, link and address changes). The instant the host switches networks - you walk out of Wi-Fi range and it falls back to mobile data, or you plug in a USB-ethernet dongle on a laptop - the monitor re-points the container's traffic. No restart, no config. +- CLAT/464xlat interfaces (the `v4-rmnet...` interfaces phones synthesise on IPv6-only mobile networks) are picked up automatically. + +This automatic mode is the right choice for the vast majority of users. The rest of this section is only relevant if you want to *override* it. + +### Pinning the uplink manually with `--upstream` + +Sometimes you do **not** want the container to follow whatever network the host is using. You want to force its internet out through one specific interface and keep it there. That is what `--upstream` does. + +When you pass `--upstream`, automatic detection is switched off entirely. The interface(s) you list become the *only* candidates the container will ever use for WAN - it never hops to whatever the host marks as its active default network. + +```bash +# Force the container's internet out through Wi-Fi, always +droidspaces --name=box --rootfs=/data/box --net=nat --upstream=wlan0 start +``` + +You can list **multiple interfaces, comma-separated**, and use **wildcards** (`*`, `?`): + +```bash +droidspaces --name=box --rootfs=/data/box --net=nat --upstream=wlan0,rmnet* start +``` + +The list is **priority-ordered**. The route monitor walks it top to bottom and uses the first interface that is currently up and actually has internet. So `wlan0,rmnet*` means "prefer Wi-Fi; if Wi-Fi is down, fall back to mobile data" - and when Wi-Fi returns, it switches back. The failover stays strictly *inside your list*; it never falls back to an interface you did not list. This is the key difference from auto mode - the WAN is yours to decide, not the host's. + +If a pinned interface is missing when the container starts, or disappears mid-session and later reappears, that is handled too: the container simply has no WAN until one of your pinned interfaces is up, then it wires up automatically. + +> **Why wildcards matter on mobile data:** Android does not give the mobile-data interface a stable number - it might be `rmnet0`, `rmnet8`, `rmnet_data2`, and the number can change across reconnects. Pinning a literal `rmnet0` will break the next time it comes up as something else. Pin `rmnet*` instead and it keeps working. + +### Use cases for `--upstream` + +**1. Route the container's WAN through an Android VPN (`tun0`)** + +Connect a VPN on the phone itself - ProtonVPN, WireGuard, OpenVPN, any app that creates a `tun0` interface. Then pin the container to it: + +```bash +droidspaces --name=box --rootfs=/data/box --net=nat --upstream=tun0 start +``` + +Now all of the container's traffic goes out through the VPN tunnel, and *only* the tunnel. If the VPN drops, `tun0` disappears and the container loses internet instead of leaking out over your real connection - a simple killswitch, for free. + +**2. Container on mobile data while the phone stays on Wi-Fi** + +Android can keep the cellular radio up even while you are on Wi-Fi. Enable **"Mobile data always active"** in Developer Options, connect to Wi-Fi, then turn mobile data on. Both networks are now live at once. Pin the container to the mobile-data interface: + +```bash +droidspaces --name=box --rootfs=/data/box --net=nat --upstream=rmnet* start +``` + +The container's traffic goes out over mobile data while the rest of the phone keeps using Wi-Fi. Handy for testing from a different IP/network, putting a container's bandwidth onto cellular, or simply running something on a separate connection from everything else on the phone. + --- ## Part 6: Bridges and Virtual Cables (Linux Plumbing) diff --git a/Documentation/Usage-Android-App.md b/Documentation/Usage-Android-App.md index 95de8979..acf707ab 100644 --- a/Documentation/Usage-Android-App.md +++ b/Documentation/Usage-Android-App.md @@ -68,15 +68,25 @@ The repository supports third-party rootfs sources in the same JSON format. ## Networking Configuration -When editing or creating a container, you can choose from three networking modes: +When editing or creating a container, you can choose from four networking modes: - **Host (Default)**: Shares host network directly. - **NAT (Isolated)**: Private network namespace with deterministic IP and port forwarding support. - **None**: No network access. +- **Gateway**: The container's LAN is delegated to another running container (typically OpenWRT), which owns DHCP, DNS, firewall and routing. Select the gateway container and (optionally) the LAN segment, interface and bridge in the **Gateway** settings. See [Networking From Zero](Networking-From-Zero.md) for the full guide. ### Internet Uplink (NAT Mode) If you select **NAT (Isolated)** mode, the internet uplink is detected fully automatically - there is nothing to configure. Droidspaces reads the kernel's own routing state to find the interface Android is currently using for internet (Wi-Fi, mobile data, ethernet) and a background Route Monitor keeps the container connected in real time as you switch networks. +#### Upstream Interface (Optional) +If you want the container to **ignore the active network** and pin its internet to specific interface(s), add them under **Upstream Interface**. This disables auto-detection and forces the WAN through your list only: + +- The list is **priority-ordered** and supports **wildcards** - e.g. `wlan0, rmnet*` prefers Wi-Fi and falls back to mobile data (use `rmnet*` because the mobile-data interface number is not stable). +- **Example - VPN killswitch**: run a VPN app on the phone and pin `tun0` so the container can only reach the internet through the tunnel. +- **Example - cellular while on Wi-Fi**: enable *Mobile data always active* in Developer Options, connect Wi-Fi, turn on mobile data, and pin `rmnet*` so the container uses cellular while the phone stays on Wi-Fi. + +Leave it empty to auto-detect the active uplink (the default, recommended for most users). + > [!NOTE] > NAT mode is IPv4 only. If your carrier only provides IPv6, see the [IPv4 NAT Workaround](Troubleshooting.md#ipv4-quirks). diff --git a/README.md b/README.md index 7eb0a623..957c0c43 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ The entire runtime is a **single static binary** under 400KB, compiled against m | **Init System Support** | Run systemd, OpenRC or any other init system as PID 1. Full service management and proper boot/shutdown/reboot sequences. | | **Deep Android Integration** | Supports two daemon modes: **Native init.rc** (lowest-level integration with auto-spawn/unkillable persistence) and **Userspace Daemon** (app-togglable, starts via `post-fs-data.sh`, no image modification required). **Both modes bypass root-domain seccomp blocks to ensure stable container lifecycles** [[init.rc Developer Guide](./init/README.md)]. | | **Namespace Isolation** | Complete isolation via PID, MNT, UTS, IPC, and Cgroup namespaces. Each container has its own process tree, mount table, hostname, IPC resources, and cgroup hierarchy. | -| **Network Isolation** | **3 Networking Modes (Host, NAT, None)**. Pure network isolation via `CLONE_NEWNET` (NAT/None modes) or shared host networking (Host mode). Works on both Android and Linux. | +| **Network Isolation** | **4 Networking Modes (Host, NAT, None, Gateway)**. Pure network isolation via `CLONE_NEWNET` (NAT/None/Gateway modes) or shared host networking (Host mode). NAT auto-detects the active uplink (or pin it with `--upstream`); Gateway delegates LAN/DHCP/firewall to another container like OpenWRT. Works on both Android and Linux. | | **Android Display & GPU** | Three acceleration modes: **llvmpipe** (software, all devices), **VirGL** (Mali/PowerVR), and **Turnip** (native Qualcomm/Adreno). As of v6.3.0, the X server and VirGL server launch automatically when the container starts - zero manual Termux commands required. Environment variables (`DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`) are injected automatically. [[More info](./Documentation/Graphics-and-Audio.md)] | | **Android Sound** | PulseAudio daemon runs on the host as the Termux user so Android's audio HAL grants it device access. The socket is bind-mounted into the container at `/tmp/.pulse-socket` and `PULSE_SERVER` is injected automatically - audio just works. [[More info](./Documentation/Graphics-and-Audio.md#pulseaudio)] | | **Linux GPU Acceleration** | Zero-configuration GPU acceleration for AMD and Intel GPUs on Linux desktop hosts. [[More info](./Documentation/Graphics-and-Audio.md)] | From c4129ebf1018259c6c3ee52debdbca4c107db0b7 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 14:43:11 +0530 Subject: [PATCH 084/211] docs: cover gateway mode and --upstream pinning in built-in help Signed-off-by: ravindu644 --- src/documentation.c | 48 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/documentation.c b/src/documentation.c index 9db66e2f..fc469a1f 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -283,20 +283,52 @@ static void print_page(int page, const char *bin) { printf("----------\n\n"); printf("%sIsolation Modes (--net):%s\n", bold, reset); - printf(" --net=host Shared with host (default)\n"); - printf(" --net=none No network access (air-gapped)\n"); - printf(" --net=nat Isolated namespace with internet access\n\n"); + printf(" --net=host Shared with host (default)\n"); + printf(" --net=none No network access (air-gapped)\n"); + printf(" --net=nat Isolated namespace with internet access\n"); + printf(" --net=gateway LAN delegated to another container " + "(e.g. OpenWRT)\n\n"); printf("%sNAT Mode Configuration:%s\n", bold, reset); printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " "start\n", bin); printf(" The internet uplink is detected automatically and tracked " - "in real time.\n"); - printf(" Use --upstream IFACE to pin WAN to a specific interface " - "(disables auto-detect),\n"); - printf(" e.g. --upstream wlan0 to route the container over Wi-Fi while " - "the phone uses mobile data.\n\n"); + "in real time\n"); + printf(" as the host switches networks (Wi-Fi <-> mobile data, " + "ethernet, etc.).\n\n"); + + printf("%sManual Uplink Pinning (--upstream, NAT only):%s\n", bold, reset); + printf(" Pin WAN to specific interface(s) and disable auto-detect. " + "The list is\n"); + printf(" comma-separated, priority-ordered, and supports wildcards.\n"); + printf(" --upstream=wlan0 Always use Wi-Fi\n"); + printf(" --upstream=wlan0,rmnet* Prefer Wi-Fi, fall back to mobile " + "data\n"); + printf(" --upstream=tun0 Route only through a phone VPN " + "(killswitch)\n"); + printf(" --upstream=rmnet* Stay on mobile data while the phone " + "uses Wi-Fi\n"); + printf(" (Use rmnet* - the mobile-data interface number is not stable " + "across reconnects.)\n\n"); + + printf("%sGateway Mode Configuration:%s\n", bold, reset); + printf(" Delegate the LAN to another running container (e.g. OpenWRT), " + "which owns\n"); + printf(" DHCP, DNS, firewall and routing. Start the gateway first, " + "then the client.\n"); + printf(" %s --name=openwrt --rootfs=/path/to/openwrt --net=nat start\n", + bin); + printf(" %s --name=kali --rootfs=/path/to/kali --net=gateway " + "--gateway=openwrt start\n", + bin); + printf(" --gateway=NAME Gateway container (required)\n"); + printf(" --gateway-net=NAME LAN segment / bridge suffix " + "(default: lan)\n"); + printf(" --gateway-iface=IFACE Interface name inside the gateway " + "(default: eth1)\n"); + printf(" --gateway-bridge=BR Override host bridge name " + "(default: ds-NAME)\n\n"); printf("%sPort Forwarding (NAT only):%s\n", bold, reset); printf(" --port=8080:80 Single port\n"); From d9e6938062bcdf52b2a7f638967a8c8c22a57994 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 14:55:04 +0530 Subject: [PATCH 085/211] docs: link Networking From Zero guide in README Signed-off-by: ravindu644 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 957c0c43..adff5872 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,7 @@ For GPU acceleration methods, sound setup, DE auto-boot internals, and Linux des | Document | Description | |----------|-------------| | [Feature Deep Dives](Documentation/Features.md) | Detailed explanation of each major feature. | +| [Networking From Zero](Documentation/Networking-From-Zero.md) | Beginner-friendly guide to every networking concept behind Droidspaces - NAT, automatic uplink detection, `--upstream` pinning, and gateway mode with OpenWRT. | | [Display, Audio & Desktop Guide](Documentation/Graphics-and-Audio.md) | GPU acceleration, PulseAudio sound, and desktop environment auto-boot on Android and Linux. | | [Cool Things You Can Do (Tailscale, Docker, etc.)](Documentation/Cool-things-you-can-do.md) | Practical recipes for running Tailscale, Docker, and other tools inside containers. | | [Uninstallation Guide](Documentation/Uninstallation.md) | How to remove Droidspaces from your system. | From 9e738a83942d7cf4d16be6ccdd4799e83507489b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 16:11:28 +0530 Subject: [PATCH 086/211] net: heal stale gateway cable when its peer outlives the gateway netns Signed-off-by: ravindu644 --- src/net/network.c | 80 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/src/net/network.c b/src/net/network.c index 353c2bbc..0cfb2d34 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -208,6 +208,42 @@ static int ds_netns_rename_up(const char *netns_path, const char *old_name, return ret; } +/* Return 1 if interface `ifname` exists inside the netns at `netns_path`, else + * 0. Used to tell a healthy gateway cable (its peer really lives inside the + * CURRENT gateway netns) apart from a stale host-side veth whose peer is + * stranded in a zombie netns - one kept alive past container stop by a leftover + * process (e.g. tailscaled). On any error it returns 0, i.e. "not present", so + * the caller rebuilds the cable: the safe default. */ +static int netns_has_link(const char *netns_path, const char *ifname) { + if (!netns_path || !ifname || !ifname[0]) + return 0; + + int self_fd = open("/proc/self/ns/net", O_RDONLY | O_CLOEXEC); + if (self_fd < 0) + return 0; + int target_fd = open(netns_path, O_RDONLY | O_CLOEXEC); + if (target_fd < 0) { + close(self_fd); + return 0; + } + + int present = 0; + if (setns(target_fd, CLONE_NEWNET) == 0) { + ds_nl_ctx_t *ctx = ds_nl_open(); + if (ctx) { + present = ds_nl_link_exists(ctx, ifname) ? 1 : 0; + ds_nl_close(ctx); + } + if (setns(self_fd, CLONE_NEWNET) < 0) + ds_warn("[NET] Gateway: failed to restore netns after liveness check: %s", + strerror(errno)); + } + + close(target_fd); + close(self_fd); + return present; +} + /* --------------------------------------------------------------------------- * Uplink routing globals - shared by android routing setup and monitor * ---------------------------------------------------------------------------*/ @@ -918,10 +954,12 @@ static pid_t gateway_pid_of(const char *name) { * - ensure the gateway-side veth (ds-g) exists with its peer living in * the gateway netns as gw_if (e.g. eth1) * - * Idempotent: a live ds-g implies a live peer (veth pairs die together), - * so a repeat call is a cheap reattach. When the cable is absent we plug a - * fresh one into the gateway's (possibly just-rebooted) netns - this is what - * heals clients after a gateway restart, with no client restart. + * Idempotent: a repeat call is a cheap reattach when the cable is genuinely + * healthy (its peer lives inside the CURRENT gateway netns). When the cable is + * absent - or present but stale (peer stranded in a zombie netns that outlived + * the previous gateway) - we plug a fresh one into the gateway's (possibly + * just-rebooted) netns. This is what heals clients after a gateway restart, + * with no client restart. * * Returns 0 when the gateway-side cable is up, -1 on a netlink failure. * ---------------------------------------------------------------------------*/ @@ -972,19 +1010,33 @@ static int gateway_ensure_lan_uplink_locked(struct ds_config *cfg, write_file("/proc/sys/net/bridge/bridge-nf-call-iptables", "0"); write_file("/proc/sys/net/bridge/bridge-nf-call-ip6tables", "0"); - /* Cable already present → live gateway peer → just re-assert master + up. - * This is the idempotent no-op path when the segment is already healthy. */ - if (ds_nl_link_exists(ctx, gw_host)) { - if (ds_nl_set_master(ctx, gw_host, bridge) < 0) - ds_warn("[NET] Gateway: failed to reattach %s to %s", gw_host, bridge); - ds_nl_link_up(ctx, gw_host); - ds_nl_close(ctx); - return 0; - } - char gw_netns[PATH_MAX]; snprintf(gw_netns, sizeof(gw_netns), "/proc/%d/ns/net", (int)gw_pid); + /* Host-side cable present. This does NOT prove the peer is inside the + * CURRENT gateway netns: if a process kept the previous gateway's netns alive + * past `stop` (e.g. tailscaled), the veth pair survived and its peer is + * stranded there - so a stale ds-g can outlive the gateway it was built + * for. Verify gw_if actually exists inside this gateway before trusting the + * cable. + * - peer live in this netns → idempotent no-op: re-assert master + up. + * - peer absent → stale cable: delete it (which also reaps the + * stranded peer, veth pairs die together) and + * fall through to build a fresh one into this netns. */ + if (ds_nl_link_exists(ctx, gw_host)) { + if (netns_has_link(gw_netns, gw_if)) { + if (ds_nl_set_master(ctx, gw_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to reattach %s to %s", gw_host, bridge); + ds_nl_link_up(ctx, gw_host); + ds_nl_close(ctx); + return 0; + } + ds_warn("[NET] Gateway: stale cable %s (peer not in gateway netns) - " + "rebuilding", + gw_host); + ds_nl_del_link(ctx, gw_host); + } + ds_log("[NET] Gateway: creating gateway veth %s <-> %s", gw_host, gw_peer); if (ds_nl_create_veth(ctx, gw_host, gw_peer) < 0) { ds_warn("[NET] Gateway: failed to create gateway veth pair"); From 8510a026494bea335b6b644e30d361512b717792 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Tue, 30 Jun 2026 17:07:38 +0530 Subject: [PATCH 087/211] net: explicitly reap gateway veth on gateway stop to free orphan netns Signed-off-by: ravindu644 --- src/include/droidspace.h | 5 ++ src/net/network.c | 111 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 66e75f30..f4937f60 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -695,6 +695,11 @@ void ds_net_start_route_monitor(void); * cable to every running client that delegates to it, with no client restart. * Called from the gateway container's monitor on each boot cycle. */ void ds_net_rewire_gateway_clients(const char *gateway_name, pid_t gateway_pid); +/* Gateway teardown: when a container that ACTS AS A GATEWAY stops, explicitly + * delete the gateway-side veth(s) it serves and reap any now-idle delegated + * bridge. The kernel does not auto-reap these (the host-side veth pins its + * orphan peer netns), so this prevents the leak. No-op for a non-gateway. */ +void ds_net_gateway_teardown(const char *gateway_name); int ds_net_disable_tx_checksum(const char *ifname); void parse_cidr(const char *cidr, uint32_t *ip_out, uint32_t *mask_out); diff --git a/src/net/network.c b/src/net/network.c index 0cfb2d34..7cdf05e1 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -1291,6 +1291,105 @@ void ds_net_rewire_gateway_clients(const char *gateway_name, closedir(d); } +/* --------------------------------------------------------------------------- + * ds_net_gateway_teardown + * + * Called when a container that ACTS AS A GATEWAY stops. The gateway-side veth + * ds-g lives in the host netns; its peer is the gateway's eth1. When the + * gateway stops the kernel does NOT auto-reap ds-g: the host-side veth itself + * pins its now-process-less peer netns (a veth end holds a reference to its + * peer's namespace), and that netns can only be freed by a cleanup_net that + * cannot run while ds-g pins it - a self-sustaining orphan. So we delete ds-g + * explicitly, exactly as NAT mode deletes ds-v. + * + * We do not track our own segments (clients choose --gateway-net), so scan the + * client configs that delegate to us, derive each segment's bridge + gateway + * veth, delete the veth (reaping the peer and freeing the netns), and reap the + * bridge once no client veths remain on it. Per-segment work runs under the + * same advisory lock client setup/cleanup use, and bridges are de-duplicated + * since many clients can share one segment. A no-op for a container that is + * nobody's gateway. + * ---------------------------------------------------------------------------*/ +void ds_net_gateway_teardown(const char *gateway_name) { + if (!gateway_name || !gateway_name[0]) + return; + + char containers_dir[PATH_MAX]; + snprintf(containers_dir, sizeof(containers_dir), "%s/Containers", + get_workspace_dir()); + DIR *d = opendir(containers_dir); + if (!d) + return; + + /* De-dupe segments: many clients can share one --gateway-net (one bridge). */ + char seen[32][IFNAMSIZ]; + int seen_count = 0; + + struct dirent *ent; + while ((ent = readdir(d)) != NULL) { + if (ent->d_name[0] == '.') + continue; + + struct ds_config c = {0}; + if (ds_config_load_by_name(ent->d_name, &c) != 0) + continue; + + if (!(c.net_mode == DS_NET_GATEWAY && c.gateway_container[0] && + strcmp(c.gateway_container, gateway_name) == 0)) { + ds_config_free(&c); + continue; + } + + char bridge[IFNAMSIZ], gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; + gateway_bridge_name(&c, bridge, sizeof(bridge)); + gateway_veth_names(&c, gw_host, sizeof(gw_host), gw_peer, sizeof(gw_peer)); + + int dup = 0; + for (int i = 0; i < seen_count; i++) + if (strcmp(seen[i], bridge) == 0) { + dup = 1; + break; + } + if (dup) { + ds_config_free(&c); + continue; + } + if (seen_count < (int)(sizeof(seen) / sizeof(seen[0]))) + safe_strncpy(seen[seen_count++], bridge, IFNAMSIZ); + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ds_config_free(&c); + continue; + } + + /* Same lock client setup/cleanup take, so we cannot race a concurrent + * client start/wire or the gateway's own rewire on the segment. */ + int lock = gateway_segment_lock(bridge); + + ds_nl_del_link(ctx, gw_host); + ds_log("[NET] Gateway teardown: removed gateway veth %s (segment %s)", + gw_host, bridge); + + int clients = ds_nl_count_bridge_members_with_prefix( + ctx, bridge, app_veth_host_prefix(&c)); + if (clients > 0) { + ds_log("[NET] Gateway teardown: %d client(s) still on %s - keeping " + "bridge", + clients, bridge); + } else { + ds_nl_del_link(ctx, bridge); + ds_log("[NET] Gateway teardown: reaped idle delegated LAN bridge %s", + bridge); + } + + gateway_segment_unlock(lock); + ds_nl_close(ctx); + ds_config_free(&c); + } + closedir(d); +} + /* --------------------------------------------------------------------------- * setup_veth_child_side_named * @@ -1999,6 +2098,12 @@ void ds_net_start_route_monitor(void) { * ---------------------------------------------------------------------------*/ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { + /* If this container is a gateway for others, explicitly tear down the + * gateway-side veth(s) it serves: the kernel will not auto-reap them (the + * host-side veth pins its orphan peer netns). No-op when nobody delegates + * to us, so it is safe to run for every stopping container. */ + ds_net_gateway_teardown(cfg->container_name); + if (cfg->net_mode == DS_NET_GATEWAY) { ds_nl_ctx_t *ctx = ds_nl_open(); if (!ctx) @@ -2037,8 +2142,10 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { "- keeping bridge to avoid flapping its LAN iface", bridge, cfg->gateway_container); } else { - /* No clients and the gateway is gone (its netns death already took the - * gateway veth with its peer) - safe to reap the now-idle bridge. */ + /* No clients and the gateway is gone. The gateway's own stop already + * ran ds_net_gateway_teardown(), which explicitly deleted the gateway + * veth (the kernel does not auto-reap it), so the bridge is now idle + * and safe to reap. */ ds_nl_del_link(ctx, bridge); ds_log("[NET] Gateway cleanup: reaped idle delegated LAN bridge %s", bridge); From 39dbbdf793ee9dffba262cfdec086799ca32bd6e Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 1 Jul 2026 11:39:35 +0530 Subject: [PATCH 088/211] net: wire gateway clients before unblocking gateway init Move ds_net_rewire_gateway_clients() ahead of the DONE handshake write. The DONE write unblocks the container init to pivot_root and exec the real init (procd/netifd); plugging client LAN cables (eth1) after it hot-plugged them into a gateway whose netifd was already booting, racing its LAN bring-up. Wire the client cables first, then unblock, so the gateway execs its init with every client cable already present - mirroring how a client's eth0 is wired before its own DONE. Signed-off-by: ravindu644 --- src/monitor.c | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/monitor.c b/src/monitor.c index 49814b44..f79fca3c 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -401,6 +401,22 @@ reboot_loop:; } } + /* Gateway self-heal: if any running client delegates to THIS container as + * its gateway, (re)plug their LAN cable into our (possibly just-rebooted) + * netns now. Runs on every boot cycle - a cheap no-op when nobody routes + * through us - so a gateway reboot re-wires its clients with no client + * restart. + * + * This MUST run BEFORE the DONE handshake below. The DONE write is what + * unblocks init to proceed into pivot_root + exec of the real init + * (procd/netifd). A client's own eth0 is likewise wired before its DONE + * (setup_gateway_veth_side above); plugging the gateway's LAN cable(s) + * (eth1 ...) after DONE would hot-plug them into a gateway whose netifd + * is already booting, racing its LAN bring-up. Wire first, then unblock, + * so the gateway execs its init with every client cable already present. + */ + ds_net_rewire_gateway_clients(cfg->container_name, netns_pid); + /* Send handshake to init */ struct ds_net_handshake hs; ds_net_derive_handshake(netns_pid, cfg, &hs); @@ -413,13 +429,6 @@ reboot_loop:; if (write(cfg->net_done_pipe[1], &hs, sizeof(hs)) != (ssize_t)sizeof(hs)) ds_warn("[NET] Monitor: failed to write handshake to init"); close(cfg->net_done_pipe[1]); - - /* Gateway self-heal: if any running client delegates to THIS container as - * its gateway, (re)plug their LAN cable into our (possibly just-rebooted) - * netns now. Runs on every boot cycle - a cheap no-op when nobody routes - * through us - so a gateway reboot re-wires its clients with no client - * restart. */ - ds_net_rewire_gateway_clients(cfg->container_name, netns_pid); } } From 79570146f415cc18462800efa5e6af0cce7627c4 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 1 Jul 2026 11:53:13 +0530 Subject: [PATCH 089/211] mount: force image rootfs read-write before pivot_root Fixes OpenWRT external and internal reboot. On shutdown/reboot OpenWRT remounts its root read-only, leaving our mount point under /mnt/Droidspaces/ read-only too. External and internal reboots never unmount the rootfs.img (to keep restarts fast), so the next boot reuses that still-read-only mount and pivot_root fails to create .old_root ("Read-only file system") - the container never booted. Force the image mount read-write in internal_boot() right after the rootfs self-bind - the single point every boot path funnels through. Skipped for directory rootfs and volatile mode; no-op if already RW. Signed-off-by: ravindu644 --- src/boot.c | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/boot.c b/src/boot.c index 4abab540..5a26a917 100644 --- a/src/boot.c +++ b/src/boot.c @@ -215,6 +215,26 @@ int internal_boot(struct ds_config *cfg) { goto boot_fail; } + /* 4b. Force the image rootfs read-write - the single unified point covering + * fresh boot, restart, AND internal reboot. + * + * On shutdown/reboot OpenWRT remounts its root read-only, leaving our mount + * point under /mnt/Droidspaces/ read-only too. External and internal + * reboots never unmount the rootfs.img (to keep restarts fast), so the next + * boot reuses that still-read-only mount and pivot_root fails to create + * .old_root ("Read-only file system") - the container never boots. + * + * Skipped for directory rootfs (a fresh self-bind in this new namespace, its + * host backing fs already writable) and volatile mode (pivot_root targets the + * read-write overlay, not the read-only lower image). Safe: the guest synced + * the fs before flipping it read-only. A no-op when already read-write. */ + if (cfg->is_img_mount && !cfg->volatile_mode) { + if (mount(NULL, cfg->rootfs_path, NULL, + MS_REMOUNT | MS_NOATIME | MS_NODIRATIME, NULL) < 0) + ds_warn("Failed to remount rootfs %s read-write: %s", cfg->rootfs_path, + strerror(errno)); + } + /* 5. Set working directory to rootfs (required before pivot_root) */ if (chdir(cfg->rootfs_path) < 0) { ds_error("Failed to chdir to '%s': %s", cfg->rootfs_path, strerror(errno)); From 5f5c613125cb1d8bfc09b7e4b4fd79415ecee33a Mon Sep 17 00:00:00 2001 From: poqdavid Date: Wed, 1 Jul 2026 08:53:43 +0200 Subject: [PATCH 090/211] docs: Add Galaxy A15 4G (sma155f) to GKI support list (#225) --- Documentation/community-supported-devices.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 4038b3d8..2aa28756 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -233,6 +233,7 @@ This document is a community-maintained compatibility list for Android devices k | **OnePlus ACE 6 Ultra** | PMB110 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Pad 3 Pro** | OPD2513 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Pad 4** | OPD2514 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Galaxy A15 4G** | SM-A155F | One UI 7.0 - Android 15| `5.10.226` | KernelSU/Next + SusFS | [Source](https://github.com/poqdavid/android_kernel_samsung_sma155f/tree/kernelsunext) | [Download](https://github.com/poqdavid/android_kernel_samsung_sma155f/releases) | Both | Virgl only | Working | [@poqdavid](https://github.com/poqdavid) | See the latest release notes for more details and feature availability. | ## Contribution guidelines To keep this list useful and reliable, please follow these rules when adding or updating entries: From ac346ff928536d0b4c36b4f0791500bc66122463 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 1 Jul 2026 12:36:24 +0530 Subject: [PATCH 091/211] ci: remove redundant run-name configuration from CI workflow Signed-off-by: ravindu644 --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40e48d4b..d7ed6693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ name: Droidspaces CI -run-name: ${{ github.event.head_commit.message || format('Release {0}', github.event.inputs.tag_name) || github.workflow }} on: push: From fe67da354204d8c1a91ec7d26759dec30873dfd5 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 1 Jul 2026 12:41:56 +0530 Subject: [PATCH 092/211] ci: name CI apk Droidspaces-universal-CI-.apk Use the short commit hash instead of the build date, and default the tag component to CI instead of test for non-release builds. Signed-off-by: ravindu644 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7ed6693..131a1fb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,9 +104,9 @@ jobs: - name: Stage & Rename Artifacts run: | - TAG="${{ github.event.inputs.tag_name || 'test' }}" - DATE=$(date +%Y-%m-%d) - APK_NAME="Droidspaces-universal-${TAG}-${DATE}.apk" + TAG="${{ github.event.inputs.tag_name || 'CI' }}" + COMMIT_HASH=$(git log -1 --pretty=%h) + APK_NAME="Droidspaces-universal-${TAG}-${COMMIT_HASH}.apk" TAR_NAME=$(ls droidspaces-v*-*.tar.gz) mkdir -p dist mv Android/app/build/outputs/apk/release/app-release.apk dist/${APK_NAME} From 35c6dd4c2ea49fed8e54668a23f4148ea583bbb6 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 1 Jul 2026 13:11:14 +0530 Subject: [PATCH 093/211] build: name tarball with commit hash instead of date Match the CI apk naming: droidspaces-v-.tar.gz. Falls back to the date when not in a git checkout. Signed-off-by: ravindu644 --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 00e6ed61..a92194d3 100644 --- a/Makefile +++ b/Makefile @@ -259,8 +259,8 @@ tarball: echo "[+] Created: $$TARBALL ($$(du -h $$TARBALL | cut -f1))" all-tarball: all-build - @DATE=$$(date +%Y-%m-%d); \ - TARBALL="$(BINARY_NAME)-v$(VERSION)-$$DATE.tar.gz"; \ + @COMMIT_HASH=$$(git rev-parse --short HEAD 2>/dev/null || date +%Y-%m-%d); \ + TARBALL="$(BINARY_NAME)-v$(VERSION)-$$COMMIT_HASH.tar.gz"; \ ROOT_DIR="$(BINARY_NAME)-v$(VERSION)"; \ TEMP_DIR="/tmp/droidspaces-tarball-$$$$"; \ mkdir -p $$TEMP_DIR/$$ROOT_DIR; \ From da001f9f570acc96a317fc9e680c2d7824422d14 Mon Sep 17 00:00:00 2001 From: Abdelah Yisehak <112805391+abd3-13@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:46:12 +0300 Subject: [PATCH 094/211] change: chkopenrc.sh to use rc-status for checking of running services (#226) --- Android/app/src/main/assets/chkopenrc.sh | 25 +++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Android/app/src/main/assets/chkopenrc.sh b/Android/app/src/main/assets/chkopenrc.sh index 98cc8371..5512fe59 100644 --- a/Android/app/src/main/assets/chkopenrc.sh +++ b/Android/app/src/main/assets/chkopenrc.sh @@ -4,6 +4,10 @@ RUNLEVELS_DIR="/etc/runlevels" INITD_DIR="/etc/init.d" +_all_running() { + rc-status -f ini 2>/dev/null | awk -F'=' '/=/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' +} + _all_services() { ls "$INITD_DIR" 2>/dev/null | grep -v '^\.' | sort } @@ -22,13 +26,24 @@ _get_desc() { case "$1" in --running) - # Output: name|Description (matches chksystemd.sh --running) - for svc in $(_all_services); do - if rc-service "$svc" status >/dev/null 2>&1; then + # Output: name|Description (matches chksystemd.sh --running) + + if running=$(_all_running 2>/dev/null); then + # Preferred method: use rc-status output directly. + echo "$running" | while IFS= read -r svc; do + [ -n "$svc" ] || continue desc=$(_get_desc "$svc") echo "${svc}|${desc}" - fi - done + done + else + # Fallback: query each service individually. + for svc in $(_all_services); do + if rc-service "$svc" status >/dev/null 2>&1; then + desc=$(_get_desc "$svc") + echo "${svc}|${desc}" + fi + done + fi ;; --enabled) # Output: name From 7612af04564ed6a3e75496a8c9fc65c49753da58 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 4 Jul 2026 18:43:23 +0530 Subject: [PATCH 095/211] droidspaces: bump v6.4.0 Signed-off-by: ravindu644 --- src/include/droidspace.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/include/droidspace.h b/src/include/droidspace.h index f4937f60..dbf71a6b 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -67,7 +67,7 @@ * ---------------------------------------------------------------------------*/ #define DS_PROJECT_NAME "Droidspaces" -#define DS_VERSION "6.3.0" +#define DS_VERSION "6.4.0" #define DS_MIN_KERNEL_MAJOR 3 #define DS_MIN_KERNEL_MINOR 10 #define DS_RECOMMENDED_KERNEL_MAJOR 4 From 800d57c869575cfc45f114e7d67cace91f47e6a4 Mon Sep 17 00:00:00 2001 From: Abdelah Yisehak <112805391+abd3-13@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:41:16 +0300 Subject: [PATCH 096/211] Update bugreport.sh (#227) --- Android/app/src/main/assets/bugreport.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Android/app/src/main/assets/bugreport.sh b/Android/app/src/main/assets/bugreport.sh index 5739d3be..2c8b8c26 100755 --- a/Android/app/src/main/assets/bugreport.sh +++ b/Android/app/src/main/assets/bugreport.sh @@ -12,7 +12,14 @@ BUSYBOX="${DROIDSPACE_DIR}/bin/busybox" LOGS_DIR="${DROIDSPACE_DIR}/Logs" DATE_TIME="$("${BUSYBOX}" date +"%Y-%m-%d_%H-%M-%S")" BUGREPORT_DIR="${LOGS_DIR}/bugreport_${DATE_TIME}" -OUTPUT_TARBALL="/sdcard/Droidspaces-bugreport_${DATE_TIME}.tar.gz" +TARBALL="Droidspaces-bugreport_${DATE_TIME}.tar.gz" + +# Check if /sdcard is writable +if [ -w /sdcard ]; then + OUTPUT_TARBALL="/sdcard/$TARBALL" +else + OUTPUT_TARBALL="$DROIDSPACE_DIR/$TARBALL" +fi # We need busybox for reliable cross-device tools (tar, sort, grep, etc.) if [ ! -f "${BUSYBOX}" ] || [ ! -x "${BUSYBOX}" ]; then From 3134eea2278d3a22f8d89c9fcfe06d1b79844cd9 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 9 Jul 2026 12:15:10 +0530 Subject: [PATCH 097/211] app: disable save/proceed button on static IP collision Signed-off-by: ravindu644 --- .../app/ui/screen/ContainerConfigScreen.kt | 17 ++++++++++++++++- .../app/ui/screen/EditContainerScreen.kt | 12 ++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index 0a253ff8..638ace6a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -153,7 +153,13 @@ fun ContainerConfigScreen( installed = installedContainers, context = context ) - val canProceed = netMode != "gateway" || gatewayErrors.isValid + + val collisionContainer = remember(netMode, staticNatIp, installedContainers) { + if (netMode != "nat" || staticNatIp.isEmpty()) null + else installedContainers.find { it.name != containerName && it.staticNatIp == staticNatIp } + } + + val canProceed = (netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null // Internal UI States var showFilePicker by remember { mutableStateOf(false) } @@ -524,6 +530,15 @@ fun ContainerConfigScreen( ) } + if (collisionContainer != null) { + Text( + text = context.getString(R.string.error_ip_collision, collisionContainer.name), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp) + ) + } + // Upstream Interface (optional pin) Text( text = context.getString(R.string.upstream_interface_title), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index bc31bb1d..39e2ece2 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -129,6 +129,11 @@ fun EditContainerScreen( context = context ) + val collisionContainer = remember(netMode, staticNatIp, containerViewModel.containerList) { + if (netMode != "nat" || staticNatIp.isEmpty()) null + else containerViewModel.containerList.find { it.name != container.name && it.staticNatIp == staticNatIp } + } + // Track the "saved" baseline values - updated after each successful save var savedHostname by remember { mutableStateOf(container.hostname) } var savedNetMode by remember { mutableStateOf(container.netMode) } @@ -460,7 +465,7 @@ fun EditContainerScreen( bottomBar = { val btnShape = RoundedCornerShape(20.dp) val isReadyToSave = !isSaving && !isSaved && hasChanges && hostnameError == null && - (netMode != "gateway" || gatewayErrors.isValid) + (netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null val targetBtnColor = when { isSaved -> MaterialTheme.colorScheme.primaryContainer isSaving || isReadyToSave -> MaterialTheme.colorScheme.primary @@ -716,11 +721,6 @@ fun EditContainerScreen( octet4.isEmpty() || (octet4.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) } - val collisionContainer = remember(staticNatIp) { - if (staticNatIp.isEmpty()) null - else containerViewModel.containerList.find { it.name != container.name && it.staticNatIp == staticNatIp } - } - Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), From a350154d961e35807d53abd47e2ccd28f6dbdd80 Mon Sep 17 00:00:00 2001 From: VizXtreme Date: Sun, 12 Jul 2026 21:28:03 +0530 Subject: [PATCH 098/211] feat(ui): support AMOLED mode with system theme and preserve selection (#234) --- .../app/ui/screen/SettingsScreen.kt | 38 +++++++------------ .../app/ui/theme/ThemeStateHolder.kt | 3 +- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt index 83cbfec7..fb78bce1 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt @@ -332,11 +332,6 @@ fun SettingsScreen( checked = followSystemTheme, onCheckedChange = { checked -> prefsManager.followSystemTheme = checked - // Disable AMOLED mode when enabling follow system theme - // Theme state updates automatically via SharedPreferences listener - if (checked && amoledMode) { - prefsManager.amoledMode = false - } } ) @@ -349,28 +344,23 @@ fun SettingsScreen( checked = darkTheme, onCheckedChange = { checked -> prefsManager.darkTheme = checked - // Disable AMOLED if dark theme is disabled - // Theme state updates automatically via SharedPreferences listener - if (!checked && amoledMode) { - prefsManager.amoledMode = false - } } ) + } - // AMOLED Mode (only shown when dark theme is explicitly enabled, directly beneath Dark Theme) - if (darkTheme) { - SwitchItem( - icon = Icons.Default.RadioButtonUnchecked, - title = context.getString(R.string.amoled_mode), - summary = context.getString(R.string.amoled_mode_description), - checked = amoledMode, - enabled = true, - onCheckedChange = { checked -> - prefsManager.amoledMode = checked - // Theme state updates automatically via SharedPreferences listener - } - ) - } + // AMOLED Mode (shown when followSystemTheme is true OR manual darkTheme is true) + if (followSystemTheme || darkTheme) { + SwitchItem( + icon = Icons.Default.RadioButtonUnchecked, + title = context.getString(R.string.amoled_mode), + summary = context.getString(R.string.amoled_mode_description), + checked = amoledMode, + enabled = true, + onCheckedChange = { checked -> + prefsManager.amoledMode = checked + // Theme state updates automatically via SharedPreferences listener + } + ) } // Use Dynamic Color (Monet theming) - Only show on Android 12+ diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/theme/ThemeStateHolder.kt b/Android/app/src/main/java/com/droidspaces/app/ui/theme/ThemeStateHolder.kt index 66850a72..7dcf0dc8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/theme/ThemeStateHolder.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/theme/ThemeStateHolder.kt @@ -76,14 +76,13 @@ fun rememberThemeState(): ThemeState { val systemDark = isSystemInDarkTheme() val effectiveDarkTheme = if (followSystemTheme) systemDark else darkTheme - val effectiveAmoledMode = amoledMode && effectiveDarkTheme // AMOLED only works with dark theme // Return state that triggers recomposition when any theme preference changes return remember(followSystemTheme, darkTheme, amoledMode, useDynamicColor, systemDark, themePalette) { ThemeState( followSystemTheme = followSystemTheme, darkTheme = effectiveDarkTheme, - amoledMode = effectiveAmoledMode, + amoledMode = amoledMode, useDynamicColor = useDynamicColor, themePalette = themePalette ) From 58690ea1fff19a4a6faef4d05ed2aa94bf6a8318 Mon Sep 17 00:00:00 2001 From: vitaliylysikov <84893623+vitaliylysikov@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:59:04 +0800 Subject: [PATCH 099/211] Update community-supported-devices.md (#233) Add Redmi K90 Pro Max/Poco F8 Ultra --- Documentation/community-supported-devices.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 2aa28756..1abf3fc4 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -223,6 +223,8 @@ This document is a community-maintained compatibility list for Android devices k | **Xiaomi 17 (Chinese variant)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | | **Xiaomi 17 Pro (Chinese variant)** | 2509FPN0BC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **Xiaomi 17 Pro max (Chinese variant)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **Redmi K90 Pro max (Chinese variant)** | 25102RKBEC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **Poco F8 Ultra** | 25102PCBEG | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **红魔 11 Pro (Chinese variant)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **HONOR Magic Pad 3 Pro 13.3 (Chinese variant)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **OnePlus 15** | CPH2747, CPH2745, PLK110, CPH2749 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | From 7c99ccc7074a56d285d0a3bb43193435ac27cc76 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 9 Jul 2026 20:09:52 +0530 Subject: [PATCH 100/211] ci: include branch name in CI build status notification Signed-off-by: ravindu644 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 131a1fb6..b80b3dbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,7 @@ jobs: echo "**Droidspaces Build Successful**" echo "" echo "**Repository:** [${{ github.repository }}](https://github.com/${{ github.repository }})" + echo "**Branch:** ${{ github.ref_name }}" echo "**Author:** [${{ github.actor }}](https://github.com/${{ github.actor }})" echo "**Event:** $EVENT_TYPE" echo "" From 41e164e8ad556be3a7082947c4473395287ad0a0 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Mon, 13 Jul 2026 11:56:03 +0530 Subject: [PATCH 101/211] RequirementsScreen, docs: added `CONFIG_USER_NS` for the Docker unsafe procfs fix Fixes: https://github.com/ravindu644/Droidspaces-OSS/issues/84 Signed-off-by: ravindu644 --- .../com/droidspaces/app/ui/screen/RequirementsScreen.kt | 8 +++++++- Documentation/Kernel-Configuration.md | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt index 8853a96e..026ee48c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt @@ -177,7 +177,10 @@ CONFIG_NF_NAT_IPV4=y CONFIG_IP_NF_NAT=y # Disable this on older kernels to make internet work -CONFIG_ANDROID_PARANOID_NETWORK=n""", +CONFIG_ANDROID_PARANOID_NETWORK=n + +# Fix for docker unsafe procfs error +CONFIG_USER_NS=y""", guideUrl = "https://github.com/ravindu644/Droidspaces-OSS/blob/main/Documentation/Kernel-Configuration.md#non-gki", snackbarHostState = snackbarHostState ) @@ -207,6 +210,9 @@ CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y # --- Below configs are optional but recommended --- +# Fix for docker unsafe procfs error +CONFIG_USER_NS=y + # UFW support CONFIG_NETFILTER_XT_TARGET_REJECT=y CONFIG_NETFILTER_XT_TARGET_LOG=y diff --git a/Documentation/Kernel-Configuration.md b/Documentation/Kernel-Configuration.md index 1b00a05b..dbe7be14 100644 --- a/Documentation/Kernel-Configuration.md +++ b/Documentation/Kernel-Configuration.md @@ -120,6 +120,9 @@ CONFIG_IP_NF_NAT=y # Disable this on older kernels to make internet work CONFIG_ANDROID_PARANOID_NETWORK=n + +# Fix for docker unsafe procfs error +CONFIG_USER_NS=y ``` ### Step 2: Firewall Support (UFW/Fail2ban) - Optional @@ -244,6 +247,9 @@ CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y # --- Below configs are optional but recommended --- +# Fix for docker unsafe procfs error +CONFIG_USER_NS=y + # UFW support CONFIG_NETFILTER_XT_TARGET_REJECT=y CONFIG_NETFILTER_XT_TARGET_LOG=y From 455f4ecb551c1c96d3a499889f74140df4fc0a35 Mon Sep 17 00:00:00 2001 From: Yibo Wang <2024312277@stu.hit.edu.cn> Date: Thu, 9 Jul 2026 15:47:06 +0800 Subject: [PATCH 102/211] ported the "Allow User Namespaces" feature from the `anland` branch - Added `--allow-userns` option to backend and app to fix desktop slowdowns. - Disabled the user namespace toggle in the app if the kernel doesn't support it. Co-authored-by: ravindu644 --- .../src/main/assets/boot-module/module.prop | 4 +- .../app/ui/component/ContainerCard.kt | 1 + .../app/ui/screen/EditContainerScreen.kt | 36 ++++++++++-- .../droidspaces/app/util/ContainerManager.kt | 3 + Android/app/src/main/res/values/strings.xml | 4 ++ src/boot.c | 2 +- src/config.c | 3 + src/container.c | 19 ++++-- src/include/droidspace.h | 3 +- src/main.c | 7 ++- src/seccomp.c | 58 +++++++++---------- 11 files changed, 95 insertions(+), 45 deletions(-) diff --git a/Android/app/src/main/assets/boot-module/module.prop b/Android/app/src/main/assets/boot-module/module.prop index dde6dd51..8198e1c5 100644 --- a/Android/app/src/main/assets/boot-module/module.prop +++ b/Android/app/src/main/assets/boot-module/module.prop @@ -1,6 +1,6 @@ id=droidspaces name=Droidspaces: Daemon & Init -version=v6.3.0 -versionCode=6300 +version=v6.4.0 +versionCode=6400 author=ravindu644 description=Automatically starts the Droidspaces daemon if "Daemon Mode" is enabled, and starts containers during boot if "Run-at-Boot" is active. diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt index bc3f6006..dab97359 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt @@ -182,6 +182,7 @@ fun ContainerCard( if (container.enableVirgl) options.add(context.getString(R.string.virgl_option)) if (container.enablePulseaudio) options.add(context.getString(R.string.pulseaudio_option)) if (container.selinuxPermissive) options.add(context.getString(R.string.selinux_permissive_option)) + if (container.allowUserns) options.add(context.getString(R.string.userns_option)) if (container.volatileMode) options.add(context.getString(R.string.volatile_option)) if (container.forceCgroupv1) options.add(context.getString(R.string.cgroup_v1_option)) if (container.blockNestedNs) options.add(context.getString(R.string.deadlock_shield_option)) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index 39e2ece2..3ac640aa 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -102,6 +102,7 @@ fun EditContainerScreen( var virglExtraFlags by remember { mutableStateOf(container.virglExtraFlags) } var enablePulseaudio by remember { mutableStateOf(container.enablePulseaudio) } var selinuxPermissive by remember { mutableStateOf(container.selinuxPermissive) } + var allowUserns by remember { mutableStateOf(container.allowUserns) } var volatileMode by remember { mutableStateOf(container.volatileMode) } var bindMounts by remember { mutableStateOf(container.bindMounts) } var dnsServers by remember { mutableStateOf(container.dnsServers) } @@ -147,6 +148,7 @@ fun EditContainerScreen( var savedVirglExtraFlags by remember { mutableStateOf(container.virglExtraFlags) } var savedEnablePulseaudio by remember { mutableStateOf(container.enablePulseaudio) } var savedSelinuxPermissive by remember { mutableStateOf(container.selinuxPermissive) } + var savedAllowUserns by remember { mutableStateOf(container.allowUserns) } var savedVolatileMode by remember { mutableStateOf(container.volatileMode) } var savedBindMounts by remember { mutableStateOf(container.bindMounts) } var savedDnsServers by remember { mutableStateOf(container.dnsServers) } @@ -189,6 +191,7 @@ fun EditContainerScreen( virglExtraFlags != savedVirglExtraFlags || enablePulseaudio != savedEnablePulseaudio || selinuxPermissive != savedSelinuxPermissive || + allowUserns != savedAllowUserns || volatileMode != savedVolatileMode || bindMounts != savedBindMounts || dnsServers != savedDnsServers || @@ -237,6 +240,7 @@ fun EditContainerScreen( virglExtraFlags = virglExtraFlags, enablePulseaudio = enablePulseaudio, selinuxPermissive = selinuxPermissive, + allowUserns = allowUserns, volatileMode = volatileMode, bindMounts = bindMounts, dnsServers = dnsServers, @@ -276,6 +280,7 @@ fun EditContainerScreen( savedVirglExtraFlags = virglExtraFlags savedEnablePulseaudio = enablePulseaudio savedSelinuxPermissive = selinuxPermissive + savedAllowUserns = allowUserns savedVolatileMode = volatileMode savedBindMounts = bindMounts savedDnsServers = dnsServers @@ -962,6 +967,32 @@ fun EditContainerScreen( } ) + val isSeccompDisabled = privileged.contains("noseccomp") || privileged.contains("full") + + //use /proc/self/setgroups to detect userns (only exists when CONFIG_USER_NS is enabled) + val usernsSupported = remember { java.io.File("/proc/self/setgroups").exists() } + + LaunchedEffect(isSeccompDisabled, usernsSupported) { + if (isSeccompDisabled) blockNestedNs = false + if (isSeccompDisabled && usernsSupported) allowUserns = true + if (!usernsSupported) allowUserns = false + } + + ToggleCard( + icon = Icons.Default.Groups, + title = context.getString(R.string.allow_userns), + description = if (usernsSupported) + context.getString(R.string.allow_userns_description) + else + context.getString(R.string.allow_userns_description_not_supported), + checked = allowUserns, + onCheckedChange = { + clearFocus() + allowUserns = it + }, + enabled = !isSeccompDisabled && usernsSupported + ) + ToggleCard( icon = Icons.Default.AutoDelete, title = context.getString(R.string.volatile_mode), @@ -984,11 +1015,6 @@ fun EditContainerScreen( } ) - val isSeccompDisabled = privileged.contains("noseccomp") || privileged.contains("full") - LaunchedEffect(isSeccompDisabled) { - if (isSeccompDisabled) blockNestedNs = false - } - ToggleCard( icon = Icons.Default.GppBad, title = context.getString(R.string.manual_deadlock_shield), diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index 3c006238..54fab33b 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -40,6 +40,7 @@ data class ContainerInfo( val virglExtraFlags: String = "", val enablePulseaudio: Boolean = false, val selinuxPermissive: Boolean = false, + val allowUserns: Boolean = false, val volatileMode: Boolean = false, val bindMounts: List = emptyList(), val dnsServers: String = "", @@ -84,6 +85,7 @@ data class ContainerInfo( if (virglExtraFlags.isNotBlank()) appendLine("virgl_extra_flags=$virglExtraFlags") appendLine("enable_pulseaudio=${if (enablePulseaudio) "1" else "0"}") appendLine("selinux_permissive=${if (selinuxPermissive) "1" else "0"}") + appendLine("allow_userns=${if (allowUserns) "1" else "0"}") appendLine("volatile_mode=${if (volatileMode) "1" else "0"}") if (bindMounts.isNotEmpty()) { appendLine("bind_mounts=${bindMounts.joinToString(",") { "${it.src}:${it.dest}${if (it.ro) ":ro" else ""}" }}") @@ -321,6 +323,7 @@ object ContainerManager { virglExtraFlags = configMap["virgl_extra_flags"] ?: "", enablePulseaudio = configMap["enable_pulseaudio"] == "1", selinuxPermissive = configMap["selinux_permissive"] == "1", + allowUserns = configMap["allow_userns"] == "1", volatileMode = configMap["volatile_mode"] == "1", bindMounts = bindMounts, dnsServers = configMap["dns_servers"] ?: "", diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 3f62a305..bd68d63c 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -150,6 +150,7 @@ Cgroup V1 Deadlock Shield Privileged + User Namespaces Installation Summary Review Configuration Tarball @@ -203,6 +204,9 @@ e.g. --multi-clients --angle-vulkan SELinux Permissive Set SELinux permissive + Allow User Namespaces + Allow unprivileged user namespaces, which Flatpak/bwrap/Firefox requires. However, you may be vulnerable to certain exploits like Fragnesia without a patched kernel + Not supported. CONFIG_USER_NS is not enabled in the kernel Run at Boot Start container on boot Auto Boot Priority diff --git a/src/boot.c b/src/boot.c index 5a26a917..8a3042b3 100644 --- a/src/boot.c +++ b/src/boot.c @@ -529,7 +529,7 @@ int internal_boot(struct ds_config *cfg) { * Apply security hardening (capabilities and seccomp) * This is done at the very end to ensure all setup tasks that might need * privileges (like chown/chmod or mknod) are finished. */ - ds_seccomp_apply_minimal(cfg->privileged_mask); + ds_seccomp_apply_minimal(cfg->privileged_mask, cfg->userns_allowed); android_seccomp_setup(is_systemd, cfg->block_nested_ns && !(cfg->privileged_mask & DS_PRIV_NOSEC), diff --git a/src/config.c b/src/config.c index 5ef0158d..200d818c 100644 --- a/src/config.c +++ b/src/config.c @@ -293,6 +293,8 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { cfg->pulseaudio = parse_bool(val); } else if (strcmp(key, "selinux_permissive") == 0) { cfg->selinux_permissive = parse_bool(val); + } else if (strcmp(key, "allow_userns") == 0) { + cfg->userns_allowed = parse_bool(val); } else if (strcmp(key, "volatile_mode") == 0) { cfg->volatile_mode = parse_bool(val); } else if (strcmp(key, "force_cgroupv1") == 0) { @@ -641,6 +643,7 @@ static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "enable_hw_access=%d\n", cfg->hw_access); fprintf(f, "enable_gpu_mode=%d\n", cfg->gpu_mode); fprintf(f, "selinux_permissive=%d\n", cfg->selinux_permissive); + fprintf(f, "allow_userns=%d\n", cfg->userns_allowed); fprintf(f, "volatile_mode=%d\n", cfg->volatile_mode); fprintf(f, "force_cgroupv1=%d\n", cfg->force_cgroupv1); fprintf(f, "block_nested_ns=%d\n", cfg->block_nested_ns); diff --git a/src/container.c b/src/container.c index 5e2b10e1..2ba60ad5 100644 --- a/src/container.c +++ b/src/container.c @@ -1120,7 +1120,7 @@ int enter_rootfs(struct ds_config *cfg, const char *user) { * inherited only via fork/exec from PID 1 - entering processes arrive via * setns() and are NOT children of init, so they inherit nothing. */ ds_log_silent = 1; - ds_seccomp_apply_minimal(cfg->privileged_mask); + ds_seccomp_apply_minimal(cfg->privileged_mask, cfg->userns_allowed); android_seccomp_setup( 0, cfg->block_nested_ns && !(cfg->privileged_mask & DS_PRIV_NOSEC), cfg->privileged_mask); @@ -1313,7 +1313,7 @@ int run_in_rootfs(struct ds_config *cfg, int argc, char **argv, /* Apply identical security hardening as internal_boot() and enter_rootfs(). * Same reasoning: run processes are not children of container PID 1. */ ds_log_silent = 1; - ds_seccomp_apply_minimal(cfg->privileged_mask); + ds_seccomp_apply_minimal(cfg->privileged_mask, cfg->userns_allowed); android_seccomp_setup( 0, cfg->block_nested_ns && !(cfg->privileged_mask & DS_PRIV_NOSEC), cfg->privileged_mask); @@ -1617,6 +1617,7 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { printf("VOLATILE_MODE=%d\n", cfg->volatile_mode); printf("FORCE_CGROUP_V1=%d\n", cfg->force_cgroupv1); printf("DEADLOCK_SHIELD=%d\n", cfg->block_nested_ns); + printf("USERNS_ALLOWED=%d\n", cfg->userns_allowed); printf("FOREGROUND_MODE=%d\n", cfg->foreground); printf("DNS_SERVERS=%s\n", cfg->dns_servers[0] ? cfg->dns_servers : ""); @@ -1838,7 +1839,13 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { feat_count++; } - /* 14. Privileged Mode */ + /* 14. User namespaces */ + if (cfg->userns_allowed) { + printf(" " C_RED "User namespaces:" C_RESET " enabled\n"); + feat_count++; + } + + /* 15. Privileged Mode */ if (cfg->privileged_mask > 0) { printf(" " C_RED "Privileged mode:" C_RESET " "); if (cfg->privileged_mask == DS_PRIV_FULL) { @@ -1870,19 +1877,19 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { feat_count++; } - /* 15. Bind Mounts */ + /* 16. Bind Mounts */ if (cfg->bind_count > 0) { printf(" Bind mounts: %d active\n", cfg->bind_count); feat_count++; } - /* 16. Custom Init */ + /* 17. Custom Init */ if (cfg->custom_init[0]) { printf(" " C_RED "Custom Init:" C_RESET " %s\n", cfg->custom_init); feat_count++; } - /* 17. Environment Variables */ + /* 18. Environment Variables */ if (cfg->env_var_count > 0) { printf(" Env variables: %d loaded\n", cfg->env_var_count); feat_count++; diff --git a/src/include/droidspace.h b/src/include/droidspace.h index dbf71a6b..feaaa56e 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -369,6 +369,7 @@ struct ds_config { int disable_ipv6; /* --disable-ipv6 */ int android_storage; /* --enable-android-storage */ int selinux_permissive; /* --selinux-permissive */ + int userns_allowed; /* --allow-userns */ int net_bridgeless; /* Probe result: no CONFIG_BRIDGE, use PTP NAT */ int reboot_cycle; /* 1 if we are in a reboot loop */ int force_cgroupv1; /* --force-cgroupv1: use v1 even if v2 is available */ @@ -570,7 +571,7 @@ void android_remount_data_suid(void); int android_setup_storage(const char *rootfs_path); int android_seccomp_setup(int is_systemd, int block_nested_ns, int privileged_mask); -int ds_seccomp_apply_minimal(int privileged_mask); +int ds_seccomp_apply_minimal(int privileged_mask, int userns_allowed); /* SELinux + Termux privilege helpers */ int get_selinux_context(const char *path, char *buf, size_t size); diff --git a/src/main.c b/src/main.c index e5484659..41884aae 100644 --- a/src/main.c +++ b/src/main.c @@ -105,7 +105,8 @@ void print_usage(void) { " --cpus=COUNT CPU limit (e.g. 1.5, 2)\n" " --pids-limit=N Max number of PIDs\n" " --privileged=TAGS Relax security: nomask, nocaps, noseccomp, " - "shared, unfiltered-dev, full\n\n"); + "shared, unfiltered-dev, full\n" + " --allow-userns Allow user namespaces\n\n"); printf( C_BOLD @@ -395,6 +396,7 @@ int main(int argc, char **argv) { {"disable-ipv6", no_argument, 0, 'I'}, {"enable-android-storage", no_argument, 0, 'S'}, {"selinux-permissive", no_argument, 0, 'P'}, + {"allow-userns", no_argument, 0, 279}, {"volatile", no_argument, 0, 'V'}, {"bind-mount", required_argument, 0, 'B'}, {"bind", required_argument, 0, 'B'}, @@ -700,6 +702,9 @@ int main(int argc, char **argv) { case 'P': cfg.selinux_permissive = 1; break; + case 279: + cfg.userns_allowed = 1; + break; case 'V': cfg.volatile_mode = 1; break; diff --git a/src/seccomp.c b/src/seccomp.c index db54a411..11034580 100644 --- a/src/seccomp.c +++ b/src/seccomp.c @@ -30,7 +30,7 @@ * Blocks direct host kernel takeover vectors (module loading, kexec). * Applied unconditionally to all kernels and all modes. */ -int ds_seccomp_apply_minimal(int privileged_mask) { +int ds_seccomp_apply_minimal(int privileged_mask, int userns_allowed) { /* noseccomp: skip everything, 32-bit binaries must work */ if (privileged_mask & DS_PRIV_NOSEC) return 0; @@ -99,36 +99,37 @@ int ds_seccomp_apply_minimal(int privileged_mask) { (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS); #endif + if (!userns_allowed) { #ifdef __NR_clone3 - /* 6. Block clone3 */ - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, - __NR_clone3, 0, 1); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (ENOSYS & SECCOMP_RET_DATA)); + /* 6. Block clone3 */ + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + __NR_clone3, 0, 1); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (ENOSYS & SECCOMP_RET_DATA)); #endif - /* 7. unshare(CLONE_NEWUSER) */ - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, - __NR_unshare, 0, 4); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])); - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, - 0x10000000, 0, 1); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); - - /* 8. clone(CLONE_NEWUSER) */ - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, - __NR_clone, 0, 3); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])); - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, - 0x10000000, 0, 1); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); - + /* 7. unshare(CLONE_NEWUSER) */ + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + __NR_unshare, 0, 4); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])); + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, + 0x10000000, 0, 1); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); + + /* 8. clone(CLONE_NEWUSER) */ + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + __NR_clone, 0, 3); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])); + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, + 0x10000000, 0, 1); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); + } /* * 9. CVE-2026-31431 ("Copy Fail") - mitigation layer 2. * @@ -153,7 +154,6 @@ int ds_seccomp_apply_minimal(int privileged_mask) { /* Reload nr for any rules that follow this block. */ filter[curr++] = (struct sock_filter)BPF_STMT( BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); - /* * 10. Block host clock modification syscalls. * From 997066f27be77b54577e7724aba8087b2287b66a Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Mon, 13 Jul 2026 13:44:56 +0530 Subject: [PATCH 103/211] net: add localhost DNAT to port-forward path; watch route_localnet Every --port rule now also gets OUTPUT DNAT (lo) + POSTROUTING MASQUERADE so 127.0.0.1:HOST reaches the container. Route monitor re-asserts route_localnet=1 each cycle, same pattern as ip_forward. Signed-off-by: ravindu644 --- src/include/droidspace.h | 4 ++ src/net/iptables.c | 139 ++++++++++++++++++++++++++++++++++++--- src/net/network.c | 20 ++++++ 3 files changed, 153 insertions(+), 10 deletions(-) diff --git a/src/include/droidspace.h b/src/include/droidspace.h index feaaa56e..481f7bb4 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -692,6 +692,10 @@ void ds_net_derive_handshake(pid_t init_pid, struct ds_config *cfg, struct ds_net_handshake *hs); void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid); void ds_net_start_route_monitor(void); +/* Marks route_localnet as required; route monitor re-asserts it every cycle + * (same pattern as ip_forward). Called once port-forward rules with localhost + * DNAT are installed. Sticky for process lifetime - never cleared. */ +void ds_net_mark_local_forward_active(void); /* Gateway self-heal: when the gateway container (re)boots, re-wire its LAN * cable to every running client that delegates to it, with no client restart. * Called from the gateway container's monitor on each boot cycle. */ diff --git a/src/net/iptables.c b/src/net/iptables.c index f1c87214..16048639 100644 --- a/src/net/iptables.c +++ b/src/net/iptables.c @@ -1256,7 +1256,7 @@ static int pf_state_remove(const char *container_ip) { to_dest, cont_port_str) != 5) continue; - /* Delete PREROUTING DNAT - mirror the variant that was inserted */ + /* Delete PREROUTING DNAT + FORWARD ACCEPT - mirror the variant inserted */ if (strcmp(variant, "addrtype") == 0) { char *del[] = {"iptables", "-t", "nat", "-D", "PREROUTING", "-p", proto, "-m", @@ -1264,21 +1264,47 @@ static int pf_state_remove(const char *container_ip) { host_port_str, "-j", "DNAT", "--to-destination", to_dest, NULL}; run_command_quiet(del); - } else { + + char cont_ip_buf[INET_ADDRSTRLEN]; + safe_strncpy(cont_ip_buf, container_ip, sizeof(cont_ip_buf)); + char *del_fwd[] = {"iptables", "-D", "FORWARD", "-p", + proto, "-d", cont_ip_buf, "--dport", + cont_port_str, "-j", "ACCEPT", NULL}; + run_command_quiet(del_fwd); + } else if (strcmp(variant, "basic") == 0) { char *del[] = {"iptables", "-t", "nat", "-D", "PREROUTING", "-p", proto, "--dport", host_port_str, "-j", "DNAT", "--to-destination", to_dest, NULL}; run_command_quiet(del); - } - /* Delete FORWARD ACCEPT */ - char cont_ip_buf[INET_ADDRSTRLEN]; - safe_strncpy(cont_ip_buf, container_ip, sizeof(cont_ip_buf)); - char *del_fwd[] = {"iptables", "-D", "FORWARD", "-p", - proto, "-d", cont_ip_buf, "--dport", - cont_port_str, "-j", "ACCEPT", NULL}; - run_command_quiet(del_fwd); + char cont_ip_buf[INET_ADDRSTRLEN]; + safe_strncpy(cont_ip_buf, container_ip, sizeof(cont_ip_buf)); + char *del_fwd[] = {"iptables", "-D", "FORWARD", "-p", + proto, "-d", cont_ip_buf, "--dport", + cont_port_str, "-j", "ACCEPT", NULL}; + run_command_quiet(del_fwd); + } else if (strcmp(variant, "local_out") == 0) { + /* Localhost OUTPUT DNAT (127.0.0.1:host_port -> to_dest) */ + char *del[] = {"iptables", "-t", + "nat", "-D", + "OUTPUT", "-p", + proto, "-o", + "lo", "--dport", + host_port_str, "-j", + "DNAT", "--to-destination", + to_dest, NULL}; + run_command_quiet(del); + } else if (strcmp(variant, "local_post") == 0) { + /* Localhost POSTROUTING MASQUERADE for return traffic */ + char cont_ip_buf[INET_ADDRSTRLEN]; + safe_strncpy(cont_ip_buf, container_ip, sizeof(cont_ip_buf)); + char *del[] = {"iptables", "-t", "nat", "-D", + "POSTROUTING", "-p", proto, "-d", + cont_ip_buf, "--dport", cont_port_str, "-j", + "MASQUERADE", NULL}; + run_command_quiet(del); + } } fclose(f); @@ -1339,6 +1365,14 @@ int ds_ipt_add_portforwards(struct ds_config *cfg, const char *container_ip) { * for every port forward entry. */ int use_addrtype = addrtype_available(); + /* Every port-forward rule below also gets a matching localhost DNAT + * (OUTPUT chain, -o lo) so 127.0.0.1:HOST reaches the container the same + * way LAN peers do. That requires route_localnet=1 (kernel drops loopback + * destined/sourced packets otherwise). Enable once here; the route monitor + * re-asserts it for the process lifetime (Android/netd resets it). */ + write_file("/proc/sys/net/ipv4/conf/all/route_localnet", "1"); + ds_net_mark_local_forward_active(); + for (int i = 0; i < cfg->port_forward_count; i++) { struct ds_port_forward *pf = &cfg->port_forwards[i]; @@ -1419,6 +1453,48 @@ int ds_ipt_add_portforwards(struct ds_config *cfg, const char *container_ip) { if (inserted_variant) pf_state_append(state_f, inserted_variant, pf->proto, host_port_str, to_dest, cont_port_str); + + /* Localhost forwarding: 127.0.0.1: -> : + * Same proto/single-port/range coverage as the LAN-facing rules above - + * host_port_str/cont_port_str/to_dest are already formatted for ranges + * (START:END / START-END) by pf_fmt_ports, so this works identically for + * tcp, udp, single ports, and symmetric/asymmetric-width ranges. + * + * OUTPUT DNAT rewrites packets a local process sends to lo:host_port so + * they target the container. POSTROUTING MASQUERADE rewrites the source + * back to the container's gateway IP so return traffic routes correctly + * (without it the container sees the real 127.0.0.1 source and replies + * are dropped/misrouted). */ + char *lo_dnat[] = { + "iptables", "-t", "nat", "-I", "OUTPUT", + "1", "-p", pf->proto, "-o", "lo", + "--dport", host_port_str, "-j", "DNAT", "--to-destination", + to_dest, NULL}; + int lo_ok = (run_command_log(lo_dnat) == 0); + if (!lo_ok) + ds_warn("portforward: localhost OUTPUT DNAT failed for port %s", + host_port_str); + + char *lo_masq[] = {"iptables", "-t", + "nat", "-I", + "POSTROUTING", "1", + "-p", pf->proto, + "-d", (char *)(uintptr_t)container_ip, + "--dport", cont_port_str, + "-j", "MASQUERADE", + NULL}; + int masq_ok = (run_command_quiet(lo_masq) == 0); + if (!masq_ok) + ds_warn("portforward: localhost POSTROUTING MASQUERADE failed for " + "port %s", + cont_port_str); + + if (lo_ok) + pf_state_append(state_f, "local_out", pf->proto, host_port_str, to_dest, + cont_port_str); + if (masq_ok) + pf_state_append(state_f, "local_post", pf->proto, host_port_str, to_dest, + cont_port_str); } if (state_f) @@ -1507,6 +1583,26 @@ int ds_ipt_remove_portforwards(struct ds_config *cfg) { "ACCEPT", NULL}; run_command_quiet(del_fwd); + + /* Localhost OUTPUT DNAT */ + char *del_lo[] = {"iptables", "-t", + "nat", "-D", + "OUTPUT", "-p", + pf->proto, "-o", + "lo", "--dport", + host_port_str, "-j", + "DNAT", "--to-destination", + to_dest, NULL}; + run_command_quiet(del_lo); + + /* Localhost POSTROUTING MASQUERADE */ + char *del_masq[] = { + "iptables", "-t", "nat", + "-D", "POSTROUTING", "-p", + pf->proto, "-d", (char *)(uintptr_t)container_ip, + "--dport", cont_port_str, "-j", + "MASQUERADE", NULL}; + run_command_quiet(del_masq); } /* Pass 3: iptables-save shell sweep (fallback) @@ -1538,6 +1634,29 @@ int ds_ipt_remove_portforwards(struct ds_config *cfg) { container_ip); char *sh_fwd[] = {"sh", "-c", cmd, NULL}; run_command_quiet(sh_fwd); + + /* Remove localhost OUTPUT DNAT rules whose --to-destination targets + * this IP (127.0.0.1:HOST -> container_ip:PORT). */ + snprintf(cmd, sizeof(cmd), + "iptables-save -t nat | grep ' -A OUTPUT ' | " + "grep -- '-o lo ' | grep -- '--to-destination %s:' | " + "sed 's/ -A / -D /' | " + "while IFS= read -r rule; do iptables -t nat $rule; done", + container_ip); + char *sh_lo[] = {"sh", "-c", cmd, NULL}; + run_command_quiet(sh_lo); + + /* Remove localhost POSTROUTING MASQUERADE rules whose -d + --dport + * target this IP. Filtered on --dport too so the general uplink + * MASQUERADE rule (whole subnet, no -d/--dport) is never touched. */ + snprintf(cmd, sizeof(cmd), + "iptables-save -t nat | grep ' -A POSTROUTING ' | " + "grep -- ' -d %s/32 ' | grep -- '--dport' | " + "grep ' -j MASQUERADE' | sed 's/ -A / -D /' | " + "while IFS= read -r rule; do iptables -t nat $rule; done", + container_ip); + char *sh_masq[] = {"sh", "-c", cmd, NULL}; + run_command_quiet(sh_masq); } return 0; diff --git a/src/net/network.c b/src/net/network.c index 7cdf05e1..0de39859 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -254,6 +254,10 @@ static int g_route_monitor_sock = -1; static volatile sig_atomic_t g_stop_monitor = 0; static pthread_t g_route_monitor_tid; static int g_route_monitor_started = 0; /* guarded by g_gw_mutex */ +/* Set once any container installs localhost port-forward DNAT rules. + * Sticky - never cleared, since route_localnet=1 is harmless to leave on + * and other containers may still depend on it. */ +static volatile sig_atomic_t g_local_forward_active = 0; /* User-pinned upstream interfaces (--upstream). When non-empty, the uplink is * resolved ONLY from this list (priority order, literals + wildcards) and all @@ -1944,6 +1948,20 @@ static void *route_monitor_loop(void *arg) { } } + /* Same treatment for route_localnet - required for localhost port-forward + * DNAT (127.0.0.1:HOST -> container). Android/netd resets this the same + * way it resets ip_forward. */ + if (g_local_forward_active) { + char val[4] = {0}; + if (read_file("/proc/sys/net/ipv4/conf/all/route_localnet", val, + sizeof(val)) > 0 && + val[0] == '0') { + ds_log("[NET] Route monitor: route_localnet was disabled - " + "re-enabling..."); + write_file("/proc/sys/net/ipv4/conf/all/route_localnet", "1\n"); + } + } + /* 1.5-second heartbeat: aggressively re-asserts ip_forward and covers * devices with broken netlink notifications. */ int pr = poll(&pfd, 1, 1500); @@ -2069,6 +2087,8 @@ void ds_net_stop_route_monitor(void) { } } +void ds_net_mark_local_forward_active(void) { g_local_forward_active = 1; } + void ds_net_start_route_monitor(void) { if (!is_android()) return; From 03d2e099a415e9151d86be51434a809be6e7e56f Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 15 Jul 2026 08:44:47 +0200 Subject: [PATCH 104/211] Translated using Weblate (Chinese (Simplified Han script)) (#235) Currently translated at 99.8% (511 of 512 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (508 of 508 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hans/ Translation: Droidspaces/Strings Co-authored-by: MonkiFantasy --- Android/app/src/main/res/values-zh-rCN/strings.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index 90da5ca4..2d786a33 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -119,7 +119,7 @@ 重新安装二进制文件和模块 复制 未知 - 維護者 + 维护者 主机名:%1$s 启动 停止 @@ -508,4 +508,7 @@ 磁盘占用 已使用 %1$s / %2$s 计算中… + 不支持:内核未启用 CONFIG_USER_NS 配置项 + 允许用户命名空间 + 用户命名空间 From a512b181116c0aee70e1a10e10817aaab0390ad5 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 15 Jul 2026 13:06:08 +0530 Subject: [PATCH 105/211] app: add missing userns toggle to container installer + summary Signed-off-by: ravindu644 --- .../ui/navigation/DroidspacesNavigation.kt | 5 +-- .../app/ui/screen/ContainerConfigScreen.kt | 31 +++++++++++++++---- .../ui/screen/InstallationSummaryScreen.kt | 3 +- .../ContainerInstallationViewModel.kt | 7 +++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index e9cc2f9a..0df66ee8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -317,6 +317,7 @@ fun DroidspacesNavigation( initialVirglExtraFlags = viewModel.virglExtraFlags, initialEnablePulseaudio = viewModel.enablePulseaudio, initialSelinuxPermissive = viewModel.selinuxPermissive, + initialAllowUserns = viewModel.allowUserns, initialVolatileMode = viewModel.volatileMode, initialBindMounts = viewModel.bindMounts, initialDnsServers = viewModel.dnsServers, @@ -335,8 +336,8 @@ fun DroidspacesNavigation( initialGatewayBridge = viewModel.gatewayBridge, containerName = viewModel.containerName, installedContainers = sharedContainerViewModel.containerList, - onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge -> - viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, upstreamInterfaces, portForwards, forceCgroupv1, blockNestedNs, privileged, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) + onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, allowUserns, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge -> + viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, allowUserns, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, upstreamInterfaces, portForwards, forceCgroupv1, blockNestedNs, privileged, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) navController.navigate(Screen.SparseImageConfig.route) }, onBack = { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index 638ace6a..854095ce 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -66,6 +66,7 @@ fun ContainerConfigScreen( initialVirglExtraFlags: String = "", initialEnablePulseaudio: Boolean = false, initialSelinuxPermissive: Boolean = false, + initialAllowUserns: Boolean = false, initialVolatileMode: Boolean = false, initialBindMounts: List = emptyList(), initialDnsServers: String = "", @@ -96,6 +97,7 @@ fun ContainerConfigScreen( virglExtraFlags: String, enablePulseaudio: Boolean, selinuxPermissive: Boolean, + allowUserns: Boolean, volatileMode: Boolean, bindMounts: List, dnsServers: String, @@ -126,6 +128,7 @@ fun ContainerConfigScreen( var virglExtraFlags by remember { mutableStateOf(initialVirglExtraFlags) } var enablePulseaudio by remember { mutableStateOf(initialEnablePulseaudio) } var selinuxPermissive by remember { mutableStateOf(initialSelinuxPermissive) } + var allowUserns by remember { mutableStateOf(initialAllowUserns) } var volatileMode by remember { mutableStateOf(initialVolatileMode) } var bindMounts by remember { mutableStateOf(initialBindMounts) } var dnsServers by remember { mutableStateOf(initialDnsServers) } @@ -329,7 +332,7 @@ fun ContainerConfigScreen( .clickable( enabled = canProceed, onClick = { - onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) + onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, allowUserns, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) }, indication = androidx.compose.material.ripple.rememberRipple(bounded = true), interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } @@ -703,6 +706,27 @@ fun ContainerConfigScreen( onCheckedChange = { selinuxPermissive = it } ) + val isSeccompDisabled = privileged.contains("noseccomp") || privileged.contains("full") + val usernsSupported = remember { java.io.File("/proc/self/setgroups").exists() } + + LaunchedEffect(isSeccompDisabled, usernsSupported) { + if (isSeccompDisabled) blockNestedNs = false + if (isSeccompDisabled && usernsSupported) allowUserns = true + if (!usernsSupported) allowUserns = false + } + + ToggleCard( + icon = Icons.Default.Groups, + title = context.getString(R.string.allow_userns), + description = if (usernsSupported) + context.getString(R.string.allow_userns_description) + else + context.getString(R.string.allow_userns_description_not_supported), + checked = allowUserns, + onCheckedChange = { allowUserns = it }, + enabled = !isSeccompDisabled && usernsSupported + ) + ToggleCard( icon = Icons.Default.AutoDelete, title = context.getString(R.string.volatile_mode), @@ -719,11 +743,6 @@ fun ContainerConfigScreen( onCheckedChange = { forceCgroupv1 = it } ) - val isSeccompDisabled = privileged.contains("noseccomp") || privileged.contains("full") - LaunchedEffect(isSeccompDisabled) { - if (isSeccompDisabled) blockNestedNs = false - } - ToggleCard( icon = Icons.Default.GppBad, title = context.getString(R.string.manual_deadlock_shield), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index 94b91089..c5acf7c0 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -171,6 +171,7 @@ fun InstallationSummaryScreen( if (config.enableVirgl) SummaryItem(stringResource(R.string.enable_virgl), stringResource(R.string.enabled_legend), Icons.Default.Layers) if (config.enablePulseaudio) SummaryItem(stringResource(R.string.enable_pulseaudio), stringResource(R.string.enabled_legend), Icons.AutoMirrored.Filled.VolumeUp) if (config.selinuxPermissive) SummaryItem(stringResource(R.string.selinux_permissive), stringResource(R.string.enabled_legend), Icons.Default.Security) + if (config.allowUserns) SummaryItem(stringResource(R.string.allow_userns), stringResource(R.string.enabled_legend), Icons.Default.Groups) if (config.volatileMode) SummaryItem(stringResource(R.string.volatile_mode), stringResource(R.string.enabled_legend), Icons.Default.AutoDelete) if (config.runAtBoot) SummaryItem(stringResource(R.string.run_at_boot), stringResource(R.string.enabled_legend), Icons.Default.PowerSettingsNew) if (config.forceCgroupv1) SummaryItem(stringResource(R.string.force_cgroupv1), stringResource(R.string.enabled_legend), Icons.Default.Layers) @@ -207,7 +208,7 @@ fun InstallationSummaryScreen( if (!config.enableAndroidStorage && !config.enableHwAccess && !config.enableGpuMode && !config.selinuxPermissive && - !config.volatileMode && config.bindMounts.isEmpty() && + !config.allowUserns && !config.volatileMode && config.bindMounts.isEmpty() && !config.runAtBoot && !config.disableIPv6 && !config.enableTermuxX11 && !config.enableVirgl && !config.enablePulseaudio && !config.forceCgroupv1 && !config.blockNestedNs && diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt index 9165f6f3..d54ec971 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt @@ -57,6 +57,9 @@ class ContainerInstallationViewModel : ViewModel() { var selinuxPermissive: Boolean by mutableStateOf(false) private set + var allowUserns: Boolean by mutableStateOf(false) + private set + var volatileMode: Boolean by mutableStateOf(false) private set @@ -137,6 +140,7 @@ class ContainerInstallationViewModel : ViewModel() { virglExtraFlags: String, enablePulseaudio: Boolean, selinuxPermissive: Boolean, + allowUserns: Boolean, volatileMode: Boolean, bindMounts: List, dnsServers: String, @@ -165,6 +169,7 @@ class ContainerInstallationViewModel : ViewModel() { this.virglExtraFlags = virglExtraFlags this.enablePulseaudio = enablePulseaudio this.selinuxPermissive = selinuxPermissive + this.allowUserns = allowUserns this.volatileMode = volatileMode this.bindMounts = bindMounts this.dnsServers = dnsServers @@ -206,6 +211,7 @@ class ContainerInstallationViewModel : ViewModel() { virglExtraFlags = virglExtraFlags, enablePulseaudio = enablePulseaudio, selinuxPermissive = selinuxPermissive, + allowUserns = allowUserns, volatileMode = volatileMode, bindMounts = bindMounts, dnsServers = dnsServers, @@ -243,6 +249,7 @@ class ContainerInstallationViewModel : ViewModel() { virglExtraFlags = "" enablePulseaudio = false selinuxPermissive = false + allowUserns = false volatileMode = false bindMounts = emptyList() dnsServers = "" From 6e4512379b1b2f07a1f3f416d4de09ff923b1706 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 15 Jul 2026 18:17:18 +0530 Subject: [PATCH 106/211] net: dedup upstream interfaces on parse fixes duplicate entries on every container restart Signed-off-by: ravindu644 --- src/utils.c | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/utils.c b/src/utils.c index dfe77e80..f6b19d71 100644 --- a/src/utils.c +++ b/src/utils.c @@ -32,11 +32,12 @@ void safe_strncpy(char *dst, const char *src, size_t size) { /* Append comma-separated interface names/wildcards from `val` to ifaces[], * trimming surrounding whitespace and skipping empty or over-long (>= IFNAMSIZ) - * tokens. Duplicates are kept - a pinned list is priority-ordered and - * first-match-wins, so a repeat is a harmless re-check. *count is advanced up - * to `max`; the return value is the number of tokens dropped because the array - * was already full (0 = all fit), letting the caller warn or fail as it likes. - */ + * tokens. Exact duplicates (already present in ifaces[0..*count)) are + * silently skipped, mirroring the bind-mount/port-forward de-dup so re-parsing + * the same list (e.g. on container restart) is a no-op instead of growing the + * array. *count is advanced up to `max`; the return value is the number of + * tokens dropped because the array was already full (0 = all fit), letting + * the caller warn or fail as it likes. */ int ds_parse_iface_csv(const char *val, char ifaces[][IFNAMSIZ], int *count, int max) { char copy[1024]; @@ -52,6 +53,15 @@ int ds_parse_iface_csv(const char *val, char ifaces[][IFNAMSIZ], int *count, *end-- = '\0'; if (!tok[0] || strlen(tok) >= IFNAMSIZ) continue; + int dup = 0; + for (int i = 0; i < *count; i++) { + if (strcmp(ifaces[i], tok) == 0) { + dup = 1; + break; + } + } + if (dup) + continue; if (*count >= max) { dropped++; continue; From 2a782aa44554a6dc92629e081f657e83eb7eea5f Mon Sep 17 00:00:00 2001 From: VizXtreme Date: Wed, 15 Jul 2026 22:15:42 +0530 Subject: [PATCH 107/211] UI Refinement (#236) * ui: Customize dropdown menu styling to match Material card design * ci: Add test builds vizx workflow * ui: Refactor components for localization, standard borders, and double-toggle fixes * ci: Add ui branch to trigger list in ci_test.yml * ui: Wrap dropdown popup in custom MaterialTheme to fix corner radius clipping * ci: Optimize ci_test.yml for faster builds * ui: Fix dropdown menu not respecting amoled black mode * fix: Add missing Color import in DsDropdown * ui: Style 3-dot menus in init systems to match DropdownMenu * ui: Fix PullToRefresh teleport animation on release * ui: Fix corner radius mismatch in ContainerCard unified control pill * ui: Revert PullToRefresh animation fix * ui: Revamp settings page with grouped cards and separators, reduce About Dialog button spacing * ui: Fix syntax error in SettingsScreen bug report card * ui: Fix overlapping separators and match Settings card background with ContainerCard * ui: Add Debugging header and wrap About dialog sections in grouped cards * ui: Update Dropdown menu background to correctly adapt to light, dark, and AMOLED modes * ui: Update Init system dropdown menus to properly adapt to Light/Dark/AMOLED backgrounds * Delete .github/workflows/ci_test.yml --- .../app/ui/component/AccentColorPicker.kt | 4 +- .../app/ui/component/ContainerCard.kt | 2 +- .../app/ui/component/DsDropdown.kt | 79 ++-- .../droidspaces/app/ui/component/HelpCard.kt | 22 +- .../app/ui/component/SettingsRowCard.kt | 2 +- .../app/ui/component/SwitchItem.kt | 3 +- .../app/ui/component/ToggleCard.kt | 4 +- .../droidspaces/app/ui/screen/OpenRCScreen.kt | 33 +- .../droidspaces/app/ui/screen/ProcdScreen.kt | 47 ++- .../app/ui/screen/SettingsScreen.kt | 348 +++++++++++------- .../app/ui/screen/SystemdScreen.kt | 29 +- Android/app/src/main/res/values/strings.xml | 2 + 12 files changed, 367 insertions(+), 208 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt index 62e90968..1868fbda 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt @@ -9,8 +9,10 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.droidspaces.app.R import com.droidspaces.app.ui.theme.ThemePalette /** @@ -37,7 +39,7 @@ fun AccentColorPicker( }, headlineContent = { Text( - text = "Accent Color", + text = stringResource(R.string.accent_color), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt index dab97359..a349363c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt @@ -199,7 +199,7 @@ fun ContainerCard( .fillMaxWidth() .padding(horizontal = 4.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, // Depth for secondary actions - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(20.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) ) { Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt index 74adf9b6..bde05567 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt @@ -1,5 +1,7 @@ package com.droidspaces.app.ui.component +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -8,6 +10,9 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight @@ -30,9 +35,9 @@ fun DsDropdown( var expanded by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current - val fieldShape = RoundedCornerShape(16.dp) + val fieldShape = RoundedCornerShape(20.dp) val fieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) @@ -63,32 +68,52 @@ fun DsDropdown( .menuAnchor() .fillMaxWidth() ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { - expanded = false - focusManager.clearFocus() - } + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = dropdownColor, + surfaceContainer = dropdownColor, + surfaceTint = Color.Transparent + ), + shapes = MaterialTheme.shapes.copy( + extraSmall = RoundedCornerShape(20.dp) + ) ) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(displayName(option), fontWeight = FontWeight.Medium) }, - onClick = { - onSelect(option) - expanded = false - focusManager.clearFocus() - }, - leadingIcon = if (option == selected) { - { - Icon( - Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } else null - ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + focusManager.clearFocus() + }, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + shape = RoundedCornerShape(20.dp) + ) + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(displayName(option), fontWeight = FontWeight.Medium) }, + onClick = { + onSelect(option) + expanded = false + focusManager.clearFocus() + }, + leadingIcon = if (option == selected) { + { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } else null + ) + } } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/HelpCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/HelpCard.kt index ac53ca4f..ebcbb466 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/HelpCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/HelpCard.kt @@ -2,14 +2,10 @@ package com.droidspaces.app.ui.component import android.content.Intent import android.net.Uri -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -26,20 +22,12 @@ fun HelpCard( val url = context.getString(R.string.help_card_url) val cardShape = RoundedCornerShape(20.dp) - val interactionSource = remember { MutableInteractionSource() } - Surface( - modifier = modifier - .fillMaxWidth() - .clip(cardShape) - .clickable( - interactionSource = interactionSource, - indication = rememberRipple(bounded = true), - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - context.startActivity(intent) - } - ), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + }, + modifier = modifier.fillMaxWidth(), shape = cardShape, color = MaterialTheme.colorScheme.surfaceContainer, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt index 2eaaeb65..432ebc68 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt @@ -33,7 +33,7 @@ fun SettingsRowCard( enabled = enabled, shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) ) { Row( modifier = Modifier diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/SwitchItem.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/SwitchItem.kt index b94134ff..f0948293 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/SwitchItem.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/SwitchItem.kt @@ -24,6 +24,7 @@ fun SwitchItem( val stateAlpha = Modifier.alpha(if (enabled) 1f else 0.5f) ListItem( + colors = ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent), modifier = Modifier .toggleable( value = checked, @@ -52,7 +53,7 @@ fun SwitchItem( Switch( checked = checked, enabled = enabled, - onCheckedChange = onCheckedChange, + onCheckedChange = null, interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.onPrimary, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt index 8ba8a3c6..d18a11e6 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt @@ -34,7 +34,7 @@ fun ToggleCard( enabled = enabled, shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) ) { Row( modifier = Modifier @@ -81,7 +81,7 @@ fun ToggleCard( } Switch( checked = checked, - onCheckedChange = onCheckedChange, + onCheckedChange = null, enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.onPrimary, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt index aa856534..248eaacf 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -401,12 +402,34 @@ private fun OpenRCServiceCard( ) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - DropdownMenuItem( - text = { Text(context.getString(R.string.restart_service)) }, - leadingIcon = { Icon(Icons.Default.Refresh, null) }, - onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerOpenRCManager.restartService(containerName, service.name) } } + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = dropdownColor, + surfaceContainer = dropdownColor, + surfaceTint = Color.Transparent + ), + shapes = MaterialTheme.shapes.copy( + extraSmall = RoundedCornerShape(20.dp) ) + ) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + shape = RoundedCornerShape(20.dp) + ) + ) { + DropdownMenuItem( + text = { Text(context.getString(R.string.restart_service)) }, + leadingIcon = { Icon(Icons.Default.Refresh, null) }, + onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerOpenRCManager.restartService(containerName, service.name) } } + ) + } } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt index 2d5be973..0c30b6d3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -404,19 +405,41 @@ private fun ProcdServiceCard( ) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - DropdownMenuItem( - text = { Text(context.getString(R.string.restart_service)) }, - leadingIcon = { Icon(Icons.Default.Refresh, null) }, - onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerProcdManager.restartService(containerName, service.name) } } - ) - DropdownMenuItem( - text = { Text(context.getString(R.string.reload_service)) }, - leadingIcon = { Icon(Icons.Default.Refresh, null) }, - onClick = { showMenu = false; onAction(context.getString(R.string.reload_service)) { ContainerProcdManager.reloadService(containerName, service.name) } } - ) + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = dropdownColor, + surfaceContainer = dropdownColor, + surfaceTint = Color.Transparent + ), + shapes = MaterialTheme.shapes.copy( + extraSmall = RoundedCornerShape(20.dp) + ) + ) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + shape = RoundedCornerShape(20.dp) + ) + ) { + DropdownMenuItem( + text = { Text(context.getString(R.string.restart_service)) }, + leadingIcon = { Icon(Icons.Default.Refresh, null) }, + onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerProcdManager.restartService(containerName, service.name) } } + ) + DropdownMenuItem( + text = { Text(context.getString(R.string.reload_service)) }, + leadingIcon = { Icon(Icons.Default.Refresh, null) }, + onClick = { showMenu = false; onAction(context.getString(R.string.reload_service)) { ContainerProcdManager.reloadService(containerName, service.name) } } + ) + } + } } - } } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt index fb78bce1..786c7e75 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt @@ -163,13 +163,20 @@ fun SettingsScreen( // Backend Reinstallation Section Text( text = context.getString(R.string.backend_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp, top = 8.dp), color = MaterialTheme.colorScheme.primary ) - ListItem( + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + color = if (darkTheme) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Build, @@ -215,6 +222,8 @@ fun SettingsScreen( ) ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + // Daemon Mode Toggle SwitchItem( icon = Icons.Default.SettingsBackupRestore, @@ -227,6 +236,8 @@ fun SettingsScreen( } ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + val isBackendAvailable = appStateViewModel.isBackendAvailable SwitchItem( icon = Icons.Default.Link, @@ -246,8 +257,10 @@ fun SettingsScreen( } ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + // Auto Boot Priority - clickable to navigate to the boot-order screen - ListItem( + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.LowPriority, @@ -273,23 +286,34 @@ fun SettingsScreen( modifier = Modifier.clickable { onNavigateToAutoBootPriority() } ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + // Requirements Card - clickable to navigate to requirements page RequirementsCard( onNavigateToRequirements = onNavigateToRequirements ) + } + } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Spacer(modifier = Modifier.height(8.dp)) // Theme Section Text( text = context.getString(R.string.appearance_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp, top = 8.dp), color = MaterialTheme.colorScheme.primary ) - // Language Picker + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + color = if (darkTheme) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + // Language Picker val currentLanguageDisplay = remember(currentAppLocale) { val currentLanguageCode = LocaleHelper.getCurrentLanguageCode() if (currentLanguageCode == "system") { @@ -302,7 +326,7 @@ fun SettingsScreen( } } - ListItem( + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Translate, @@ -324,6 +348,8 @@ fun SettingsScreen( } ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + // Follow System Theme SwitchItem( icon = Icons.Default.BrightnessAuto, @@ -337,6 +363,7 @@ fun SettingsScreen( // Dark Theme (only shown when not following system) if (!followSystemTheme) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) SwitchItem( icon = Icons.Default.DarkMode, title = context.getString(R.string.dark_theme), @@ -350,6 +377,7 @@ fun SettingsScreen( // AMOLED Mode (shown when followSystemTheme is true OR manual darkTheme is true) if (followSystemTheme || darkTheme) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) SwitchItem( icon = Icons.Default.RadioButtonUnchecked, title = context.getString(R.string.amoled_mode), @@ -365,6 +393,7 @@ fun SettingsScreen( // Use Dynamic Color (Monet theming) - Only show on Android 12+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) SwitchItem( icon = Icons.Default.ColorLens, title = context.getString(R.string.dynamic_color), @@ -380,6 +409,7 @@ fun SettingsScreen( // Accent Color Picker - show when dynamic color is off, // or always show on devices below Android 12 (no dynamic color support) if (!useDynamicColor || android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) AccentColorPicker( selectedPalette = themeState.themePalette, isDarkTheme = darkTheme, @@ -388,11 +418,28 @@ fun SettingsScreen( } ) } + } + } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - // Bug Report Section - ListItem( + // Debugging Section + Text( + text = context.getString(R.string.debugging_section), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp, top = 8.dp), + color = MaterialTheme.colorScheme.primary + ) + + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + + shape = RoundedCornerShape(24.dp), + color = if (darkTheme) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.BugReport, @@ -436,20 +483,27 @@ fun SettingsScreen( Modifier } ) - ) + ) + } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Spacer(modifier = Modifier.height(8.dp)) // About Section Text( text = context.getString(R.string.about_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp, top = 8.dp), color = MaterialTheme.colorScheme.primary ) - ListItem( + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + color = if (darkTheme) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Info, @@ -469,9 +523,10 @@ fun SettingsScreen( modifier = Modifier.clickable { showAboutDialog = true } - ) + ) + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) } } @@ -523,7 +578,7 @@ private fun AboutDialog(onDismiss: () -> Unit) { Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) + .padding(top = 24.dp, start = 24.dp, end = 24.dp, bottom = 8.dp) ) { // Title Text( @@ -545,120 +600,126 @@ private fun AboutDialog(onDismiss: () -> Unit) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(4.dp)) Text( text = context.getString(R.string.developers), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary ) - // Developer 1 - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/ravindu644")) - context.startActivity(intent) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = context.getString(R.string.developer_ravindu644), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = context.getString(R.string.developer_ravindu644_role), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column { + // Developer 1 + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/ravindu644")) + context.startActivity(intent) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = context.getString(R.string.developer_ravindu644), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = context.getString(R.string.developer_ravindu644_role), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = context.getString(R.string.github), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + // Telegram channel + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/Droidspaces")) + context.startActivity(intent) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_telegram), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = context.getString(R.string.telegram_channel), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = context.getString(R.string.telegram_channel), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + // Source Code row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/ravindu644/Droidspaces-OSS")) + context.startActivity(intent) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = context.getString(R.string.source_code), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = context.getString(R.string.source_code), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } } - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = context.getString(R.string.github), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - Spacer(modifier = Modifier.height(4.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(4.dp)) - // Telegram channel - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/Droidspaces")) - context.startActivity(intent) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_telegram), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = context.getString(R.string.telegram_channel), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f) - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = context.getString(R.string.telegram_channel), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - Spacer(modifier = Modifier.height(4.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(4.dp)) - // Source Code row - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/ravindu644/Droidspaces-OSS")) - context.startActivity(intent) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Code, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = context.getString(R.string.source_code), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f) - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = context.getString(R.string.source_code), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) } - Spacer(modifier = Modifier.height(4.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(4.dp)) // Contributors Text( @@ -668,19 +729,30 @@ private fun AboutDialog(onDismiss: () -> Unit) { color = MaterialTheme.colorScheme.primary ) val contributors = remember { ContributorManager.load(context) } - contributors.forEach { contributor -> - ContributorItem( - contributor = contributor, - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(contributor.githubUrl)) - context.startActivity(intent) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + contributors.forEachIndexed { index, contributor -> + ContributorItem( + contributor = contributor, + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(contributor.githubUrl)) + context.startActivity(intent) + } + ) + if (index < contributors.size - 1) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + } } - ) + } } } // OK Button - Spacer(modifier = Modifier.height(8.dp)) TextButton( onClick = onDismiss, modifier = Modifier.align(Alignment.End) @@ -702,7 +774,7 @@ private fun ContributorItem(contributor: Contributor, onClick: () -> Unit) { }.getOrNull() else null } Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -756,7 +828,7 @@ private fun RequirementsCard( ) { val context = LocalContext.current - ListItem( + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Code, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt index f37037f0..40a0ccbc 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -377,9 +378,31 @@ private fun ServiceCard( } Box { Surface(onClick = { showMenu = true }, modifier = Modifier.size(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - if (service.isRunning) { DropdownMenuItem(text = { Text(context.getString(R.string.restart_service)) }, leadingIcon = { Icon(Icons.Default.Refresh, null) }, onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerSystemdManager.restartService(containerName, service.name) } }) } - DropdownMenuItem(text = { Text(context.getString(R.string.mask_service)) }, leadingIcon = { Icon(Icons.Default.Lock, null) }, onClick = { showMenu = false; onAction(context.getString(R.string.mask_service)) { ContainerSystemdManager.maskService(containerName, service.name) } }) + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = dropdownColor, + surfaceContainer = dropdownColor, + surfaceTint = Color.Transparent + ), + shapes = MaterialTheme.shapes.copy( + extraSmall = RoundedCornerShape(20.dp) + ) + ) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + shape = RoundedCornerShape(20.dp) + ) + ) { + if (service.isRunning) { DropdownMenuItem(text = { Text(context.getString(R.string.restart_service)) }, leadingIcon = { Icon(Icons.Default.Refresh, null) }, onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerSystemdManager.restartService(containerName, service.name) } }) } + DropdownMenuItem(text = { Text(context.getString(R.string.mask_service)) }, leadingIcon = { Icon(Icons.Default.Lock, null) }, onClick = { showMenu = false; onAction(context.getString(R.string.mask_service)) { ContainerSystemdManager.maskService(containerName, service.name) } }) + } } } } diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index bd68d63c..c9f28bc5 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -86,6 +86,7 @@ Pure black background for OLED displays Apply System\'s Default Color Palette Use Android 12+ Monet theming (Material You) + Accent Color Daemon Mode Run Droidspaces as a persistent system-level daemon to bypass seccomp blocks from Magisk/APatch, GrapheneOS, etc. This enables high-privilege operations by fetching and running commands from userspace (Reboot required). Integrate Droidspaces to the system path @@ -592,4 +593,5 @@ URL cannot be empty URL must start with https:// No custom repositories subscribed yet. + Debugging From e9b284240bd172bfc902f0645795468d567de081 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 16 Jul 2026 10:05:08 +0530 Subject: [PATCH 108/211] app: fix: sync About dialog width to match Resize dialog Signed-off-by: ravindu644 --- .../main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt index 786c7e75..dea62e35 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt @@ -568,9 +568,9 @@ private fun AboutDialog(onDismiss: () -> Unit) { ) { Surface( modifier = Modifier - .fillMaxWidth(0.9f) + .fillMaxWidth() .fillMaxHeight(0.85f) - .padding(16.dp), + .padding(horizontal = 24.dp), shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceContainer, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) From c3fd619ba408a2acdb3bf9233c8d5ea920e8450c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 16 Jul 2026 10:12:39 +0530 Subject: [PATCH 109/211] app: bold container name + type-to-confirm gate on uninstall dialog Signed-off-by: ravindu644 --- .../app/ui/screen/ContainersScreen.kt | 41 +++++++++++++++++-- Android/app/src/main/res/values/strings.xml | 1 + 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index e25c3733..4dfdc45d 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -18,6 +18,9 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.material3.SnackbarHost @@ -965,6 +968,8 @@ private fun UninstallConfirmationDialog( ) { val context = LocalContext.current val dialogShape = RoundedCornerShape(24.dp) + var confirmText by remember { mutableStateOf("") } + val isConfirmed = confirmText == containerName Dialog( onDismissRequest = onDismiss, @@ -995,10 +1000,38 @@ private fun UninstallConfirmationDialog( ) } Text( - text = context.getString(R.string.uninstall_container_message, containerName), + text = buildAnnotatedString { + val template = context.getString(R.string.uninstall_container_message) + val parts = template.split("%1\$s") + append(parts.getOrElse(0) { "" }) + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(containerName) } + append(parts.getOrElse(1) { "" }) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = context.getString(R.string.type_container_name_to_confirm), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + OutlinedTextField( + value = confirmText, + onValueChange = { confirmText = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(containerName) }, + singleLine = true, + isError = confirmText.isNotEmpty() && !isConfirmed, + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + ) + ) + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) @@ -1015,9 +1048,9 @@ private fun UninstallConfirmationDialog( } } Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onConfirm), + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(enabled = isConfirmed, onClick = onConfirm), shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.error, + color = if (isConfirmed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { @@ -1025,7 +1058,7 @@ private fun UninstallConfirmationDialog( context.getString(R.string.uninstall), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onError + color = if (isConfirmed) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } } diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index c9f28bc5..63fa7aa4 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -124,6 +124,7 @@ Uninstall Container? This will permanently delete container \"%1$s\" and all its data. This action cannot be undone.\n\nIf the container is running, it will be stopped first. Uninstall + Type the container name exactly to confirm: Cancel View logs Edit Container Configuration From fe509afc7a0df5641ca6740b9304ad36dbedc2c9 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Thu, 16 Jul 2026 10:42:39 +0530 Subject: [PATCH 110/211] ci: cache toolchains, fix author/event, add sha link Signed-off-by: ravindu644 --- .github/workflows/ci.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b80b3dbf..f0e0e325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,15 @@ jobs: sudo apt update sudo apt install -y build-essential wget tree zstd + - name: Cache Toolchains + id: cache-toolchains + uses: actions/cache@v4 + with: + path: ~/toolchains + key: toolchains-compilers-25843896720 + - name: Download Toolchains + if: steps.cache-toolchains.outputs.cache-hit != 'true' run: | mkdir -p $HOME/toolchains cd $HOME/toolchains @@ -118,16 +126,27 @@ jobs: - name: Generate Telegram Message if: github.event_name != 'pull_request' run: | - EVENT_TYPE="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true' && 'Official Release' || 'Git Push' }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "${{ github.event.inputs.create_release }}" = "true" ]; then + EVENT_TYPE="Official Release" + else + EVENT_TYPE="Manual Trigger" + fi + else + EVENT_TYPE="Git Push" + fi COMMIT_MSG=$(git log -1 --pretty=format:%s) + COMMIT_SHA=$(git log -1 --pretty=format:%h) + COMMIT_AUTHOR=$(git log -1 --pretty=format:%an) { echo "TELEGRAM_MESSAGE< Date: Thu, 16 Jul 2026 09:23:51 +0200 Subject: [PATCH 111/211] Translated using Weblate (Chinese (Traditional Han script)) (#237) Currently translated at 100.0% (512 of 512 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (512 of 512 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hant/ Translation: Droidspaces/Strings Co-authored-by: Jung Chi Wang Co-authored-by: MonkiFantasy --- Android/app/src/main/res/values-zh-rCN/strings.xml | 1 + Android/app/src/main/res/values-zh-rTW/strings.xml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index 2d786a33..b0b5c15b 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -511,4 +511,5 @@ 不支持:内核未启用 CONFIG_USER_NS 配置项 允许用户命名空间 用户命名空间 + 允许非特权用户命名空间,Flatpak/bwrap/Firefox等软件需要此功能。但是,若内核未打补丁,设备可能易受某些漏洞利用攻击影响(如 Fragnesia) diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index c79376eb..933dc028 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -503,4 +503,8 @@ 磁碟使用情況 %1$s / %2$s 已使用 正在計算… + 使用者命名空間 + 允許使用者命名空間 + 允許非特權使用者命名空間(Flatpak、bwrap 和 Firefox 均需要此功能)。不過,若內核未打補丁,您可能會面臨諸如 Fragnesia 之類漏洞的攻擊風險 + 不支援。核心中未啟用 CONFIG_USER_NS From 410cbeec33b6b7ac18397ebb8e79578ae85e7227 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 17 Jul 2026 08:10:26 +0200 Subject: [PATCH 112/211] Translated using Weblate (Chinese (Traditional Han script), Spanish) (#238) Currently translated at 100.0% (515 of 515 strings) Translated using Weblate (Spanish) Currently translated at 56.3% (290 of 515 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/es/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hant/ Translation: Droidspaces/Strings Co-authored-by: Jung Chi Wang Co-authored-by: Libre <6n0n1m0s@proton.me> --- .../app/src/main/res/values-es/strings.xml | 69 ++++++++++++++++++- .../src/main/res/values-zh-rTW/strings.xml | 3 + 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/res/values-es/strings.xml b/Android/app/src/main/res/values-es/strings.xml index 46587ab6..b7eb71ba 100644 --- a/Android/app/src/main/res/values-es/strings.xml +++ b/Android/app/src/main/res/values-es/strings.xml @@ -53,7 +53,7 @@ Gestor de contenedores Linux (estilo LXC) para Android Desarrolladores ravindu644 - Backend, IU + Mantenedor Canal de Telegram Colaboradores GitHub @@ -87,7 +87,7 @@ Iniciar Detener Reiniciar - IPv6 + Deshabilitar IPv6 Almacenamiento HW Info del contenedor @@ -228,4 +228,69 @@ Requisitos comprobados con éxito Comprobación de requisitos con errores Requisitos del kernel copiados al portapapeles + Contenedores de Linux. Nativo. Sin sobrecarga. + Contenedores de Linux reales + La misma tecnología de nombre del núcleo que LXC, Podman y Docker. Sin emulación, sin chroot. + Sobrecarga cero + Habla directamente con el kernel de Linux host. Sin hipervisor, sin capa de máquina virtual. + No matable por diseño + Soporta la integración init.rc nativa y daemons espacio a 2 capas. Completamente ‘inmatable’. + systemd / OpenWrt procd / OpenRC en PID 1 + Soporte completo del sistema de init. Gestión real de servicios, no un entorno simulado. + Aislamiento completo + Árbol privado PID, tabla de montaje, nombre del host, IPC y jerarquía de ‘cgrous’ por contenedor. + Acceso completo de hardware/GPU + Exponer hardware host (GPU, sensores, USB, dispositivos de bloque) directamente a sus contenedores. + Contenedores completos privilegiados + Relaja barreras de seguridad con modo privilegiado para usuarios avanzados. + Arranque automático al encender + Inicio automático de contenedores al arranque, incluso si la pantalla está bloqueada y los datos están cifrados. + Segundo plano: acción requerida + Pulse para reparar configuración del sistema + Requerimientos de Temux + Requisitos del kernel sin GRI (≤4.19) + Requisitos del kernel GRI (≥5.4) + Guía + Color de acento + Modo de dæmonio + Ejecutar Droidspaces como un daemon persistente a nivel de sistema para evitar bloques de seccomp de Magisk/APatch, GrapheneOS, etc. Esto permite operaciones de alto privilegio al buscar y ejecutar comandos desde el espacio de usuario (Reinicio requerido). + Integra Droidspaces a la ruta de sistema + Cree un enlace en /system/bin para acceder al comando droidspaces en Android root shell. ADVERTENCIA: ESTE CAUSES BUCLE DE ARRANQUE SI SU MONTADO DEL SISTEMA ES SOBRECARGADO. Únicamente utilice esta opción cuando use el sistema de montaje basado en Magic Mount (Reinicio requerido) + Código fuente + Generar un informe de fallo + Recoge informe del sistema y bitácoras para depuración + Informe de fallo + Instalar + Teclee el nombre del contenedor exactamente para confirmar: + GPU + X11 + VirGL + PulseAudio + SELinux Permisivo + Volátil + Cgroup V1 + Escudo de punto muerto + Privilegiado + Espacios de nombres de usuario + Resumen de instalación + Configuración de revisión + Tarball + Directorio + Ruta de instalación + Ninguna opción adicional habilitada + Opciones + %1$s: %2$s + %1$s: %2$s + Tiempo activo + Empleo de disco + %1$s / %2$s utilizado + Calculando… + Acceso a GPU + Nodos de GPU del espejo interno al /dev aislado (traspaso de hardware incompleto) + Configurar Termux:X11 + Habilita zócalo X11 montando en Termux:X11 + Configurar PulseAudio + Habilita asistencia de sonido por PulseAudio + Indicadores adicionales de Termux:X11 + p. ej. -dpi 160 -fuerza-sysvshm diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index 933dc028..8ea163d4 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -507,4 +507,7 @@ 允許使用者命名空間 允許非特權使用者命名空間(Flatpak、bwrap 和 Firefox 均需要此功能)。不過,若內核未打補丁,您可能會面臨諸如 Fragnesia 之類漏洞的攻擊風險 不支援。核心中未啟用 CONFIG_USER_NS + 強調色 + 請準確輸入容器名稱以進行確認: + 偵錯 From 937053faf235d92b20a90cb871b8d36e6bac4a32 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Fri, 17 Jul 2026 11:36:01 +0530 Subject: [PATCH 113/211] ci: do not cache toolchains Signed-off-by: ravindu644 --- .github/workflows/ci.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0e0e325..6ccefc7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,15 +38,7 @@ jobs: sudo apt update sudo apt install -y build-essential wget tree zstd - - name: Cache Toolchains - id: cache-toolchains - uses: actions/cache@v4 - with: - path: ~/toolchains - key: toolchains-compilers-25843896720 - - name: Download Toolchains - if: steps.cache-toolchains.outputs.cache-hit != 'true' run: | mkdir -p $HOME/toolchains cd $HOME/toolchains From 1890424779e46c1f3ced182191af28eea7be8c46 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Fri, 17 Jul 2026 11:31:55 +0530 Subject: [PATCH 114/211] droidspaces: bump v6.4.5 Signed-off-by: ravindu644 --- Android/app/src/main/assets/boot-module/module.prop | 4 ++-- src/include/droidspace.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Android/app/src/main/assets/boot-module/module.prop b/Android/app/src/main/assets/boot-module/module.prop index 8198e1c5..90ac2beb 100644 --- a/Android/app/src/main/assets/boot-module/module.prop +++ b/Android/app/src/main/assets/boot-module/module.prop @@ -1,6 +1,6 @@ id=droidspaces name=Droidspaces: Daemon & Init -version=v6.4.0 -versionCode=6400 +version=v6.4.5 +versionCode=6450 author=ravindu644 description=Automatically starts the Droidspaces daemon if "Daemon Mode" is enabled, and starts containers during boot if "Run-at-Boot" is active. diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 481f7bb4..8f562b7e 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -67,7 +67,7 @@ * ---------------------------------------------------------------------------*/ #define DS_PROJECT_NAME "Droidspaces" -#define DS_VERSION "6.4.0" +#define DS_VERSION "6.4.5" #define DS_MIN_KERNEL_MAJOR 3 #define DS_MIN_KERNEL_MINOR 10 #define DS_RECOMMENDED_KERNEL_MAJOR 4 From 37d7154f93d4c1baa486c4f3a00c9f8b5a761d91 Mon Sep 17 00:00:00 2001 From: VizXtreme Date: Fri, 17 Jul 2026 18:03:07 +0530 Subject: [PATCH 115/211] feat(ui): Swipe to switch between home, containers, and control panel tabs (#241) --- .../app/ui/screen/MainTabScreen.kt | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt index a25770a9..2be25d3a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt @@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -45,21 +48,12 @@ enum class TabItem(val titleResId: Int, val icon: androidx.compose.ui.graphics.v } /** - * Main tab screen with optimized state management. - * - * Key improvements: - * 1. Uses AppStateViewModel for backend status (persists across navigation) - * 2. No redundant state variables (refreshCounter, shouldTriggerAnimatedRefresh removed) - * 3. Single coroutine scope for all operations - * 4. Proper recomposition boundaries - * - * This fixes the "settings back button glitch" by NOT re-checking backend on navigation. * Backend is only checked on: * 1. Cold app start * 2. Pull-to-refresh * 3. Post-installation (when returning from installation flow) */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun MainTabScreen( containerViewModel: ContainerViewModel, @@ -79,15 +73,18 @@ fun MainTabScreen( // containerViewModel is now passed as parameter to ensure sharing - // Tab selection - survives configuration changes - var selectedTab by rememberSaveable { mutableStateOf(TabItem.Home) } + val tabs = remember { TabItem.values() } + val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabs.size }) + val selectedTab = tabs[pagerState.currentPage] // Track which container has its action drawer expanded (hoisted for global collapse) var expandedContainerName by rememberSaveable { mutableStateOf(null) } // Handle back press - return to Home tab if not already there BackHandler(enabled = selectedTab != TabItem.Home) { - selectedTab = TabItem.Home + scope.launch { + pagerState.animateScrollToPage(tabs.indexOf(TabItem.Home)) + } } // Track if we've already triggered initial load in this session @@ -282,19 +279,27 @@ fun MainTabScreen( // visible region above the bar instead of behind it. var bottomBarHeight by remember { mutableStateOf(0.dp) } Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - Column( - modifier = Modifier - .fillMaxSize() - ) { - when (selectedTab) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + when (tabs[page]) { TabItem.Home -> { HomeTabContent( droidspacesStatus = droidspacesStatus, isChecking = isChecking, isRootAvailable = appStateViewModel.isRootAvailable, onNavigateToInstallation = onNavigateToInstallation, - onNavigateToContainers = { selectedTab = TabItem.Containers }, - onNavigateToControlPanel = { selectedTab = TabItem.ControlPanel }, + onNavigateToContainers = { + scope.launch { + pagerState.animateScrollToPage(tabs.indexOf(TabItem.Containers)) + } + }, + onNavigateToControlPanel = { + scope.launch { + pagerState.animateScrollToPage(tabs.indexOf(TabItem.ControlPanel)) + } + }, containerCount = containerCount, runningCount = runningCount, onRefresh = { performRefresh(TabItem.Home) } @@ -339,8 +344,10 @@ fun MainTabScreen( ) { MainBottomBar( selectedTab = selectedTab, - onTabSelected = { - selectedTab = it + onTabSelected = { tab -> + scope.launch { + pagerState.animateScrollToPage(tabs.indexOf(tab)) + } expandedContainerName = null } ) From 12859a95f40251fa9bf03b55a9c731cd28872851 Mon Sep 17 00:00:00 2001 From: Mohithash Date: Sat, 18 Jul 2026 10:41:59 +0530 Subject: [PATCH 116/211] =?UTF-8?q?Add=20POCO=20F6=20/=20Redmi=20Turbo=203?= =?UTF-8?q?=20(peridot)=20=E2=80=94=20DroidSpaces=20LXC=20mode,=20working?= =?UTF-8?q?=20(#242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add POCO F6 / Redmi Turbo 3 (peridot) — DroidSpaces LXC mode, verified working KernelSU-Next kernel, GKI 6.1.175, container tested on-device: PID/mnt/net/ipc/uts/cgroup namespace isolation, a live Alpine Linux 3.23.5 container with init+dhcpcd+sshd+getty, and 300 fork/exit cycles under namespace stress with zero kernel faults. Raw command output is in the linked release notes. * peridot: shorten notes to match table style --------- Co-authored-by: Mohithash <17986082+Mohithash@users.noreply.github.com> --- Documentation/community-supported-devices.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 1abf3fc4..5760ed29 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -181,6 +181,7 @@ This document is a community-maintained compatibility list for Android devices k | **OnePlus Nord 4** | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Nord 5** | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus SM8650 (All LineageOS OnePlus 6.1 Devices)** | SM8650 | LineageOS 23.2 - Android 16 QPR 2 | `6.1.166` | none | [Source](https://github.com/LineageOS/android_kernel_oneplus_sm8650) | [Download](https://github.com/linx3141/CRom-KSU-Builder/releases/tag/sm8650_LineageOS-lineage-23.2_None_20260512_1647) | Both | Turnip & Virgl | Working | [@linx3141](https://github.com/linx3141) | - | +| **POCO F6 / Redmi Turbo 3 (peridot)** | 24069PC21I | VoltageOS (AOSP-based) - Android 16 | `6.1.175` | KernelSU-Next | [Source](https://github.com/Mohithash/kernel_xiaomi_sm8635/tree/peridot-6.1.175) | [Download](https://github.com/Mohithash/kernel_xiaomi_sm8635/releases/download/droidspaces-v1/Theettam-2.0-KSUN3.3.0-SUSFS2.2.0-DroidSpaces-EXPERIMENTAL-peridot-6.1.175-android14-11-ga3b9c44908dd-ab13320413.zip) | Container/LXC | Untested | Working | [@Mohithash](https://github.com/Mohithash) | AnyKernel3 flashable, Image-only. Container/LXC verified with a live Alpine Linux 3.23.5. | | **6.6 Devices** | - | - | 6.6.x | - | - | - | - | - | - | -| - | | **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | From 71e5368ec550d3e0d2c506e8b1bc6ecbe2116de7 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 12:56:33 +0530 Subject: [PATCH 117/211] fix(security): check getgrouplist() return in daemon peer auth The daemon's SO_PEERCRED group-membership check ignored getgrouplist()'s return value. A connecting UID belonging to more than 64 groups makes getgrouplist() return -1 and set ngroups to the required (larger) count while filling only the 64-slot stack array, so the membership loop read past the buffer -- an out-of-bounds stack read that could wrongly authorize a non-member or crash the pre-fork daemon (DoS). Check the return; on overflow allocate the required size and retry so a legitimate member with many supplementary groups is still authorized, otherwise fail closed. Mirrors socketd_bridge.c's guarded check. Signed-off-by: ravindu644 --- src/daemon.c | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/daemon.c b/src/daemon.c index a9ee0538..8ee695f7 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -1070,14 +1070,32 @@ int ds_daemon_run(int foreground, char **argv) { struct passwd *pw = getpwuid(cred.uid); if (gr && pw) { int ngroups = 64; - gid_t groups[64]; - getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups); + gid_t stackgroups[64]; + gid_t *groups = stackgroups; + gid_t *heapgroups = NULL; + /* getgrouplist() returns -1 when the user belongs to more than + * ngroups groups, leaving ngroups set to the required count while + * only the first 64 slots are filled. Iterating to that larger + * count would read past the stack buffer (OOB read -> possible auth + * bypass or a crash that kills the pre-fork daemon). Reallocate to + * the required size and retry so a legitimate member with many + * supplementary groups is still authorized. */ + if (getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups) < 0) { + heapgroups = malloc(sizeof(gid_t) * (size_t)ngroups); + if (heapgroups && getgrouplist(pw->pw_name, pw->pw_gid, heapgroups, + &ngroups) >= 0) { + groups = heapgroups; + } else { + ngroups = 0; /* fail closed */ + } + } for (int i = 0; i < ngroups; i++) { if (groups[i] == gr->gr_gid) { allowed = 1; break; } } + free(heapgroups); } } From eb3824b1af283689c1ef6e7db0117ffb8097780b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 12:57:27 +0530 Subject: [PATCH 118/211] fix(security): length-aware growth in gen_stat() /proc/stat buffer gen_stat()'s realloc guard only reserved 1024 bytes of headroom, but the passthrough memcpy copies whole /proc/stat lines, and the intr/softirq lines can reach sizeof(line)-1 (2047) bytes on hosts with many IRQ vectors -- a heap buffer overflow of up to ~1KB in a root process. Size the growth to the actual line length plus slack (covering both the memcpy passthrough and the generated aggregate line), matching the length-aware guard already used by gen_cpuinfo(). Signed-off-by: ravindu644 --- src/virtualize.c | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/virtualize.c b/src/virtualize.c index 92faebe8..b67646a7 100644 --- a/src/virtualize.c +++ b/src/virtualize.c @@ -294,8 +294,15 @@ static char *gen_stat(struct ds_config *cfg, size_t *out_len) { /* Pass 2: emit with recomputed aggregate */ int agg_done = 0; while (fgets(line, sizeof(line), f)) { - if (off + 1024 >= cap) { - cap *= 2; + /* A single /proc/stat line (intr, softirq) can be up to sizeof(line)-1 + * bytes on hosts with many IRQ vectors. A fixed-headroom guard is not + * enough: size the growth to the actual line length (plus slack for the + * generated aggregate "cpu" line and the trailing NUL) before either the + * memcpy passthrough or the snprintf below can write. */ + size_t need = strlen(line) + 512; + if (off + need >= cap) { + while (off + need >= cap) + cap *= 2; char *nb = realloc(buf, cap); if (!nb) { free(buf); From 995fe9b0e77007f9c42b398a7631c3c5639aee27 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 12:59:50 +0530 Subject: [PATCH 119/211] fix(security): pin bind-mount target inode to defeat symlink swap race bind_mount() opened the source race-safely (O_PATH|O_NOFOLLOW + /proc/self/fd) but passed the target as a raw path to mkdir/write_file/ mount, leaving a TOCTOU window: an attacker (or a malicious rootfs) could swap the target for a symlink between the lstat() check and the mount(), redirecting the bind -- and the O_CREAT|O_TRUNC file creation -- outside the rootfs while host paths are still reachable pre-pivot. Create the target with O_CREAT|O_EXCL|O_NOFOLLOW (file case), then re-open it O_PATH|O_NOFOLLOW and mount source onto target via /proc/self/fd for both ends, pinning the exact validated inodes so the swap race cannot redirect the mount. Intermediate-component symlink resolution still needs openat2(RESOLVE_NO_SYMLINKS) (not portable to the old kernels supported); custom binds retain the is_subpath() post-mount escape check. Signed-off-by: ravindu644 --- src/mount.c | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/mount.c b/src/mount.c index 583d2b2f..1f2f1d62 100644 --- a/src/mount.c +++ b/src/mount.c @@ -151,15 +151,28 @@ int bind_mount(const char *src, const char *tgt) { return -1; } + /* Create the target if it does not exist yet. Use O_NOFOLLOW | O_EXCL for + * the file case so a symlink planted at the final component (e.g. by a + * malicious rootfs) is never followed or silently reused. */ struct stat st_tgt; if (lstat(tgt, &st_tgt) < 0) { if (S_ISDIR(st_src.st_mode)) { - mkdir(tgt, st_src.st_mode & 07777); + if (mkdir(tgt, st_src.st_mode & 07777) < 0 && errno != EEXIST) { + close(src_fd); + return -1; + } if (chown(tgt, st_src.st_uid, st_src.st_gid) < 0) { /* ignore chown failure, not critical for bind mount setup */ } } else { - write_file(tgt, ""); + int tf = + open(tgt, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0644); + if (tf < 0 && errno != EEXIST) { + close(src_fd); + return -1; + } + if (tf >= 0) + close(tf); } } else if (S_ISLNK(st_tgt.st_mode)) { ds_error("Security Violation: Bind target %s is a symlink!", tgt); @@ -168,11 +181,28 @@ int bind_mount(const char *src, const char *tgt) { return -1; } - char proc_path[64]; - snprintf(proc_path, sizeof(proc_path), "/proc/self/fd/%d", src_fd); + /* Re-open the target through O_PATH | O_NOFOLLOW and mount onto the resulting + * descriptor via /proc/self/fd. This pins the exact inode we validated, so + * the classic lstat()->mount() swap race (target replaced by a symlink in + * between) cannot redirect the bind outside the rootfs. O_NOFOLLOW also + * rejects a symlink at the final component here. (A symlink in an + * intermediate component is still resolved at open() time; fully closing + * that needs openat2(RESOLVE_NO_SYMLINKS), which is unavailable on the older + * kernels Droidspaces supports. Custom binds get an additional is_subpath() + * post-check in setup_custom_binds().) */ + int tgt_fd = open(tgt, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (tgt_fd < 0) { + close(src_fd); + return -1; + } + + char src_proc[64], tgt_proc[64]; + snprintf(src_proc, sizeof(src_proc), "/proc/self/fd/%d", src_fd); + snprintf(tgt_proc, sizeof(tgt_proc), "/proc/self/fd/%d", tgt_fd); - int res = domount(proc_path, tgt, NULL, MS_BIND | MS_REC, NULL); + int res = domount(src_proc, tgt_proc, NULL, MS_BIND | MS_REC, NULL); close(src_fd); + close(tgt_fd); return res; } From 7be27b934aaffd611b307339c172ec0eac548843 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:03:47 +0530 Subject: [PATCH 120/211] fix(security): safe temp-file creation, reject symlinked bind targets Three root-owned create/truncate paths followed symlinks and/or used predictable temp names, letting a pre-planted symlink redirect the write: * write_file_atomic() used a deterministic ".tmp" opened without O_EXCL/O_NOFOLLOW. Switch to mkstemp() (randomized name, O_EXCL), fsync via the fd, and fchmod 0644 to preserve prior semantics. * ds_bind_mount_socket() created/chmod'd/bind-mounted dst (in the container's /tmp) without O_NOFOLLOW. Add O_NOFOLLOW, operate via fchown/fchmod on the opened inode, and refuse when dst is a symlink so the mount never lands on a link target. * hardware.c GPU-group rewrite used a fixed /etc/group.tmp opened with fopen("we"). Switch to mkstemp()+fdopen(). write_file() itself is left unchanged: it is used almost entirely for /proc/sys and /sys/fs/cgroup pseudo-files and post-pivot container-internal /etc targets, where a blanket O_NOFOLLOW would break legitimate writes through symlinked config files without preventing any host escape. Signed-off-by: ravindu644 --- src/hardware.c | 19 ++++++++++++++++--- src/utils.c | 46 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/hardware.c b/src/hardware.c index 1b2e8420..259438bc 100644 --- a/src/hardware.c +++ b/src/hardware.c @@ -671,7 +671,9 @@ int setup_gpu_groups(void) { /* We'll rewrite the group file to a temporary location */ const char *group_path = "/etc/group"; - const char *tmp_path = "/etc/group.tmp"; + /* Randomized name + O_EXCL (mkstemp) so a symlink pre-planted at a + * predictable /etc/group.tmp cannot redirect this root-owned write. */ + char tmp_path[] = "/etc/group.XXXXXX"; FILE *fin = fopen(group_path, "re"); if (!fin) { @@ -679,9 +681,20 @@ int setup_gpu_groups(void) { return -1; } - FILE *fout = fopen(tmp_path, "we"); + int tfd = mkstemp(tmp_path); + if (tfd < 0) { + ds_warn("Cannot create temp group file: %s", strerror(errno)); + fclose(fin); + return -1; + } + (void)fcntl(tfd, F_SETFD, FD_CLOEXEC); + if (fchmod(tfd, 0644) < 0) { /* best effort; group files are 0644 */ + } + FILE *fout = fdopen(tfd, "w"); if (!fout) { - ds_warn("Cannot create /etc/group.tmp: %s", strerror(errno)); + ds_warn("Cannot create temp group file: %s", strerror(errno)); + close(tfd); + unlink(tmp_path); fclose(fin); return -1; } diff --git a/src/utils.c b/src/utils.c index f6b19d71..a1b50f23 100644 --- a/src/utils.c +++ b/src/utils.c @@ -420,20 +420,32 @@ int write_file(const char *path, const char *content) { } int write_file_atomic(const char *path, const char *content) { + /* Randomized name + O_EXCL (via mkstemp) so a symlink pre-planted at a + * predictable ".tmp" cannot redirect this (often root-owned) write or + * let it truncate an arbitrary followed target. */ char tmp[PATH_MAX]; - snprintf(tmp, sizeof(tmp), "%s.tmp", path); + int n = snprintf(tmp, sizeof(tmp), "%s.XXXXXX", path); + if (n < 0 || n >= (int)sizeof(tmp)) + return -1; - if (write_file(tmp, content) < 0) + int fd = mkstemp(tmp); + if (fd < 0) return -1; + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); - /* fsync before rename - ensures data hits disk on Android before reboot */ - int sync_fd = open(tmp, O_RDONLY | O_CLOEXEC); - if (sync_fd >= 0) { - fsync(sync_fd); - close(sync_fd); - } + size_t len = strlen(content); + int ok = (write_all(fd, content, len) == (ssize_t)len); + + /* mkstemp creates the file 0600; preserve the previous 0644 semantics. */ + if (ok && fchmod(fd, 0644) < 0) + ok = 0; - if (rename(tmp, path) < 0) { + /* fsync before rename - ensures data hits disk on Android before reboot. */ + if (ok && fsync(fd) < 0) + ok = 0; + close(fd); + + if (!ok || rename(tmp, path) < 0) { unlink(tmp); return -1; } @@ -2664,12 +2676,22 @@ pid_t ds_spawn_daemon(ds_child_fn child_fn, void *user_data, */ int ds_bind_mount_socket(const char *src, const char *dst, uid_t uid, const char *label) { - int fd = open(dst, O_WRONLY | O_CREAT | O_CLOEXEC, 0666); + /* O_NOFOLLOW: never create through, chown/chmod, or bind-mount over a symlink + * planted at dst (which lives in the container's own /tmp). Operate on the + * opened inode via fchown/fchmod so there is no path-re-resolution race. */ + int fd = open(dst, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0666); if (fd >= 0) { + if (fchown(fd, uid, uid) < 0) { /* ignore */ + } + fchmod(fd, 0666); close(fd); - if (chown(dst, uid, uid) < 0) { /* ignore */ + } else { + struct stat st; + if (lstat(dst, &st) == 0 && S_ISLNK(st.st_mode)) { + ds_warn("[%s] refusing to bind-mount socket: %s is a symlink", label, + dst); + return -1; } - chmod(dst, 0666); } if (mount(src, dst, NULL, MS_BIND, NULL) != 0) { ds_warn("[%s] failed to bind-mount socket: %s", label, strerror(errno)); From e5d47e718526ed976b1707ce4b4358ab9388b5dc Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:05:42 +0530 Subject: [PATCH 121/211] fix(security): create pidfile and container.config non-world-writable The daemon calls umask(0), so files created via fopen() default to 0666. Both the daemon pidfile and the per-container container.config were therefore world-writable: any local user could overwrite the pidfile to redirect the app's signals to an arbitrary PID, and tamper with a config later re-parsed by the root daemon. Create both with an explicit 0644 mode instead of touching the global umask (which container children inherit via re-exec): the pidfile via open(O_CREAT|O_TRUNC|O_NOFOLLOW, 0644), and container.config via mkstemp() + fchmod 0644 -- the latter also removing the predictable '.tmp' symlink-redirect exposure. Signed-off-by: ravindu644 --- src/config.c | 23 ++++++++++++++++++----- src/daemon.c | 21 ++++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/config.c b/src/config.c index 200d818c..d58acae5 100644 --- a/src/config.c +++ b/src/config.c @@ -819,13 +819,26 @@ int ds_config_save(const char *config_path, struct ds_config *cfg) { } } + /* Step 2: Write all configurations to a temporary file. Use mkstemp (random + * name + O_EXCL) with an explicit 0644 mode: the daemon runs with umask 0, so + * a plain fopen would leave container.config world-writable, and a + * predictable ".tmp" could be pre-planted as a symlink. */ char temp_path[PATH_MAX]; - snprintf(temp_path, sizeof(temp_path), "%s.tmp", config_path); - - /* Step 2: Write all configurations to temporary file */ - FILE *f_out = fopen(temp_path, "we"); - if (!f_out) + int tn = snprintf(temp_path, sizeof(temp_path), "%s.XXXXXX", config_path); + if (tn < 0 || tn >= (int)sizeof(temp_path)) + return -1; + int tfd = mkstemp(temp_path); + if (tfd < 0) + return -1; + (void)fcntl(tfd, F_SETFD, FD_CLOEXEC); + if (fchmod(tfd, 0644) < 0) { /* best effort; config is world-readable 0644 */ + } + FILE *f_out = fdopen(tfd, "w"); + if (!f_out) { + close(tfd); + unlink(temp_path); return -1; + } ds_config_serialize_known(f_out, cfg); diff --git a/src/daemon.c b/src/daemon.c index 8ee695f7..5b161dfa 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -988,15 +988,26 @@ int ds_daemon_run(int foreground, char **argv) { strerror(errno)); #endif - /* Write PID file so the Android app can signal us */ + /* Write PID file so the Android app can signal us. The daemon runs with + * umask 0, so create it 0644 explicitly (not the fopen default 0666): a + * world-writable pidfile would let any local user overwrite it and redirect + * the app's signals (SIGUSR2/kill) to an arbitrary PID. O_NOFOLLOW guards + * against a symlink planted in the workspace dir; no O_EXCL so a restart can + * replace a stale pidfile. */ { char pid_path[PATH_MAX]; snprintf(pid_path, sizeof(pid_path), "%s/droidspacesd.pid", get_workspace_dir()); - FILE *pf = fopen(pid_path, "w"); - if (pf) { - fprintf(pf, "%d\n", getpid()); - fclose(pf); + int pfd = open(pid_path, + O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0644); + if (pfd >= 0) { + FILE *pf = fdopen(pfd, "w"); + if (pf) { + fprintf(pf, "%d\n", getpid()); + fclose(pf); + } else { + close(pfd); + } } } From 174b75c92054fe15ee405adac65d84d0e8cf5521 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:07:45 +0530 Subject: [PATCH 122/211] fix(security): refuse to load a group/other-writable config ds_config_load() parsed config files with no permission check. The root daemon parses container.config (rootfs_path, bind_mounts, dns_servers, gateway_*, custom_init) on load/stop/info/restart, so a config writable by a non-owner let a local user inject values the daemon acts on. fstat the opened descriptor and refuse the file if it is writable by group or other. This applies to both the host workspace config and the in-namespace snapshot load (a container that makes its snapshot 0666 simply falls back to the host copy). A group/other-writable check is used rather than a strict root-owner requirement so legitimate user-owned configs loaded via sudo are not rejected. Signed-off-by: ravindu644 --- src/config.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/config.c b/src/config.c index d58acae5..2a658e0c 100644 --- a/src/config.c +++ b/src/config.c @@ -225,6 +225,21 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { return -1; } + /* A config parsed by the root daemon must only be modifiable by its owner. + * Refuse one that is writable by group or other: otherwise any local user + * could inject rootfs_path / bind / dns / gateway values that the daemon + * then acts on. fstat the opened descriptor (not the path) to avoid a + * check/use race. */ + { + struct stat st; + if (fstat(fileno(f), &st) == 0 && (st.st_mode & (S_IWGRP | S_IWOTH))) { + ds_warn("Refusing config %s: writable by group/other (mode %04o)", + config_path, (unsigned)(st.st_mode & 07777)); + fclose(f); + return -1; + } + } + /* Clear existing unknown lines to avoid duplication on re-load */ free_config_unknown_lines(cfg); From 2c46cff8d7d26d56851a1c18b1273c680bff04c9 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:08:47 +0530 Subject: [PATCH 123/211] fix(security): force NUL-termination of socketd wire target field The lifecycle (START/STOP/RESTART) and INSPECT request handlers filled a fixed 256-byte target[] via an exact sizeof read, then passed it to safe_strncpy() which calls strlen() first. The wire format does not require a NUL, so an authorized peer sending 256 non-zero bytes made strlen read past the field into adjacent stack memory (out-of-bounds read, plus disclosure via the ds_warn truncation log). Force target[sizeof-1] = '\0' immediately after the payload read in both handlers. Signed-off-by: ravindu644 --- src/socketd_bridge.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 12cdb262..fc084435 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -822,6 +822,10 @@ static enum ds_socketd_status socketd_read_lifecycle_request( return DS_SOCKETD_STATUS_BAD_REQUEST; } + /* The wire format does not guarantee target[] is NUL-terminated; force it + * before safe_strncpy() (which calls strlen) so a client sending 256 + * non-zero bytes cannot make strlen read past the fixed-size field. */ + req_out->target[sizeof(req_out->target) - 1] = '\0'; safe_strncpy(target_out, req_out->target, target_size); if (!target_out[0]) return DS_SOCKETD_STATUS_BAD_REQUEST; @@ -1315,6 +1319,9 @@ static void socketd_handle_conn(int conn) { return; } + /* Force NUL-termination of the wire field before safe_strncpy()'s strlen + * to avoid an out-of-bounds read past the fixed-size target[]. */ + inspect_req.target[sizeof(inspect_req.target) - 1] = '\0'; char target[DS_SOCKETD_RECORD_NAME_MAX]; safe_strncpy(target, inspect_req.target, sizeof(target)); if (!target[0]) { From 79449ca5528cec09a01056b6f77216fc71e40a14 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:11:53 +0530 Subject: [PATCH 124/211] fix(security): reject socket peers outside the daemon PID namespace Both the daemon (@droidspaces) and the socketd bridge use abstract unix sockets, which are scoped to the network namespace rather than the filesystem. A HOST-net container shares the host netns and, running with host UIDs (no user namespace), a root process inside it presents uid 0 to SO_PEERCRED and passed authorization -- gaining START/STOP/RESTART/INSPECT over host containers. Add ds_peer_in_pidns() and gate both authorizers on it: reject peers that are not in the daemon's PID namespace. The abstract socket is kept (the Android app depends on it), and the check fails open when the peer's ns link is unreadable so legitimate host clients -- which are always in the daemon's pidns, since in-container invocations use DS_NO_PROXY and the recursive-daemon guard -- are never wrongly denied. Signed-off-by: ravindu644 --- src/daemon.c | 11 +++++++++++ src/include/droidspace.h | 4 ++++ src/socketd_bridge.c | 10 ++++++++++ src/utils.c | 16 ++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/src/daemon.c b/src/daemon.c index 5b161dfa..aaeffe4d 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -1110,6 +1110,17 @@ int ds_daemon_run(int foreground, char **argv) { } } + /* Abstract sockets are scoped to the network namespace, not the + * filesystem, so a HOST-net container -- sharing our netns but running in + * its own PID namespace and presenting uid 0 without a user namespace -- + * could otherwise pass the checks above. Reject peers outside our PID + * namespace; legitimate host clients are always inside it (in-container + * invocations use DS_NO_PROXY and never reach the daemon). */ + if (allowed && !ds_peer_in_pidns(cred.pid)) { + ds_warn("Rejected pid %d: not in daemon PID namespace", (int)cred.pid); + allowed = 0; + } + if (!allowed) { const char *msg = "permission denied: only root or '" DS_GROUP "' group members may connect."; diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 8f562b7e..b2051209 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -530,6 +530,10 @@ void ds_global_daemon_stop(int (*check_fn)(void), pid_t cached_pid, pid_t *pid_out, const char *pidfile, const char *sock_path, const char *tag); void ds_oom_protect(void); +/* Return 1 if peer_pid shares this process's PID namespace (or if it cannot be + * determined), 0 only on a positive mismatch. Used by the abstract-socket + * authorizers to reject host-net container peers. */ +int ds_peer_in_pidns(pid_t peer_pid); void ds_spawn_log_relay(int pipe_read_fd, const char *log_file, const char *tag); pid_t ds_spawn_daemon(ds_child_fn child_fn, void *user_data, diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index fc084435..ff292de0 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -96,6 +96,16 @@ static int socketd_peer_authorized(int fd) { if (cred_len != sizeof(cred)) return 0; + /* + * Abstract sockets are scoped to the network namespace, not the filesystem, + * so a HOST-net container (sharing our netns but running in its own PID + * namespace, presenting uid 0 without a user namespace) could otherwise + * connect. Reject peers outside our PID namespace before any uid/group + * check; legitimate host clients are always inside it. + */ + if (!ds_peer_in_pidns(cred.pid)) + return 0; + /* * The abstract backend socket has no filesystem permissions, so enforce the * same local authorization model used by the main Droidspaces daemon: root or diff --git a/src/utils.c b/src/utils.c index a1b50f23..318d1087 100644 --- a/src/utils.c +++ b/src/utils.c @@ -2530,6 +2530,22 @@ void ds_oom_protect(void) { } } +int ds_peer_in_pidns(pid_t peer_pid) { + if (peer_pid <= 0) + return 1; /* unknown -> fail open */ + + char path[64], self_ns[64], peer_ns[64]; + ssize_t sn = readlink("/proc/self/ns/pid", self_ns, sizeof(self_ns) - 1); + snprintf(path, sizeof(path), "/proc/%d/ns/pid", (int)peer_pid); + ssize_t pn = readlink(path, peer_ns, sizeof(peer_ns) - 1); + if (sn <= 0 || pn <= 0) + return 1; /* cannot determine -> fail open, never wrongly deny */ + + self_ns[sn] = '\0'; + peer_ns[pn] = '\0'; + return strcmp(self_ns, peer_ns) == 0; +} + /* * Fork a log-relay child that reads from pipe_read_fd and writes timestamped * lines to / with [tag] prefix. The relay ignores all From 59ddf214a751e7ab48dc0a2b91adc04316b3bfe0 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:12:41 +0530 Subject: [PATCH 125/211] fix(security): write sysvinit initctl only through a verified FIFO stop_rootfs() wrote a 384-byte init_request into the container's /run/initctl (and /dev/initctl) via /proc//root without O_NOFOLLOW. The container controls those paths within its own root, so it could replace initctl with a symlink or a regular file and capture/redirect the host-privileged write (confined to the container root, e.g. clobbering its own /etc/passwd). Add O_NOFOLLOW to both opens and fstat the result, writing only when it is a real FIFO (S_ISFIFO); otherwise close and fall back to SIGPWR. Signed-off-by: ravindu644 --- src/container.c | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/container.c b/src/container.c index 2ba60ad5..3930a916 100644 --- a/src/container.c +++ b/src/container.c @@ -875,20 +875,27 @@ int stop_rootfs_with_timeout(struct ds_config *cfg, int skip_unmount, .sleeptime = 3, }; - int fd = open(initctl, O_WRONLY | O_NONBLOCK | O_CLOEXEC); + int fd = open(initctl, O_WRONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC); if (fd < 0) { /* Fallback: try /dev/initctl (historical path, used by Slackware) */ snprintf(initctl, sizeof(initctl), "/proc/%d/root/dev/initctl", pid); - fd = open(initctl, O_WRONLY | O_NONBLOCK | O_CLOEXEC); + fd = open(initctl, O_WRONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC); } - if (fd >= 0) { + /* Only write into an actual FIFO. The container fully controls + * /run/initctl inside its own root: O_NOFOLLOW rejects a symlink at the + * final component, and the S_ISFIFO check rejects a regular file the + * container may have planted to capture the host-written init_request. */ + struct stat ictl_st; + if (fd >= 0 && fstat(fd, &ictl_st) == 0 && S_ISFIFO(ictl_st.st_mode)) { if (write(fd, &req, sizeof(req)) != (ssize_t)sizeof(req)) ds_warn("sysvinit: short write to initctl, falling back to SIGPWR"); close(fd); } else { - ds_warn("sysvinit: cannot open initctl (tried /run and /dev), falling " - "back to SIGPWR"); + if (fd >= 0) + close(fd); + ds_warn("sysvinit: cannot open initctl FIFO (tried /run and /dev), " + "falling back to SIGPWR"); kill(pid, SIGPWR); } break; From 6a73f765ee35088d2495fbe32669739d8c356de9 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:13:13 +0530 Subject: [PATCH 126/211] fix(security): set SOCK_CLOEXEC on route-monitor and DHCP sockets Two long-lived raw sockets in the multi-threaded monitor process were created without SOCK_CLOEXEC: the RTNLGRP-bound route-monitor netlink socket (network.c) and the AF_PACKET ETH_P_ALL DHCP socket (dhcp.c). Both persist for the container lifetime while the monitor fork+execs helpers (iptables/modprobe/port-forward), leaking a host-wide routing-event observer / arbitrary L2 sniff-and-inject capability into unrelated children. Create both with SOCK_CLOEXEC atomically, matching ds_nl_open(). Signed-off-by: ravindu644 --- src/net/dhcp.c | 2 +- src/net/network.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/net/dhcp.c b/src/net/dhcp.c index ba13ef56..0ccb6a19 100644 --- a/src/net/dhcp.c +++ b/src/net/dhcp.c @@ -333,7 +333,7 @@ static void *dhcp_server_loop(void *arg) { uint8_t rx_buf[2048]; /* 0. Create the AF_PACKET socket. */ - packet_sock = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); + packet_sock = socket(AF_PACKET, SOCK_RAW | SOCK_CLOEXEC, htons(ETH_P_ALL)); if (packet_sock < 0) { ds_warn("[DHCP] packet socket: %s", strerror(errno)); pthread_mutex_lock(&g_dhcp_lock); diff --git a/src/net/network.c b/src/net/network.c index 0de39859..76c2a300 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -1897,7 +1897,7 @@ static void *route_monitor_loop(void *arg) { ds_log("[NET] Uplink route monitor started (automatic detection)"); - int sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); + int sock = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); if (sock < 0) { ds_warn("[NET] Route monitor: failed to open netlink socket: %s", strerror(errno)); From 9fb229a1271fc4403b636b26fe8d2d7c1a15402d Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:13:42 +0530 Subject: [PATCH 127/211] fix(security): guard size==0 underflow in sanitize_container_name With size == 0 the loop bound 'size - 1' (size_t) wrapped to SIZE_MAX, turning the copy into an unbounded out-of-bounds write. All current callers pass a non-zero buffer, but the shared helper must not carry that unchecked precondition. Return early on size == 0 / NULL out, and emit an empty string for a NULL name, matching safe_strncpy/read_file. Signed-off-by: ravindu644 --- src/utils.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/utils.c b/src/utils.c index 318d1087..7311aaa2 100644 --- a/src/utils.c +++ b/src/utils.c @@ -74,6 +74,15 @@ int ds_parse_iface_csv(const char *val, char ifaces[][IFNAMSIZ], int *count, /* Mirrors ContainerManager.sanitizeContainerName() in the Android app. * Replaces spaces with dashes so directory names are consistent. */ void sanitize_container_name(const char *name, char *out, size_t size) { + /* Guard size == 0: 'size - 1' is size_t, so it would wrap to SIZE_MAX and + * turn the copy into an unbounded write. (safe_strncpy/read_file guard this + * the same way.) */ + if (!out || size == 0) + return; + if (!name) { + out[0] = '\0'; + return; + } size_t i; for (i = 0; i < size - 1 && name[i] != '\0'; i++) out[i] = (name[i] == ' ') ? '-' : name[i]; From af4a123f6a5490a4f9f1473138b558285b31023e Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:15:07 +0530 Subject: [PATCH 128/211] fix(security): fix format-string arg mismatch in domount_silent The [DEBUG] log had three %s conversions but only two arguments (the 'tgt' operand was missing), so the third %s read an indeterminate vararg and dereferenced it as char* -- a crash / stack info-leak if ever reached. domount_silent currently has no callers, but fix the latent bug by passing tgt. Signed-off-by: ravindu644 --- src/mount.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mount.c b/src/mount.c index 1f2f1d62..87236dec 100644 --- a/src/mount.c +++ b/src/mount.c @@ -113,7 +113,7 @@ int domount_silent(const char *src, const char *tgt, const char *fstype, if (mount(src, tgt, fstype, flags, data) < 0) { if (errno != EBUSY) { ds_log("[DEBUG] mount %s -> %s failed: %s", src ? src : "none", - strerror(errno)); + tgt ? tgt : "none", strerror(errno)); return -1; } } From 2117b89b206c8b20e20b55e21d53fac006919e44 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:15:58 +0530 Subject: [PATCH 129/211] fix(security): clamp gen_uptime out_len to bytes actually written *out_len was set from snprintf()'s return, which is the length it would have written, not the bytes stored. If the formatted uptime/idle ever exceeded the 64-byte buffer, a consumer's write_all(fd, buf, *out_len) would over-read the heap. Check for error and clamp to 63. Signed-off-by: ravindu644 --- src/virtualize.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/virtualize.c b/src/virtualize.c index b67646a7..666bd788 100644 --- a/src/virtualize.c +++ b/src/virtualize.c @@ -410,7 +410,15 @@ static char *gen_uptime(struct ds_config *cfg, size_t *out_len) { char *buf = malloc(64); if (!buf) return NULL; - *out_len = (size_t)snprintf(buf, 64, "%.2f %.2f\n", up, idle); + /* snprintf returns the length it *would* have written; clamp to the bytes + * actually in the buffer so a consumer's write_all() cannot over-read the + * heap if the formatted output ever exceeds 63 chars. */ + int n = snprintf(buf, 64, "%.2f %.2f\n", up, idle); + if (n < 0) { + free(buf); + return NULL; + } + *out_len = (n < 64) ? (size_t)n : 63; return buf; } From b0e3ffe51df9dc6fea921f722ea6c4742e2e927b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:16:18 +0530 Subject: [PATCH 130/211] fix(security): add O_NOFOLLOW to bind_vfile target creation bind_vfile() created the /proc/ bind target with O_CREAT but no O_NOFOLLOW, so a symlink planted at the final component (TOCTOU vs the preceding access() check) would be followed. Add O_NOFOLLOW; bind_mount() already rejects a symlinked target as a second layer. Signed-off-by: ravindu644 --- src/virtualize.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/virtualize.c b/src/virtualize.c index 666bd788..fba3d765 100644 --- a/src/virtualize.c +++ b/src/virtualize.c @@ -483,9 +483,11 @@ static void bind_vfile(const char *vpath, const char *target, size_t len __attribute__((unused))) { if (write_file(vpath, content) < 0) return; - /* Ensure the target exists as a regular file for bind_mount */ + /* Ensure the target exists as a regular file for bind_mount. O_NOFOLLOW so a + * symlink planted at the final component is not followed (bind_mount also + * rejects a symlinked target as defense-in-depth). */ if (access(target, F_OK) != 0) { - int fd = open(target, O_WRONLY | O_CREAT | O_CLOEXEC, 0444); + int fd = open(target, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0444); if (fd >= 0) close(fd); } From 6601337e25fe71461193e5df3cbf1ea39d463b30 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:17:25 +0530 Subject: [PATCH 131/211] fix(security): use atomic close-on-exec for transient fds Several descriptors set (or relied on) close-on-exec non-atomically: the namespace fds in enter_namespace() (open O_RDONLY), the sync socketpair, and the socketd bridge's listening/accepted sockets (socket()+fcntl, accept()+fcntl). These are closed before exec today so there was no live leak, but an interposed exec on an error path could leak them. Open the ns fds O_RDONLY|O_CLOEXEC, create the socketpair with SOCK_CLOEXEC, and switch the bridge to socket(SOCK_CLOEXEC)/accept4() (dropping the now-redundant fcntl calls). Signed-off-by: ravindu644 --- src/container.c | 4 ++-- src/socketd_bridge.c | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/container.c b/src/container.c index 3930a916..f7cc6a5a 100644 --- a/src/container.c +++ b/src/container.c @@ -999,7 +999,7 @@ int enter_namespace(pid_t pid, struct ds_config *cfg) { /* 1. Open all namespace descriptors first (CRITICAL: before any setns) */ for (int i = 0; i < 6; i++) { snprintf(path, sizeof(path), "/proc/%d/ns/%s", pid, ns_names[i]); - ns_fds[i] = open(path, O_RDONLY); + ns_fds[i] = open(path, O_RDONLY | O_CLOEXEC); if (ns_fds[i] < 0) { if (i == 0) { /* mnt is mandatory */ ds_error("Failed to open mount namespace at %s: %s", path, @@ -1074,7 +1074,7 @@ int enter_rootfs(struct ds_config *cfg, const char *user) { tty.master = tty.slave = -1; int sv[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) { + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) < 0) { close(tty.master); close(tty.slave); free_config_env_vars(cfg); diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index ff292de0..5158aa80 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -1453,12 +1453,10 @@ static void socketd_handle_conn(int conn) { static int socketd_bridge_loop(void) { struct sockaddr_un addr; - int server = socket(AF_UNIX, SOCK_STREAM, 0); + int server = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); if (server < 0) return -1; - fcntl(server, F_SETFD, FD_CLOEXEC); - socklen_t addr_len = socketd_backend_addr(&addr); if (bind(server, (struct sockaddr *)&addr, addr_len) < 0) { close(server); @@ -1474,7 +1472,7 @@ static int socketd_bridge_loop(void) { DS_SOCKETD_BACKEND_SOCK_NAME); for (;;) { - int conn = accept(server, NULL, NULL); + int conn = accept4(server, NULL, NULL, SOCK_CLOEXEC); if (conn < 0) { if (errno == EINTR) continue; @@ -1482,7 +1480,6 @@ static int socketd_bridge_loop(void) { continue; } - fcntl(conn, F_SETFD, FD_CLOEXEC); socketd_set_conn_timeouts(conn); socketd_handle_conn(conn); close(conn); From feb758664d2e3cbb18c531648fa084776659bbd8 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:17:59 +0530 Subject: [PATCH 132/211] fix(security): add SOCK_CLOEXEC to transient AF_INET sockets The ioctl/helper sockets in iface_is_running(), ds_net_disable_tx_checksum() (network.c), the DHCP MAC lookup (dhcp.c), and the raw socket in iptables.c were created without SOCK_CLOEXEC. They are closed synchronously today, but the monitor is multi-threaded (route monitor + DHCP server), so a fork+exec in another thread within the open/close window could leak them. Create all four with SOCK_CLOEXEC. Signed-off-by: ravindu644 --- src/net/dhcp.c | 2 +- src/net/iptables.c | 2 +- src/net/network.c | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/net/dhcp.c b/src/net/dhcp.c index 0ccb6a19..a01febe1 100644 --- a/src/net/dhcp.c +++ b/src/net/dhcp.c @@ -561,7 +561,7 @@ void ds_dhcp_server_start(struct ds_config *cfg, const char *veth_host, * for the gateway resolves to a real MAC. Looking up the bind interface * works in both bridged and bridgeless modes - no hardcoded bridge * dependency. Falling back to all-zeros would break the container's ARP. */ - int s = socket(AF_INET, SOCK_DGRAM, 0); + int s = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); if (s >= 0) { struct ifreq ifr; memset(&ifr, 0, sizeof(ifr)); diff --git a/src/net/iptables.c b/src/net/iptables.c index 16048639..ec9586de 100644 --- a/src/net/iptables.c +++ b/src/net/iptables.c @@ -707,7 +707,7 @@ static int should_use_raw_api(void) { static int open_raw_socket(void) { probe_iptables_modules(); - int fd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW); + int fd = socket(AF_INET, SOCK_RAW | SOCK_CLOEXEC, IPPROTO_RAW); if (fd < 0) ds_log("[IPT] Failed to open raw socket: %s", strerror(errno)); return fd; diff --git a/src/net/network.c b/src/net/network.c index 76c2a300..12090d9f 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -271,7 +271,7 @@ static int g_upstream_count = 0; * On Android, the active data interface has IFF_RUNNING set; an interface * that is physically present but not carrying data loses IFF_RUNNING. */ static int iface_is_running(const char *ifname) { - int fd = socket(AF_INET, SOCK_DGRAM, 0); + int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); if (fd < 0) return 0; struct ifreq ifr; @@ -649,7 +649,7 @@ static void ds_net_setup_android_routing(ds_nl_ctx_t *ctx) { * ---------------------------------------------------------------------------*/ int ds_net_disable_tx_checksum(const char *ifname) { - int fd = socket(AF_INET, SOCK_DGRAM, 0); + int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); if (fd < 0) return -errno; From 267714d5fdb49beb3e0fdbe29768087e72c326b7 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:19:23 +0530 Subject: [PATCH 133/211] fix(security): replace weak rand() UUID fallback with getrandom(2) When /dev/urandom could not be read, generate_uuid() fell back to a srand(time^pid^ppid) + rand() sequence and returned success, yielding a low-entropy, predictable identifier (and the caller could not tell it was the weak path). Use getrandom(2) as the fallback -- it needs no device node -- and fail closed (return -1) if that is unavailable too, so a predictable id is never emitted. Signed-off-by: ravindu644 --- src/utils.c | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/utils.c b/src/utils.c index 7311aaa2..458ee0c4 100644 --- a/src/utils.c +++ b/src/utils.c @@ -8,6 +8,7 @@ #include "droidspace.h" #include "socketd_protocol.h" #include +#include #include #include @@ -533,27 +534,19 @@ int generate_uuid(char *buf, size_t size) { } } - /* Fallback path: seeded rand() */ - static int seeded = 0; - if (!seeded) { - struct timespec ts; - clock_gettime(CLOCK_REALTIME, &ts); + /* Fallback path: getrandom(2). Unlike /dev/urandom it needs no device node + * (e.g. when /dev is not yet populated). If it too is unavailable, fail + * closed rather than emit a predictable time+pid-seeded rand() identifier. */ + ssize_t g = getrandom(raw, sizeof(raw), 0); + if (g == (ssize_t)sizeof(raw)) { + for (int i = 0; i < (int)sizeof(raw); i++) + snprintf(buf + i * 2, 3, "%02x", raw[i]); - unsigned int seed = - (unsigned int)(ts.tv_nsec ^ ts.tv_sec ^ getpid() ^ getppid()); - - srand(seed); - seeded = 1; + buf[DS_UUID_LEN] = '\0'; + return 0; } - for (int i = 0; i < DS_UUID_LEN / 2; i++) - raw[i] = (unsigned char)(rand() & 0xFF); - - for (int i = 0; i < (int)sizeof(raw); i++) - snprintf(buf + i * 2, 3, "%02x", raw[i]); - - buf[DS_UUID_LEN] = '\0'; - return 0; + return -1; } /* --------------------------------------------------------------------------- From 43712064449848a75cc128ceabe4a472673e2959 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:20:38 +0530 Subject: [PATCH 134/211] fix(security): validate port_forwards proto in the config parser The config-file port_forwards parser copied the optional /proto suffix verbatim, while the CLI --port path rejects anything but tcp/udp. There is no injection (rules are executed via execvp with an argv vector), but the inconsistent validation let a bogus proto reach iptables and the state file. Mirror the CLI check: warn and skip the entry when proto is not tcp or udp. Signed-off-by: ravindu644 --- src/config.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/config.c b/src/config.c index 2a658e0c..af9cf4ff 100644 --- a/src/config.c +++ b/src/config.c @@ -462,6 +462,15 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { * Returns 1 on success, 0 on parse/range error. */ int valid = 1; + /* Only tcp/udp are valid protocols; the CLI --port path rejects + * anything else, so mirror that here instead of storing a bogus proto + * that would later be handed to iptables. */ + if (strcmp(pf->proto, "tcp") != 0 && strcmp(pf->proto, "udp") != 0) { + ds_warn("config: invalid protocol '%s' in port_forwards - skipping", + pf->proto); + valid = 0; + } + /* Host side */ { char *dash = strchr(host_side, '-'); From 5fd8a2fa30e99b3da34b7784208b8ba460a82ba2 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:21:25 +0530 Subject: [PATCH 135/211] fix(security): scrub LD_*/IFS env before the daemon re-execs as root daemonize() set a clean PATH but left LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT and IFS intact; handle_session() re-execs droidspaces as root inheriting the daemon environment. The env comes from the root launcher rather than the socket, so this is defense-in-depth, but a hardened root daemon should not carry linker-injection vars. unsetenv() them in daemonize(). Signed-off-by: ravindu644 --- src/daemon.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/daemon.c b/src/daemon.c index aaeffe4d..f37327fd 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -806,6 +806,15 @@ static void daemonize(int foreground) { if (chdir("/") < 0) { /* ignore */ } + /* Scrub dynamic-linker and field-splitting env vars before serving requests + * and re-exec'ing droidspaces as root, so a value inherited from the launcher + * cannot influence the privileged process (defense-in-depth; container init + * additionally receives a clearenv() in ds_env_boot_setup). */ + unsetenv("LD_PRELOAD"); + unsetenv("LD_LIBRARY_PATH"); + unsetenv("LD_AUDIT"); + unsetenv("IFS"); + if (!foreground) { /* redirect standard streams */ int dn = open("/dev/null", O_RDONLY); From 70ef5f406d0361c4a67a2603e9a1e1115aeb11dc Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:21:53 +0530 Subject: [PATCH 136/211] fix(security): use async-signal-safe write() in SIGINT handler handle_sigint() called printf(), which is not async-signal-safe and can deadlock on the stdio lock if the signal arrives mid-printf. Emit the show-cursor escape via write(STDOUT_FILENO, ...) like the adjacent clear-screen write. Signed-off-by: ravindu644 --- src/documentation.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/documentation.c b/src/documentation.c index fc469a1f..5a74da22 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -459,7 +459,9 @@ static void handle_sigint(int sig) { (void)sig; if (g_old_tios_ptr) tcsetattr(STDIN_FILENO, TCSAFLUSH, g_old_tios_ptr); - printf("\033[?25h"); /* SHOW_CURSOR */ + /* Async-signal-safe: printf is not, so write the escape directly. */ + if (write(STDOUT_FILENO, "\033[?25h", 6) < 0) { /* SHOW_CURSOR; ignore */ + } if (write(STDOUT_FILENO, "\033[2J\033[H", 7) < 0) { /* ignore */ } From 86c2f5884a772325a0368b35c7df6b0f82080549 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:22:55 +0530 Subject: [PATCH 137/211] fix(security): validate container_ip before the iptables shell sweep ds_ipt_remove_portforwards() Pass 3 interpolates container_ip into four sh -c commands -- the only shell-command path in the networking code. container_ip is already strictly validated to 172.28.x.x at every ingest point, so it is not currently exploitable, but the function itself did no local check. Re-validate it with inet_pton() at the point of use and skip the shell sweep on anything that is not a plain IPv4 literal, so no shell metacharacter can reach sh even if an unvalidated caller is ever added. Signed-off-by: ravindu644 --- src/net/iptables.c | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/net/iptables.c b/src/net/iptables.c index ec9586de..7d524ee9 100644 --- a/src/net/iptables.c +++ b/src/net/iptables.c @@ -1612,7 +1612,18 @@ int ds_ipt_remove_portforwards(struct ds_config *cfg) { * process crashed before the file could be written. * We intentionally avoid this path in the normal case: parsing iptables-save * output through a shell is slower and depends on the host having sh(1). */ - if (!had_state) { + /* Defense-in-depth: container_ip is already validated to 172.28.x.x at every + * ingest point, but Pass 3 is the only path that interpolates it into a shell + * command. Re-validate it as a plain IPv4 literal and skip the sweep + * otherwise, so no shell metacharacter can ever reach sh -c. */ + struct in_addr ip_check; + int ip_ok = (inet_pton(AF_INET, container_ip, &ip_check) == 1); + if (!had_state && !ip_ok) + ds_warn("Skipping iptables-save shell sweep: container IP '%s' is not a " + "valid IPv4 literal", + container_ip); + + if (!had_state && ip_ok) { char cmd[512]; /* Remove PREROUTING DNAT rules whose --to-destination targets this IP */ From 247e6ca4f7ca8b486f34f5c577d030d5bd1ed7e9 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:23:40 +0530 Subject: [PATCH 138/211] fix(security): guard nl_nest_end against a NULL nest pointer nl_nest_begin() returns NULL when appending would exceed the buffer, but callers passed the result straight to nl_nest_end(), which dereferenced it to fix up rta_len -- a NULL write on buffer exhaustion. Unreachable today (names are short and fixed) but a latent trap if buffers or inputs change. Guard nl_nest_end() so every caller is covered; the kernel then rejects the incomplete message rather than the process crashing. Signed-off-by: ravindu644 --- src/net/netlink.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/net/netlink.c b/src/net/netlink.c index 0222ac4a..61766c36 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -73,6 +73,12 @@ static struct rtattr *nl_nest_begin(struct nlmsghdr *n, int maxlen, int type) { /* Fix up the length of a nested rtattr opened by nl_nest_begin */ static void nl_nest_end(struct nlmsghdr *n, struct rtattr *nest) { + /* nl_nest_begin() returns NULL if the buffer was exhausted; guard here so + * every caller is covered without a NULL write. The message is then built + * without the nest and the kernel rejects it (EINVAL) rather than crashing. + */ + if (!nest) + return; nest->rta_len = (unsigned short)((uint8_t *)NLMSG_TAIL(n) - (uint8_t *)nest); } From e16807355c659ef5bf3bd5486322c359f4e590b1 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:26:39 +0530 Subject: [PATCH 139/211] fix(security): bounds-check 4-byte netlink attribute reads RTA_OK() validates an attribute's declared length but not that its payload is a full 4 bytes, so the *(int*)/*(uint32_t*)RTA_DATA reads for IFLA_MASTER, RTA_TABLE/OIF/PRIORITY and FRA_TABLE/PRIORITY/FWMARK/FWMASK could read up to 3 bytes past a truncated attribute. The data source is the host kernel (always full-width), so this is defensive hardening. Add nl_rta_u32() which returns 0 for a short attribute and reads via memcpy (also avoiding unaligned access), and route all such reads through it; guard the equivalent read in network.c's route monitor inline. Signed-off-by: ravindu644 --- src/net/netlink.c | 33 +++++++++++++++++++++++---------- src/net/network.c | 5 ++++- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/net/netlink.c b/src/net/netlink.c index 61766c36..0a2af65d 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -82,6 +82,19 @@ static void nl_nest_end(struct nlmsghdr *n, struct rtattr *nest) { nest->rta_len = (unsigned short)((uint8_t *)NLMSG_TAIL(n) - (uint8_t *)nest); } +/* Read a 4-byte netlink attribute payload safely. RTA_OK() bounds only the + * declared attribute length, not that the payload is a full 4 bytes, so a + * truncated/malformed attribute would otherwise be read out of bounds. + * Returns 0 for a short attribute. (Defensive: the source is the host kernel, + * which always emits full-width attributes.) memcpy also avoids any unaligned + * access. */ +static uint32_t nl_rta_u32(struct rtattr *rta) { + uint32_t v = 0; + if (RTA_PAYLOAD(rta) >= (int)sizeof(v)) + memcpy(&v, RTA_DATA(rta), sizeof(v)); + return v; +} + /* --------------------------------------------------------------------------- * Context lifecycle * ---------------------------------------------------------------------------*/ @@ -908,7 +921,7 @@ int ds_nl_count_bridge_members_with_prefix(ds_nl_ctx_t *ctx, const char *bridge, if (rta->rta_type == IFLA_IFNAME) safe_strncpy(ifname, RTA_DATA(rta), IFNAMSIZ); else if (rta->rta_type == IFLA_MASTER) - master = *(int *)RTA_DATA(rta); + master = (int)nl_rta_u32(rta); } if (master == (int)br_idx && ifname[0] && strncmp(ifname, prefix, prefix_len) == 0) @@ -997,9 +1010,9 @@ int ds_nl_get_iface_table(ds_nl_ctx_t *ctx, const char *ifname, int rlen = (int)RTM_PAYLOAD(h); for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { if (rta->rta_type == RTA_TABLE) - r_table = *(int *)RTA_DATA(rta); + r_table = (int)nl_rta_u32(rta); if (rta->rta_type == RTA_OIF) - r_oif = *(int *)RTA_DATA(rta); + r_oif = (int)nl_rta_u32(rta); } if ((unsigned int)r_oif == target_idx) { @@ -1081,11 +1094,11 @@ int ds_nl_get_table_default_oif(ds_nl_ctx_t *ctx, int table, char *ifname_out) { int rlen = (int)RTM_PAYLOAD(h); for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { if (rta->rta_type == RTA_TABLE) - r_table = *(int *)RTA_DATA(rta); + r_table = (int)nl_rta_u32(rta); if (rta->rta_type == RTA_OIF) - r_oif = *(int *)RTA_DATA(rta); + r_oif = (int)nl_rta_u32(rta); if (rta->rta_type == RTA_PRIORITY) - r_metric = *(uint32_t *)RTA_DATA(rta); + r_metric = nl_rta_u32(rta); } if (r_table != table || r_oif <= 0) @@ -1184,17 +1197,17 @@ int ds_nl_get_android_default(ds_nl_ctx_t *ctx, char *ifname_out, for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { switch (rta->rta_type) { case FRA_TABLE: - r_table = (int)*(uint32_t *)RTA_DATA(rta); + r_table = (int)nl_rta_u32(rta); break; case FRA_PRIORITY: - r_prio = *(uint32_t *)RTA_DATA(rta); + r_prio = nl_rta_u32(rta); break; case FRA_FWMARK: - fwmark = *(uint32_t *)RTA_DATA(rta); + fwmark = nl_rta_u32(rta); have_mark = 1; break; case FRA_FWMASK: - fwmask = *(uint32_t *)RTA_DATA(rta); + fwmask = nl_rta_u32(rta); have_mask = 1; break; case FRA_IIFNAME: diff --git a/src/net/network.c b/src/net/network.c index 12090d9f..103e2d0d 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -2028,7 +2028,10 @@ static void *route_monitor_loop(void *arg) { struct rtattr *rta = RTM_RTA(rtm); int rlen = (int)RTM_PAYLOAD(h); for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { - if (rta->rta_type == RTA_OIF) + /* RTA_OK bounds only the declared length; require a full 4-byte + * payload before dereferencing (defensive; kernel-sourced). */ + if (rta->rta_type == RTA_OIF && + RTA_PAYLOAD(rta) >= (int)sizeof(int)) oif = *(int *)RTA_DATA(rta); } char evname[IFNAMSIZ] = {0}; From a8a0a292f6303729853d9a62331192c20d9e7037 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:28:19 +0530 Subject: [PATCH 140/211] fix(security): validate container mount marker before host bookkeeping ds_metadata_sync() reconstructs the host mount sidecar from /run/droidspaces/mount read inside the container's own root, then saves it via save_mount_path(); that value later feeds unmount_rootfs_img() / umount2() on the host. A compromised container could plant an arbitrary path there and make the host unmount an unrelated mount point. A legitimate image mount is deterministically written as DS_IMG_MOUNT_ROOT_UNIVERSAL/ (i.e. /mnt/Droidspaces/, from find_available_mountpoint), so require the marker to match that exact path for this container and ignore anything else. Exact match also prevents a container from pointing at another container's mount. Signed-off-by: ravindu644 --- src/pid.c | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/pid.c b/src/pid.c index 1460d492..b5e71208 100644 --- a/src/pid.c +++ b/src/pid.c @@ -678,7 +678,22 @@ int ds_metadata_sync(pid_t pid) { build_proc_root_path(pid, "/run/droidspaces/mount", path, sizeof(path)); if (read_file(path, mount, sizeof(mount)) >= 0) { mount[strcspn(mount, "\n")] = '\0'; - save_mount_path(pidfile, mount); + /* This marker is read from inside the container's own root, so a + * compromised container could plant an arbitrary path; it later feeds + * unmount_rootfs_img()/umount2() on the host. A legitimate image mount + * is deterministically DS_IMG_MOUNT_ROOT_UNIVERSAL/ + * (find_available_mountpoint), so require an exact match and ignore + * anything else -- this also prevents pointing at another container's + * mount. */ + char expected_mount[PATH_MAX]; + snprintf(expected_mount, sizeof(expected_mount), "%s/%s", + DS_IMG_MOUNT_ROOT_UNIVERSAL, safe_name); + if (strcmp(mount, expected_mount) == 0) + save_mount_path(pidfile, mount); + else if (mount[0]) + ds_warn("Ignoring unexpected mount marker '%s' from container %d " + "(expected %s)", + mount, pid, expected_mount); } } From f85f2503972ba545819ce638c418153275332c36 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:57:47 +0530 Subject: [PATCH 141/211] refactor: remove dead payload-drain block and stale TODO in socketd bridge Delete the #if 0 / #endif payload-drain block sitting in the connection dispatch hot path, and replace the lingering 'could be an alias via __attribute__' TODO on socketd_ntoh64 with a plain explanatory comment. No behavioral change. Signed-off-by: ravindu644 --- src/socketd_bridge.c | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 5158aa80..6dd5cb97 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -171,8 +171,8 @@ static uint64_t socketd_hton64(uint64_t value) { #endif } static uint64_t socketd_ntoh64(uint64_t value) { - return socketd_hton64( - value); // The byte swap is symmetric. Could be an alias via __attribute__ + /* Network<->host 64-bit swap is symmetric, so this just forwards. */ + return socketd_hton64(value); } static int socketd_read_proc_start_ticks(pid_t pid, @@ -1004,17 +1004,6 @@ static void socketd_handle_conn(int conn) { return; } - /* - * The currently implemented opcodes do not consume payloads, but draining - * a well-sized payload keeps the framing strict and future-proofs callers. - */ -#if 0 - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); - return; - } -#endif // deprecated - switch ((enum ds_socketd_opcode)opcode) { case DS_SOCKETD_OP_PING: { if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { From 6638be2c7edc94e0711b41229924b40b657713ef Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 13:58:35 +0530 Subject: [PATCH 142/211] refactor: drop premature ds_config_free in start_rootfs boot-failure path The !booted branch freed cfg and then jumped to the cleanup: block, which dereferences cfg (cleanup_container_resources) and frees it again. It was only safe because the free helpers NULL their pointers and cleanup reads inline scalars, but it is a read-after-free / double-free waiting for the first freed field that cleanup touches. Let the single cleanup: block own all teardown. Signed-off-by: ravindu644 --- src/container.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/container.c b/src/container.c index f7cc6a5a..5207e6c1 100644 --- a/src/container.c +++ b/src/container.c @@ -744,9 +744,9 @@ int start_rootfs(struct ds_config *cfg) { ds_error("Container failed to boot correctly."); /* If pid is still alive, we might want to kill it, but monitor usually * handles this. Let's just return error so parent doesn't report - * success. - */ - ds_config_free(cfg); + * success. Teardown (including ds_config_free) is owned by the single + * cleanup: block below - freeing here would leave cleanup reading a + * freed cfg and then double-free it. */ goto cleanup; } From 296c38053b4760856942639b9e4e52b1c46dac51 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:00:19 +0530 Subject: [PATCH 143/211] refactor: free cur_base on OOM early-return in remove_matching_rules The new_blob malloc-failure path returned -ENOMEM directly, bypassing the free(cur_base) at the function tail. On an EAGAIN refetch iteration cur_base owns a get_table()-allocated blob, so that early return leaked it. Free it before returning, matching the cleanup every other exit path already performs. Signed-off-by: ravindu644 --- src/net/iptables.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/net/iptables.c b/src/net/iptables.c index 7d524ee9..c13e7e86 100644 --- a/src/net/iptables.c +++ b/src/net/iptables.c @@ -468,8 +468,13 @@ static int remove_matching_rules(int fd, const char *table_name, while (max_retries-- > 0) { unsigned char *new_blob = malloc(cur_info.size); - if (!new_blob) + if (!new_blob) { + /* On an EAGAIN refetch iteration cur_base owns a get_table() blob; free + * it before bailing so the early return does not leak it. */ + if (cur_base) + free(cur_base); return -ENOMEM; + } /* Allocate per-entry tracking arrays */ unsigned int *old_offsets = From 3102b970e9f85f4145807ef0694cb26b61c90941 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:01:17 +0530 Subject: [PATCH 144/211] refactor: unify pidfile sidecar path builders pidfile_to_mountfile() and pidfile_to_initfile() were byte-identical apart from the extension macro. Extract pidfile_to_sidecar(pidfile, ext, ...) and make both thin wrappers over it. As a side benefit the extension is now passed as a "%s" argument rather than as snprintf's format string, removing the latent format-string footgun if an extension macro ever gained a percent sign. Signed-off-by: ravindu644 --- src/utils.c | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/utils.c b/src/utils.c index 458ee0c4..a18d4122 100644 --- a/src/utils.c +++ b/src/utils.c @@ -737,18 +737,21 @@ int read_and_validate_pid(const char *pidfile, pid_t *pid_out) { * Mount sidecar files (.mount) * ---------------------------------------------------------------------------*/ -/* Internal helper to convert pidfile path to mount sidecar path: foo.pid -> - * foo.mount */ -static void pidfile_to_mountfile(const char *pidfile, char *buf, size_t size) { +/* Convert a pidfile path to a sibling sidecar path with the given extension: + * foo.pid -> foo (replacing a trailing .pid, else appending). */ +static void pidfile_to_sidecar(const char *pidfile, const char *ext, char *buf, + size_t size) { safe_strncpy(buf, pidfile, size); char *dot = strrchr(buf, '.'); - if (dot && strcmp(dot, DS_EXT_PID) == 0) { - /* If it ends in .pid, replace it */ - snprintf(dot, size - (size_t)(dot - buf), DS_EXT_MOUNT); - } else { - /* Otherwise just append */ - strncat(buf, DS_EXT_MOUNT, size - strlen(buf) - 1); - } + if (dot && strcmp(dot, DS_EXT_PID) == 0) + snprintf(dot, size - (size_t)(dot - buf), "%s", ext); + else + strncat(buf, ext, size - strlen(buf) - 1); +} + +/* foo.pid -> foo.mount */ +static void pidfile_to_mountfile(const char *pidfile, char *buf, size_t size) { + pidfile_to_sidecar(pidfile, DS_EXT_MOUNT, buf, size); } /* Save mount path alongside a pidfile: foo.pid -> foo.mount */ @@ -774,16 +777,9 @@ int remove_mount_path(const char *pidfile) { * Init-type sidecar files (.init) * ---------------------------------------------------------------------------*/ +/* foo.pid -> foo.init */ static void pidfile_to_initfile(const char *pidfile, char *buf, size_t size) { - safe_strncpy(buf, pidfile, size); - char *dot = strrchr(buf, '.'); - if (dot && strcmp(dot, DS_EXT_PID) == 0) { - /* If it ends in .pid, replace it */ - snprintf(dot, size - (size_t)(dot - buf), DS_EXT_INIT); - } else { - /* Otherwise just append */ - strncat(buf, DS_EXT_INIT, size - strlen(buf) - 1); - } + pidfile_to_sidecar(pidfile, DS_EXT_INIT, buf, size); } static const char *init_type_to_string(ds_init_type_t type) { From 9a6856b2d74d66df8c7768f8f41df2f5a176f242 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:01:57 +0530 Subject: [PATCH 145/211] refactor: use in-tree remove_recursive to clear stale PulseAudio config nuke_pulse_config() forked and exec'd /system/bin/rm -rf to delete the stale runtime dir. The tree already has remove_recursive() (nftw-based), so replace the whole fork/exec/waitpid dance with a single call - no external binary dependency and no child plumbing. Signed-off-by: ravindu644 --- src/android/pulseaudio.c | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/android/pulseaudio.c b/src/android/pulseaudio.c index 934bca24..d16a34c1 100644 --- a/src/android/pulseaudio.c +++ b/src/android/pulseaudio.c @@ -201,22 +201,7 @@ static void nuke_pulse_config(void) { return; ds_log("[PulseAudio] nuking stale config: %s", PA_CONFIG_DIR); - - pid_t child = fork(); - if (child < 0) - return; - if (child == 0) { - int devnull = open("/dev/null", O_RDWR); - if (devnull >= 0) { - dup2(devnull, STDOUT_FILENO); - dup2(devnull, STDERR_FILENO); - close(devnull); - } - execl("/system/bin/rm", "rm", "-rf", PA_CONFIG_DIR, NULL); - _exit(1); - } - int st; - waitpid(child, &st, 0); + remove_recursive(PA_CONFIG_DIR); } /* ---- spawn ------------------------------------------------------------ */ From eef4db0632af2f4fe5c9d29f1c40b468604a64ef Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:03:57 +0530 Subject: [PATCH 146/211] refactor: extract generic socketd_grow_array for record buffers The three record-append helpers (container, image, core-event) were byte-identical except the element type: same capacity doubling, wrap check, DS_SOCKETD_MAX_PAYLOAD bound, realloc and zero-fill. Factor the growth into socketd_grow_array(records, count, cap, elem_size); each append now just grows then does its typed copy. Same behavior, one place to get the bounds math right. Signed-off-by: ravindu644 --- src/socketd_bridge.c | 108 +++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 72 deletions(-) diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 6dd5cb97..142500ca 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -559,35 +559,43 @@ static void socketd_pack_inspect_record( socketd_pack_port_record(&record->ports[i], &cfg->port_forwards[i]); } -static int socketd_append_container_record( - struct ds_socketd_container_record **records_inout, size_t *count_inout, - size_t *capacity_inout, const struct ds_socketd_container_record *record) { - if (!records_inout || !count_inout || !capacity_inout || !record) +/* Ensure *records_inout has room for one more elem_size element, doubling the + * capacity (min 16) with wrap and DS_SOCKETD_MAX_PAYLOAD bounds checks and + * zero-filling the newly grown tail. Returns 0 on success, -1 on failure. */ +static int socketd_grow_array(void **records_inout, size_t *count_inout, + size_t *capacity_inout, size_t elem_size) { + if (!records_inout || !count_inout || !capacity_inout || elem_size == 0) return -1; - if (*count_inout >= *capacity_inout) { - size_t old_capacity = *capacity_inout; - size_t new_capacity = old_capacity == 0 ? 16u : old_capacity * 2u; + if (*count_inout < *capacity_inout) + return 0; - if (new_capacity < old_capacity) - return -1; + size_t old_capacity = *capacity_inout; + size_t new_capacity = old_capacity == 0 ? 16u : old_capacity * 2u; + if (new_capacity < old_capacity) + return -1; + if (new_capacity > DS_SOCKETD_MAX_PAYLOAD / elem_size) + return -1; - if (new_capacity > - DS_SOCKETD_MAX_PAYLOAD / sizeof(struct ds_socketd_container_record)) { - return -1; - } + void *grown = realloc(*records_inout, new_capacity * elem_size); + if (!grown) + return -1; - struct ds_socketd_container_record *grown = - realloc(*records_inout, new_capacity * sizeof(*grown)); - if (!grown) - return -1; + memset((char *)grown + old_capacity * elem_size, 0, + (new_capacity - old_capacity) * elem_size); - memset(grown + old_capacity, 0, - (new_capacity - old_capacity) * sizeof(*grown)); + *records_inout = grown; + *capacity_inout = new_capacity; + return 0; +} - *records_inout = grown; - *capacity_inout = new_capacity; - } +static int socketd_append_container_record( + struct ds_socketd_container_record **records_inout, size_t *count_inout, + size_t *capacity_inout, const struct ds_socketd_container_record *record) { + if (!record || + socketd_grow_array((void **)records_inout, count_inout, capacity_inout, + sizeof(**records_inout)) < 0) + return -1; (*records_inout)[*count_inout] = *record; (*count_inout)++; @@ -630,33 +638,11 @@ static int socketd_append_image_record(struct ds_socketd_image_record **records_inout, size_t *count_inout, size_t *capacity_inout, const struct ds_socketd_image_record *record) { - if (!records_inout || !count_inout || !capacity_inout || !record) + if (!record || + socketd_grow_array((void **)records_inout, count_inout, capacity_inout, + sizeof(**records_inout)) < 0) return -1; - if (*count_inout >= *capacity_inout) { - size_t old_capacity = *capacity_inout; - size_t new_capacity = old_capacity == 0 ? 16u : old_capacity * 2u; - - if (new_capacity < old_capacity) - return -1; - - if (new_capacity > - DS_SOCKETD_MAX_PAYLOAD / sizeof(struct ds_socketd_image_record)) { - return -1; - } - - struct ds_socketd_image_record *grown = - realloc(*records_inout, new_capacity * sizeof(*grown)); - if (!grown) - return -1; - - memset(grown + old_capacity, 0, - (new_capacity - old_capacity) * sizeof(*grown)); - - *records_inout = grown; - *capacity_inout = new_capacity; - } - (*records_inout)[*count_inout] = *record; (*count_inout)++; return 0; @@ -665,33 +651,11 @@ socketd_append_image_record(struct ds_socketd_image_record **records_inout, static int socketd_append_core_event_record( struct ds_socketd_core_event_record **records_inout, size_t *count_inout, size_t *capacity_inout, const struct ds_socketd_core_event_record *record) { - if (!records_inout || !count_inout || !capacity_inout || !record) + if (!record || + socketd_grow_array((void **)records_inout, count_inout, capacity_inout, + sizeof(**records_inout)) < 0) return -1; - if (*count_inout >= *capacity_inout) { - size_t old_capacity = *capacity_inout; - size_t new_capacity = old_capacity == 0 ? 16u : old_capacity * 2u; - - if (new_capacity < old_capacity) - return -1; - - if (new_capacity > - DS_SOCKETD_MAX_PAYLOAD / sizeof(struct ds_socketd_core_event_record)) { - return -1; - } - - struct ds_socketd_core_event_record *grown = - realloc(*records_inout, new_capacity * sizeof(*grown)); - if (!grown) - return -1; - - memset(grown + old_capacity, 0, - (new_capacity - old_capacity) * sizeof(*grown)); - - *records_inout = grown; - *capacity_inout = new_capacity; - } - (*records_inout)[*count_inout] = *record; (*count_inout)++; return 0; From 0508150981f9adf6ac038664a0c8426e0da6a560 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:05:34 +0530 Subject: [PATCH 147/211] refactor: share forked-daemon-child preamble across A/V helpers pulse_child_wrapper, virgl_child_wrapper and xserver_child_wrapper each repeated the same four signal(...SIG_IGN) calls plus ds_oom_protect(). Extract ds_daemon_child_preamble() and call it from all three. The per-helper SELinux handling (which genuinely differs) stays local. Signed-off-by: ravindu644 --- src/android/pulseaudio.c | 11 +---------- src/android/virgl.c | 11 +---------- src/android/x11.c | 13 +------------ src/include/droidspace.h | 4 ++++ src/utils.c | 11 +++++++++++ 5 files changed, 18 insertions(+), 32 deletions(-) diff --git a/src/android/pulseaudio.c b/src/android/pulseaudio.c index d16a34c1..06bf02a3 100644 --- a/src/android/pulseaudio.c +++ b/src/android/pulseaudio.c @@ -86,16 +86,7 @@ struct pulse_args { static void pulse_child_wrapper(int ready_fd, void *user_data) { struct pulse_args *args = (struct pulse_args *)user_data; - /* Ignore hangups, keyboard interrupts, and broken pipes to keep the daemon - * alive through terminal disconnects. SIGTERM is our shutdown signal. */ - signal(SIGHUP, SIG_IGN); - signal(SIGINT, SIG_IGN); - signal(SIGQUIT, SIG_IGN); - signal(SIGPIPE, SIG_IGN); - - /* Make PulseAudio unkillable by the OOM killer. - * Must be done while still root, before privilege drop. */ - ds_oom_protect(); + ds_daemon_child_preamble(); /* Set up the Termux environment before dropping privileges */ setenv("TMPDIR", TX11_PREFIX "/tmp", 1); diff --git a/src/android/virgl.c b/src/android/virgl.c index bba53cf1..7349e366 100644 --- a/src/android/virgl.c +++ b/src/android/virgl.c @@ -31,16 +31,7 @@ struct virgl_args { static void virgl_child_wrapper(int ready_fd, void *user_data) { struct virgl_args *args = (struct virgl_args *)user_data; - /* Ignore hangups, keyboard interrupts, and broken pipes to make the server - * process robust and persistent (except for SIGTERM which we use to stop it). - */ - signal(SIGHUP, SIG_IGN); - signal(SIGINT, SIG_IGN); - signal(SIGQUIT, SIG_IGN); - signal(SIGPIPE, SIG_IGN); - - /* Make VirGL server unkillable */ - ds_oom_protect(); + ds_daemon_child_preamble(); /* Enter droidspacesd domain -- best-effort, fallback on pre-reboot */ ds_selinux_enter_domain(); diff --git a/src/android/x11.c b/src/android/x11.c index 9472d68f..2b686ff3 100644 --- a/src/android/x11.c +++ b/src/android/x11.c @@ -56,18 +56,7 @@ struct xserver_args { static void xserver_child_wrapper(int ready_fd, void *user_data) { struct xserver_args *args = (struct xserver_args *)user_data; - /* Ignore hangups, keyboard interrupts, and broken pipes to make the server - * process robust and persistent (except for SIGTERM which we use to stop it). - */ - signal(SIGHUP, SIG_IGN); - signal(SIGINT, SIG_IGN); - signal(SIGQUIT, SIG_IGN); - signal(SIGPIPE, SIG_IGN); - - /* Make Termux-X11 server unkillable. - * This must be done while we are still running as root (before dropping - * privileges). */ - ds_oom_protect(); + ds_daemon_child_preamble(); char ctx[256]; if (get_selinux_context(TX11_DATA_DIR, ctx, sizeof(ctx)) < 0 && diff --git a/src/include/droidspace.h b/src/include/droidspace.h index b2051209..d8c265b0 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -530,6 +530,10 @@ void ds_global_daemon_stop(int (*check_fn)(void), pid_t cached_pid, pid_t *pid_out, const char *pidfile, const char *sock_path, const char *tag); void ds_oom_protect(void); +/* Common preamble for a forked long-lived daemon child (audio/GPU/X11 helper): + * ignore terminal-disconnect signals so it outlives the launching session and + * protect it from the OOM killer. Must run while still root. */ +void ds_daemon_child_preamble(void); /* Return 1 if peer_pid shares this process's PID namespace (or if it cannot be * determined), 0 only on a positive mismatch. Used by the abstract-socket * authorizers to reject host-net container peers. */ diff --git a/src/utils.c b/src/utils.c index a18d4122..e3f9f9db 100644 --- a/src/utils.c +++ b/src/utils.c @@ -2528,6 +2528,17 @@ void ds_oom_protect(void) { } } +void ds_daemon_child_preamble(void) { + /* Ignore hangups, keyboard interrupts, and broken pipes so the helper + * survives terminal disconnects (SIGTERM remains the shutdown signal). */ + signal(SIGHUP, SIG_IGN); + signal(SIGINT, SIG_IGN); + signal(SIGQUIT, SIG_IGN); + signal(SIGPIPE, SIG_IGN); + /* Protect from the OOM killer while still root, before any privilege drop. */ + ds_oom_protect(); +} + int ds_peer_in_pidns(pid_t peer_pid) { if (peer_pid <= 0) return 1; /* unknown -> fail open */ From 95d27468f62856807121cc60fc3b20cfaab8de0c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:08:44 +0530 Subject: [PATCH 148/211] refactor: share Termux socket-bridge setup for PulseAudio and VirGL ds_setup_pulse_socket and ds_setup_virgl_socket ran the identical dance: build the DS_TERMUX_TMP_OLDROOT/ source path, stat it, bind-mount via ds_bind_mount_socket owned by the socket's uid, log, and setenv. Extract ds_bridge_termux_socket(leaf, dst, env_key, env_val, label); both setups are now a single call. The X11 bridge is left as-is: it additionally creates and chmods the container socket dir and logs differently. Signed-off-by: ravindu644 --- src/android/pulseaudio.c | 29 +++++------------------------ src/android/virgl.c | 28 ++++------------------------ src/include/droidspace.h | 6 ++++++ src/utils.c | 20 ++++++++++++++++++++ 4 files changed, 35 insertions(+), 48 deletions(-) diff --git a/src/android/pulseaudio.c b/src/android/pulseaudio.c index 06bf02a3..247632f9 100644 --- a/src/android/pulseaudio.c +++ b/src/android/pulseaudio.c @@ -263,28 +263,9 @@ int ds_setup_pulse_socket(struct ds_config *cfg) { if (!is_android() || !cfg->pulseaudio) return 0; - /* Post-pivot_root: host filesystem is under /.old_root. - * TX11_PULSE_SOCKET lives in TX11_PREFIX/tmp, accessed via - * DS_TERMUX_TMP_OLDROOT, exactly like the VirGL socket. */ - char src[PATH_MAX]; - snprintf(src, sizeof(src), "%s/.pulse-socket", DS_TERMUX_TMP_OLDROOT); - - struct stat st; - if (stat(src, &st) != 0) { - ds_warn("PulseAudio: socket not found at %s - skipping socket bridge", src); - return 0; - } - - uid_t uid = st.st_uid; - - if (ds_bind_mount_socket(src, DS_PULSE_SOCKET, uid, "PulseAudio") < 0) - return 0; - - ds_log("PulseAudio: socket bind-mounted into container"); - - /* Inject PULSE_SERVER so all processes inside the container find the daemon - */ - setenv("PULSE_SERVER", "unix:" DS_PULSE_SOCKET, 1); - - return 0; + /* Post-pivot_root: the host socket lives under DS_TERMUX_TMP_OLDROOT. Bridge + * it in and export PULSE_SERVER for all container processes. */ + return ds_bridge_termux_socket(".pulse-socket", DS_PULSE_SOCKET, + "PULSE_SERVER", "unix:" DS_PULSE_SOCKET, + "PulseAudio"); } diff --git a/src/android/virgl.c b/src/android/virgl.c index 7349e366..2b416969 100644 --- a/src/android/virgl.c +++ b/src/android/virgl.c @@ -137,28 +137,8 @@ int ds_setup_virgl_socket(struct ds_config *cfg) { if (!is_android() || !cfg->virgl) return 0; - /* Post-pivot_root: host filesystem is accessible under /.old_root. - * Use the same DS_TERMUX_TMP_OLDROOT prefix that X11 socket bridging uses, - * otherwise stat() will always fail since the raw host path no longer - * resolves inside the container's mount namespace. */ - char src[PATH_MAX]; - snprintf(src, sizeof(src), "%s/.virgl_test", DS_TERMUX_TMP_OLDROOT); - - struct stat st; - if (stat(src, &st) != 0) { - ds_warn("VirGL: socket not found at %s - skipping socket bridge", src); - return 0; - } - - uid_t uid = st.st_uid; - - if (ds_bind_mount_socket(src, DS_VIRGL_SOCKET, uid, "VirGL") < 0) - return 0; - - ds_log("VirGL: socket bind-mounted into container"); - - /* Set GALLIUM_DRIVER so mesa uses the virpipe backend for HW acceleration */ - setenv("GALLIUM_DRIVER", "virpipe", 1); - - return 0; + /* Post-pivot_root: the host socket lives under DS_TERMUX_TMP_OLDROOT. Bridge + * it in and set GALLIUM_DRIVER so mesa uses the virpipe backend. */ + return ds_bridge_termux_socket(".virgl_test", DS_VIRGL_SOCKET, + "GALLIUM_DRIVER", "virpipe", "VirGL"); } diff --git a/src/include/droidspace.h b/src/include/droidspace.h index d8c265b0..39015f3b 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -544,6 +544,12 @@ pid_t ds_spawn_daemon(ds_child_fn child_fn, void *user_data, const char *log_file, const char *tag, const char *label); int ds_bind_mount_socket(const char *src, const char *dst, uid_t uid, const char *label); +/* Bridge a Termux host socket (DS_TERMUX_TMP_OLDROOT/) into the container + * at dst (owned by the host socket's uid), then export env_key=env_val. + * Best-effort: warns and skips if the source is absent. Always returns 0. */ +int ds_bridge_termux_socket(const char *leaf, const char *dst, + const char *env_key, const char *env_val, + const char *label); /* --------------------------------------------------------------------------- * config.c diff --git a/src/utils.c b/src/utils.c index e3f9f9db..96201ffd 100644 --- a/src/utils.c +++ b/src/utils.c @@ -2724,3 +2724,23 @@ int ds_bind_mount_socket(const char *src, const char *dst, uid_t uid, } return 0; } + +int ds_bridge_termux_socket(const char *leaf, const char *dst, + const char *env_key, const char *env_val, + const char *label) { + char src[PATH_MAX]; + snprintf(src, sizeof(src), "%s/%s", DS_TERMUX_TMP_OLDROOT, leaf); + + struct stat st; + if (stat(src, &st) != 0) { + ds_warn("%s: socket not found at %s - skipping socket bridge", label, src); + return 0; + } + + if (ds_bind_mount_socket(src, dst, st.st_uid, label) < 0) + return 0; + + ds_log("%s: socket bind-mounted into container", label); + setenv(env_key, env_val, 1); + return 0; +} From 1660135e32ca3f1b3ef818fec2cf575b01c73a71 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:11:55 +0530 Subject: [PATCH 149/211] refactor: unify socket peer authorization into ds_peer_authorized The main daemon and the socketd bridge each implemented SO_PEERCRED + group-membership + PID-namespace authorization independently, and the two copies had already drifted (the daemon's getgrouplist handling was fixed in isolation). Extract ds_peer_authorized(fd, group_name) as the single implementation and call it from both. This also upgrades the bridge, which previously denied any user in more than 64 groups, to the same realloc-and-retry getgrouplist handling the daemon uses. Behavior is otherwise unchanged. Signed-off-by: ravindu644 --- src/daemon.c | 76 ++++++---------------------------------- src/include/droidspace.h | 3 ++ src/socketd_bridge.c | 51 ++------------------------- src/utils.c | 59 +++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 114 deletions(-) diff --git a/src/daemon.c b/src/daemon.c index f37327fd..20522546 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -1070,74 +1070,18 @@ int ds_daemon_run(int foreground, char **argv) { } /* - * authenticate the peer: only root or members of the 'droidspaces' group - * may connect. abstract socket has no filesystem permissions, so we - * enforce this via SO_PEERCRED + getgrouplist() -- same model as Docker's + * Authenticate the peer via the shared authorizer: only root or members of + * the 'droidspaces' group, and only from our own PID namespace (the + * abstract socket has no filesystem permissions). Same model as Docker's * unix group. */ - { -#define DS_GROUP "droidspaces" - struct ucred cred; - socklen_t clen = sizeof(cred); - if (getsockopt(conn, SOL_SOCKET, SO_PEERCRED, &cred, &clen) < 0) { - close(conn); - continue; - } - - int allowed = (cred.uid == 0); - if (!allowed) { - struct group *gr = getgrnam(DS_GROUP); - struct passwd *pw = getpwuid(cred.uid); - if (gr && pw) { - int ngroups = 64; - gid_t stackgroups[64]; - gid_t *groups = stackgroups; - gid_t *heapgroups = NULL; - /* getgrouplist() returns -1 when the user belongs to more than - * ngroups groups, leaving ngroups set to the required count while - * only the first 64 slots are filled. Iterating to that larger - * count would read past the stack buffer (OOB read -> possible auth - * bypass or a crash that kills the pre-fork daemon). Reallocate to - * the required size and retry so a legitimate member with many - * supplementary groups is still authorized. */ - if (getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups) < 0) { - heapgroups = malloc(sizeof(gid_t) * (size_t)ngroups); - if (heapgroups && getgrouplist(pw->pw_name, pw->pw_gid, heapgroups, - &ngroups) >= 0) { - groups = heapgroups; - } else { - ngroups = 0; /* fail closed */ - } - } - for (int i = 0; i < ngroups; i++) { - if (groups[i] == gr->gr_gid) { - allowed = 1; - break; - } - } - free(heapgroups); - } - } - - /* Abstract sockets are scoped to the network namespace, not the - * filesystem, so a HOST-net container -- sharing our netns but running in - * its own PID namespace and presenting uid 0 without a user namespace -- - * could otherwise pass the checks above. Reject peers outside our PID - * namespace; legitimate host clients are always inside it (in-container - * invocations use DS_NO_PROXY and never reach the daemon). */ - if (allowed && !ds_peer_in_pidns(cred.pid)) { - ds_warn("Rejected pid %d: not in daemon PID namespace", (int)cred.pid); - allowed = 0; - } - - if (!allowed) { - const char *msg = "permission denied: only root or '" DS_GROUP - "' group members may connect."; - send_frame(conn, MSG_ERR, msg, (uint32_t)strlen(msg)); - send_exit(conn, 1); - close(conn); - continue; - } + if (!ds_peer_authorized(conn, "droidspaces")) { + const char *msg = "permission denied: only root or 'droidspaces' group " + "members may connect."; + send_frame(conn, MSG_ERR, msg, (uint32_t)strlen(msg)); + send_exit(conn, 1); + close(conn); + continue; } pid_t h = fork(); diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 39015f3b..d15d176a 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -538,6 +538,9 @@ void ds_daemon_child_preamble(void); * determined), 0 only on a positive mismatch. Used by the abstract-socket * authorizers to reject host-net container peers. */ int ds_peer_in_pidns(pid_t peer_pid); +/* Authorize a connected AF_UNIX peer: allow root, or a member of group_name, + * but only when the peer shares our PID namespace. Returns 1 if authorized. */ +int ds_peer_authorized(int fd, const char *group_name); void ds_spawn_log_relay(int pipe_read_fd, const char *log_file, const char *tag); pid_t ds_spawn_daemon(ds_child_fn child_fn, void *user_data, diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 142500ca..23f53ca8 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -86,54 +86,9 @@ static int socketd_send_response(int fd, enum ds_socketd_status status, } static int socketd_peer_authorized(int fd) { -#ifdef SO_PEERCRED - struct ucred cred; - socklen_t cred_len = sizeof(cred); - memset(&cred, 0, sizeof(cred)); - - if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &cred_len) < 0) - return 0; - if (cred_len != sizeof(cred)) - return 0; - - /* - * Abstract sockets are scoped to the network namespace, not the filesystem, - * so a HOST-net container (sharing our netns but running in its own PID - * namespace, presenting uid 0 without a user namespace) could otherwise - * connect. Reject peers outside our PID namespace before any uid/group - * check; legitimate host clients are always inside it. - */ - if (!ds_peer_in_pidns(cred.pid)) - return 0; - - /* - * The abstract backend socket has no filesystem permissions, so enforce the - * same local authorization model used by the main Droidspaces daemon: root or - * members of the dedicated 'droidspaces' group may connect. - */ - if (cred.uid == 0) - return 1; - - struct group *gr = getgrnam(DS_SOCKETD_GROUP); - struct passwd *pw = getpwuid(cred.uid); - if (!gr || !pw) - return 0; - - int ngroups = 64; - gid_t groups[64]; - if (getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups) < 0) - return 0; - - for (int i = 0; i < ngroups; i++) { - if (groups[i] == gr->gr_gid) - return 1; - } - - return 0; -#else - (void)fd; - return 0; -#endif + /* Shared with the main daemon: root or a 'droidspaces' group member, and only + * from our own PID namespace (the abstract socket has no filesystem ACL). */ + return ds_peer_authorized(fd, DS_SOCKETD_GROUP); } static int socketd_discard_payload(int fd, uint32_t len) { diff --git a/src/utils.c b/src/utils.c index 96201ffd..f0df8334 100644 --- a/src/utils.c +++ b/src/utils.c @@ -8,7 +8,10 @@ #include "droidspace.h" #include "socketd_protocol.h" #include +#include +#include #include +#include #include #include @@ -2555,6 +2558,62 @@ int ds_peer_in_pidns(pid_t peer_pid) { return strcmp(self_ns, peer_ns) == 0; } +int ds_peer_authorized(int fd, const char *group_name) { +#ifdef SO_PEERCRED + struct ucred cred; + socklen_t clen = sizeof(cred); + memset(&cred, 0, sizeof(cred)); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &clen) < 0 || + clen != sizeof(cred)) + return 0; + + /* Abstract sockets are network-namespace scoped, so reject peers outside our + * PID namespace (host-net container guard) before any uid/group check. */ + if (!ds_peer_in_pidns(cred.pid)) + return 0; + + if (cred.uid == 0) + return 1; + + if (!group_name) + return 0; + struct group *gr = getgrnam(group_name); + struct passwd *pw = getpwuid(cred.uid); + if (!gr || !pw) + return 0; + + /* getgrouplist() returns -1 when the user is in more than ngroups groups, + * setting ngroups to the required count while filling only the first slots. + * Reallocate and retry so a member with many supplementary groups is still + * authorized instead of being read out of bounds / wrongly denied. */ + int ngroups = 64; + gid_t stackgroups[64]; + gid_t *groups = stackgroups; + gid_t *heapgroups = NULL; + int authorized = 0; + if (getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups) < 0) { + heapgroups = malloc(sizeof(gid_t) * (size_t)ngroups); + if (heapgroups && + getgrouplist(pw->pw_name, pw->pw_gid, heapgroups, &ngroups) >= 0) + groups = heapgroups; + else + ngroups = 0; /* fail closed */ + } + for (int i = 0; i < ngroups; i++) { + if (groups[i] == gr->gr_gid) { + authorized = 1; + break; + } + } + free(heapgroups); + return authorized; +#else + (void)fd; + (void)group_name; + return 0; +#endif +} + /* * Fork a log-relay child that reads from pipe_read_fd and writes timestamped * lines to / with [tag] prefix. The relay ignores all From 08e6540764c28960128560af9d7efec20b66939c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:13:00 +0530 Subject: [PATCH 150/211] refactor: compute doc nav-hint width by display columns, not byte fudge The nav hint padding used strlen(hint) - 8, where the magic 8 hand-cancels the extra bytes of the four 3-byte UTF-8 arrow glyphs. Any change to the hint wording broke the alignment. Count display columns directly (bytes that are not UTF-8 continuation bytes) instead. Signed-off-by: ravindu644 --- src/documentation.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/documentation.c b/src/documentation.c index 5a74da22..64c7180a 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -565,7 +565,14 @@ void print_documentation(const char *argv0) { " [←/→] Prev/Next [↑/↓] Scroll (%d/%d) [q] Quit", g_scroll_offset + 1, g_total_lines); printf("%s", hint); - for (int i = strlen(hint) - 8; i < width; i++) + /* Pad to the terminal width. hint contains multi-byte arrow glyphs, so + * count display columns (bytes that are not UTF-8 continuation bytes) + * rather than strlen, which would over-count and need a magic fudge. */ + int hint_cols = 0; + for (const char *p = hint; *p; p++) + if (((unsigned char)*p & 0xC0) != 0x80) + hint_cols++; + for (int i = hint_cols; i < width; i++) printf(" "); printf("%s", RESET_TERMINAL); From aa6c6ca31b6eadd88b5c3edc61a4d4bf55426e7c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:15:20 +0530 Subject: [PATCH 151/211] refactor: centralize tx11/virgl flag ownership in ds_config_free ds_config_free() freed binds/env/unknown lines but not tx11_extra_flags / virgl_extra_flags, which main freed separately after calling it - so every other ds_config_free() caller leaked those two strings. Free (and NULL) them inside ds_config_free(), drop main's separate frees, and replace the hand-rolled disk_cfg field frees in ds_config_save() with ds_config_free() (which also releases disk_cfg's env vars, previously leaked). Single owner now, no double free (the reset path already NULLs the fields). Signed-off-by: ravindu644 --- src/config.c | 9 +++++---- src/main.c | 2 -- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/config.c b/src/config.c index af9cf4ff..2cda084a 100644 --- a/src/config.c +++ b/src/config.c @@ -631,6 +631,10 @@ void ds_config_free(struct ds_config *cfg) { free_config_binds(cfg); free_config_env_vars(cfg); free_config_unknown_lines(cfg); + free(cfg->tx11_extra_flags); + cfg->tx11_extra_flags = NULL; + free(cfg->virgl_extra_flags); + cfg->virgl_extra_flags = NULL; } static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { @@ -829,10 +833,7 @@ int ds_config_save(const char *config_path, struct ds_config *cfg) { } free(buf_cfg); free(buf_disk); - free_config_binds(&disk_cfg); - free_config_unknown_lines(&disk_cfg); - free(disk_cfg.tx11_extra_flags); - free(disk_cfg.virgl_extra_flags); + ds_config_free(&disk_cfg); if (is_equal) { if (!cfg->config_file_existed) { diff --git a/src/main.c b/src/main.c index 41884aae..ed10dd98 100644 --- a/src/main.c +++ b/src/main.c @@ -1243,7 +1243,5 @@ int main(int argc, char **argv) { cleanup: ds_config_free(&cfg); - free(cfg.tx11_extra_flags); - free(cfg.virgl_extra_flags); return ret; } From e4280cba1d374aa70cd56673092212acf65fb5d9 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:22:25 +0530 Subject: [PATCH 152/211] refactor: call p_printf explicitly instead of #define printf in print_page print_page redefined printf to the pager wrapper (#define printf p_printf ... #undef printf) so its 143 page-body calls routed through the pager. Shadowing a libc function by macro is surprising and fragile. Rename those calls to p_printf() directly and drop the macro; the pager wrapper and its g_* scroll/dry-run state are unchanged, so output and paging behavior are identical. Signed-off-by: ravindu644 --- src/documentation.c | 412 ++++++++++++++++++++++---------------------- 1 file changed, 207 insertions(+), 205 deletions(-) diff --git a/src/documentation.c b/src/documentation.c index 64c7180a..1295fc07 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -179,242 +179,244 @@ static void print_header(int page, int total_pages, const char *title) { printf("%s", RESET_TERMINAL); } -/* Print page content */ +/* Emit page content through the pager (p_printf), honouring the current scroll + * window / dry-run counting held in the g_* pager state. */ static void print_page(int page, const char *bin) { const char *bold = BOLD; const char *reset = RESET_TERMINAL; -#define printf p_printf - switch (page) { case 0: /* CLI Basics */ - printf("\n"); - printf("CLI BASICS & ARGUMENT FLEXIBILITY\n"); - printf("---------------------------------\n\n"); - printf("Droidspaces features a robust multi-pass argument parser.\n"); - printf("You can combine arguments like a \"salad\":\n\n"); - - printf("%sInterchangeable Flags:%s\n", bold, reset); - printf(" Short and long flags are identical:\n"); - printf(" %s -r /path/to/rootfs -n mycontainer start\n", bin); - printf(" %s --rootfs /path/to/rootfs --name mycontainer start\n", bin); - printf(" %s --rootfs=/path/to/rootfs --name=mycontainer start\n\n", bin); - printf(" (Run %s help to study all available short flags)\n\n", bin); - - printf("%sFlexible Order:%s\n", bold, reset); - printf(" Command and flags can be mixed in any order:\n"); - printf(" %s start --name mycontainer --rootfs /path\n", bin); - printf(" %s --rootfs /path/to/rootfs -n mycontainer start\n\n", bin); - - printf("%sMixing Styles:%s\n", bold, reset); - printf(" Feel free to mix short and long flags with or without '=':\n"); - printf(" %s -r /path/to/rootfs --name=mycontainer --hostname myserver " - "start\n", - bin); + p_printf("\n"); + p_printf("CLI BASICS & ARGUMENT FLEXIBILITY\n"); + p_printf("---------------------------------\n\n"); + p_printf("Droidspaces features a robust multi-pass argument parser.\n"); + p_printf("You can combine arguments like a \"salad\":\n\n"); + + p_printf("%sInterchangeable Flags:%s\n", bold, reset); + p_printf(" Short and long flags are identical:\n"); + p_printf(" %s -r /path/to/rootfs -n mycontainer start\n", bin); + p_printf(" %s --rootfs /path/to/rootfs --name mycontainer start\n", bin); + p_printf(" %s --rootfs=/path/to/rootfs --name=mycontainer start\n\n", bin); + p_printf(" (Run %s help to study all available short flags)\n\n", bin); + + p_printf("%sFlexible Order:%s\n", bold, reset); + p_printf(" Command and flags can be mixed in any order:\n"); + p_printf(" %s start --name mycontainer --rootfs /path\n", bin); + p_printf(" %s --rootfs /path/to/rootfs -n mycontainer start\n\n", bin); + + p_printf("%sMixing Styles:%s\n", bold, reset); + p_printf(" Feel free to mix short and long flags with or without '=':\n"); + p_printf(" %s -r /path/to/rootfs --name=mycontainer --hostname myserver " + "start\n", + bin); break; case 1: /* Quick Start */ - printf("\n"); - printf("QUICK START\n"); - printf("-----------\n\n"); - printf("%sStarting for the first time:%s\n", bold, reset); - printf(" (Define name and rootfs/img path. Flags are persisted to " - "config)\n"); - printf(" # From a rootfs directory\n"); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs start\n\n", bin); - printf(" # From an ext4 image\n"); - printf(" %s --name=mycontainer --rootfs-img=/path/to/rootfs.img start\n\n", - bin); - - printf("%sSubsequent starts:%s\n", bold, reset); - printf(" (Settings like rootfs path are loaded from the container's " - "config file)\n"); - printf(" %s --name=mycontainer start\n\n", bin); - - printf("%sEntering the container:%s\n", bold, reset); - printf(" %s --name=mycontainer enter\n", bin); - printf(" %s --name=mycontainer enter username\n\n", bin); - - printf("%sRunning Commands:%s\n", bold, reset); - printf(" %s --name=mycontainer run 'uname -a'\n", bin); - printf(" %s --name=mycontainer --user=myuser run whoami\n\n", bin); - - printf("%sStopping:%s\n", bold, reset); - printf(" %s --name=mycontainer stop\n", bin); - printf(" %s stop --name=c1,c2,c3 (Multi-stop)\n\n", bin); - - printf("%sUsing Configuration Files:%s\n", bold, reset); - printf(" %s --config=./container.config start\n", bin); - break; - - case 2: /* Management */ - printf("\n"); - printf("MANAGEMENT & LIFECYCLE\n"); - printf("----------------------\n\n"); + p_printf("\n"); + p_printf("QUICK START\n"); + p_printf("-----------\n\n"); + p_printf("%sStarting for the first time:%s\n", bold, reset); + p_printf(" (Define name and rootfs/img path. Flags are persisted to " + "config)\n"); + p_printf(" # From a rootfs directory\n"); + p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs start\n\n", bin); + p_printf(" # From an ext4 image\n"); + p_printf( + " %s --name=mycontainer --rootfs-img=/path/to/rootfs.img start\n\n", + bin); - printf("%sRestarting:%s\n", bold, reset); - printf(" %s --name=mycontainer restart\n", bin); - printf(" Note: You can also reboot by running `reboot` inside the\n"); - printf(" container shell or remotely (e.g. via SSH).\n\n"); + p_printf("%sSubsequent starts:%s\n", bold, reset); + p_printf(" (Settings like rootfs path are loaded from the container's " + "config file)\n"); + p_printf(" %s --name=mycontainer start\n\n", bin); - printf("%sListing Containers:%s\n", bold, reset); - printf(" %s show\n", bin); - printf(" (Displays name and PID inside a table)\n\n"); + p_printf("%sEntering the container:%s\n", bold, reset); + p_printf(" %s --name=mycontainer enter\n", bin); + p_printf(" %s --name=mycontainer enter username\n\n", bin); - printf("%sTechnical Information:%s\n", bold, reset); - printf(" %s --name=mycontainer info\n", bin); - printf(" (Shows current features, metadata, and container state)\n\n"); + p_printf("%sRunning Commands:%s\n", bold, reset); + p_printf(" %s --name=mycontainer run 'uname -a'\n", bin); + p_printf(" %s --name=mycontainer --user=myuser run whoami\n\n", bin); - printf("%sTechnical Information (machine-parseable):%s\n", bold, reset); - printf(" %s --name=mycontainer --format info\n\n", bin); + p_printf("%sStopping:%s\n", bold, reset); + p_printf(" %s --name=mycontainer stop\n", bin); + p_printf(" %s stop --name=c1,c2,c3 (Multi-stop)\n\n", bin); - printf("%sResource Usage:%s\n", bold, reset); - printf(" %s --name=mycontainer usage\n", bin); - printf(" (Shows uptime, CPU%%, and RAM usage)\n\n"); + p_printf("%sUsing Configuration Files:%s\n", bold, reset); + p_printf(" %s --config=./container.config start\n", bin); + break; - printf("%sMetadata Recovery:%s\n", bold, reset); - printf(" %s scan\n", bin); - printf(" (Detects containers even if host-side config/PIDs are lost)\n\n"); + case 2: /* Management */ + p_printf("\n"); + p_printf("MANAGEMENT & LIFECYCLE\n"); + p_printf("----------------------\n\n"); + + p_printf("%sRestarting:%s\n", bold, reset); + p_printf(" %s --name=mycontainer restart\n", bin); + p_printf(" Note: You can also reboot by running `reboot` inside the\n"); + p_printf(" container shell or remotely (e.g. via SSH).\n\n"); + + p_printf("%sListing Containers:%s\n", bold, reset); + p_printf(" %s show\n", bin); + p_printf(" (Displays name and PID inside a table)\n\n"); + + p_printf("%sTechnical Information:%s\n", bold, reset); + p_printf(" %s --name=mycontainer info\n", bin); + p_printf(" (Shows current features, metadata, and container state)\n\n"); + + p_printf("%sTechnical Information (machine-parseable):%s\n", bold, reset); + p_printf(" %s --name=mycontainer --format info\n\n", bin); + + p_printf("%sResource Usage:%s\n", bold, reset); + p_printf(" %s --name=mycontainer usage\n", bin); + p_printf(" (Shows uptime, CPU%%, and RAM usage)\n\n"); + + p_printf("%sMetadata Recovery:%s\n", bold, reset); + p_printf(" %s scan\n", bin); + p_printf( + " (Detects containers even if host-side config/PIDs are lost)\n\n"); break; case 3: /* Networking */ - printf("\n"); - printf("NETWORKING\n"); - printf("----------\n\n"); - - printf("%sIsolation Modes (--net):%s\n", bold, reset); - printf(" --net=host Shared with host (default)\n"); - printf(" --net=none No network access (air-gapped)\n"); - printf(" --net=nat Isolated namespace with internet access\n"); - printf(" --net=gateway LAN delegated to another container " - "(e.g. OpenWRT)\n\n"); - - printf("%sNAT Mode Configuration:%s\n", bold, reset); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " - "start\n", - bin); - printf(" The internet uplink is detected automatically and tracked " - "in real time\n"); - printf(" as the host switches networks (Wi-Fi <-> mobile data, " - "ethernet, etc.).\n\n"); - - printf("%sManual Uplink Pinning (--upstream, NAT only):%s\n", bold, reset); - printf(" Pin WAN to specific interface(s) and disable auto-detect. " - "The list is\n"); - printf(" comma-separated, priority-ordered, and supports wildcards.\n"); - printf(" --upstream=wlan0 Always use Wi-Fi\n"); - printf(" --upstream=wlan0,rmnet* Prefer Wi-Fi, fall back to mobile " - "data\n"); - printf(" --upstream=tun0 Route only through a phone VPN " - "(killswitch)\n"); - printf(" --upstream=rmnet* Stay on mobile data while the phone " - "uses Wi-Fi\n"); - printf(" (Use rmnet* - the mobile-data interface number is not stable " - "across reconnects.)\n\n"); - - printf("%sGateway Mode Configuration:%s\n", bold, reset); - printf(" Delegate the LAN to another running container (e.g. OpenWRT), " - "which owns\n"); - printf(" DHCP, DNS, firewall and routing. Start the gateway first, " - "then the client.\n"); - printf(" %s --name=openwrt --rootfs=/path/to/openwrt --net=nat start\n", - bin); - printf(" %s --name=kali --rootfs=/path/to/kali --net=gateway " - "--gateway=openwrt start\n", - bin); - printf(" --gateway=NAME Gateway container (required)\n"); - printf(" --gateway-net=NAME LAN segment / bridge suffix " - "(default: lan)\n"); - printf(" --gateway-iface=IFACE Interface name inside the gateway " - "(default: eth1)\n"); - printf(" --gateway-bridge=BR Override host bridge name " - "(default: ds-NAME)\n\n"); - - printf("%sPort Forwarding (NAT only):%s\n", bold, reset); - printf(" --port=8080:80 Single port\n"); - printf(" --port=1000-2000:1000-2000 Symmetric range\n"); - printf(" --port=8080:80/udp Specific protocol\n\n"); - - printf("%sStatic IP assignment:%s\n", bold, reset); - printf(" --nat-ip=172.28.5.10\n\n"); - - printf("%sCustom DNS & IPv6:%s\n", bold, reset); - printf(" %s --name=mycontainer --dns=1.1.1.1,8.8.4.4 start\n", bin); - printf(" %s --name=mycontainer --disable-ipv6 start\n", bin); + p_printf("\n"); + p_printf("NETWORKING\n"); + p_printf("----------\n\n"); + + p_printf("%sIsolation Modes (--net):%s\n", bold, reset); + p_printf(" --net=host Shared with host (default)\n"); + p_printf(" --net=none No network access (air-gapped)\n"); + p_printf(" --net=nat Isolated namespace with internet access\n"); + p_printf(" --net=gateway LAN delegated to another container " + "(e.g. OpenWRT)\n\n"); + + p_printf("%sNAT Mode Configuration:%s\n", bold, reset); + p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " + "start\n", + bin); + p_printf(" The internet uplink is detected automatically and tracked " + "in real time\n"); + p_printf(" as the host switches networks (Wi-Fi <-> mobile data, " + "ethernet, etc.).\n\n"); + + p_printf("%sManual Uplink Pinning (--upstream, NAT only):%s\n", bold, + reset); + p_printf(" Pin WAN to specific interface(s) and disable auto-detect. " + "The list is\n"); + p_printf(" comma-separated, priority-ordered, and supports wildcards.\n"); + p_printf(" --upstream=wlan0 Always use Wi-Fi\n"); + p_printf(" --upstream=wlan0,rmnet* Prefer Wi-Fi, fall back to mobile " + "data\n"); + p_printf(" --upstream=tun0 Route only through a phone VPN " + "(killswitch)\n"); + p_printf(" --upstream=rmnet* Stay on mobile data while the phone " + "uses Wi-Fi\n"); + p_printf(" (Use rmnet* - the mobile-data interface number is not stable " + "across reconnects.)\n\n"); + + p_printf("%sGateway Mode Configuration:%s\n", bold, reset); + p_printf(" Delegate the LAN to another running container (e.g. OpenWRT), " + "which owns\n"); + p_printf(" DHCP, DNS, firewall and routing. Start the gateway first, " + "then the client.\n"); + p_printf(" %s --name=openwrt --rootfs=/path/to/openwrt --net=nat start\n", + bin); + p_printf(" %s --name=kali --rootfs=/path/to/kali --net=gateway " + "--gateway=openwrt start\n", + bin); + p_printf(" --gateway=NAME Gateway container (required)\n"); + p_printf(" --gateway-net=NAME LAN segment / bridge suffix " + "(default: lan)\n"); + p_printf(" --gateway-iface=IFACE Interface name inside the gateway " + "(default: eth1)\n"); + p_printf(" --gateway-bridge=BR Override host bridge name " + "(default: ds-NAME)\n\n"); + + p_printf("%sPort Forwarding (NAT only):%s\n", bold, reset); + p_printf(" --port=8080:80 Single port\n"); + p_printf(" --port=1000-2000:1000-2000 Symmetric range\n"); + p_printf(" --port=8080:80/udp Specific protocol\n\n"); + + p_printf("%sStatic IP assignment:%s\n", bold, reset); + p_printf(" --nat-ip=172.28.5.10\n\n"); + + p_printf("%sCustom DNS & IPv6:%s\n", bold, reset); + p_printf(" %s --name=mycontainer --dns=1.1.1.1,8.8.4.4 start\n", bin); + p_printf(" %s --name=mycontainer --disable-ipv6 start\n", bin); break; case 4: /* Hardware & Integration */ - printf("\n"); - printf("HARDWARE & INTEGRATION\n"); - printf("----------------------\n\n"); + p_printf("\n"); + p_printf("HARDWARE & INTEGRATION\n"); + p_printf("----------------------\n\n"); - printf("%sFull Hardware Access (--hw-access):%s\n", bold, reset); - printf( + p_printf("%sFull Hardware Access (--hw-access):%s\n", bold, reset); + p_printf( " %s --name=mycontainer --rootfs=/path/to/rootfs --hw-access start\n", bin); - printf(" (Exposes host /dev nodes, maps GPU groups, setups X11 in " - "Linux)\n\n"); - - printf("%sSecure GPU-only Mode (--gpu):%s\n", bold, reset); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --gpu start\n", - bin); - printf(" (Maps ONLY GPU nodes into an isolated tmpfs /dev)\n\n"); - - printf("%sAndroid-specific Features:%s\n", bold, reset); - printf(" --enable-android-storage Mounts /storage/emulated/0\n"); - printf(" --termux-x11 Setups Termux:X11 socket\n\n"); - - printf("%sSystem Integration:%s\n", bold, reset); - printf(" --selinux-permissive Set host SELinux to permissive\n"); - printf(" --force-cgroupv1 Force legacy cgroup hierarchy\n"); - printf(" --block-nested-namespaces Shield against VFS deadlocks\n"); + p_printf(" (Exposes host /dev nodes, maps GPU groups, setups X11 in " + "Linux)\n\n"); + + p_printf("%sSecure GPU-only Mode (--gpu):%s\n", bold, reset); + p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --gpu start\n", + bin); + p_printf(" (Maps ONLY GPU nodes into an isolated tmpfs /dev)\n\n"); + + p_printf("%sAndroid-specific Features:%s\n", bold, reset); + p_printf(" --enable-android-storage Mounts /storage/emulated/0\n"); + p_printf(" --termux-x11 Setups Termux:X11 socket\n\n"); + + p_printf("%sSystem Integration:%s\n", bold, reset); + p_printf(" --selinux-permissive Set host SELinux to permissive\n"); + p_printf(" --force-cgroupv1 Force legacy cgroup hierarchy\n"); + p_printf(" --block-nested-namespaces Shield against VFS deadlocks\n"); break; case 5: /* Advanced Features */ - printf("\n"); - printf("ADVANCED FEATURES\n"); - printf("-----------------\n\n"); - - printf("%sPrivileged Mode (--privileged):%s\n", bold, reset); - printf(" Relax security with comma-separated tags:\n"); - printf(" nomask Allow write access to /proc and /sys\n"); - printf(" nocaps Keep all Linux Capabilities (full root)\n"); - printf(" noseccomp Disable syscall filtering (useful for " - "flatpak,bwrap and other unprivileged sandboxes)\n"); - printf(" shared Enable MS_SHARED mount propagation\n"); - printf(" unfiltered-dev Bypass device filtering (all host /dev)\n"); - printf(" full Enable all above tags\n\n"); - - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --privileged=full " - "start\n\n", - bin); - - printf("%sEphemeral Mode (--volatile):%s\n", bold, reset); - printf( + p_printf("\n"); + p_printf("ADVANCED FEATURES\n"); + p_printf("-----------------\n\n"); + + p_printf("%sPrivileged Mode (--privileged):%s\n", bold, reset); + p_printf(" Relax security with comma-separated tags:\n"); + p_printf(" nomask Allow write access to /proc and /sys\n"); + p_printf(" nocaps Keep all Linux Capabilities (full root)\n"); + p_printf(" noseccomp Disable syscall filtering (useful for " + "flatpak,bwrap and other unprivileged sandboxes)\n"); + p_printf(" shared Enable MS_SHARED mount propagation\n"); + p_printf(" unfiltered-dev Bypass device filtering (all host /dev)\n"); + p_printf(" full Enable all above tags\n\n"); + + p_printf( + " %s --name=mycontainer --rootfs=/path/to/rootfs --privileged=full " + "start\n\n", + bin); + + p_printf("%sEphemeral Mode (--volatile):%s\n", bold, reset); + p_printf( " %s --name=mycontainer --rootfs=/path/to/rootfs --volatile start\n", bin); - printf(" (All changes are stored in RAM and lost on exit)\n\n"); - - printf("%sCustom Init (--init):%s\n", bold, reset); - printf(" Override the default init binary (/sbin/init).\n"); - printf(" Useful for distros with non-standard init paths, or for\n"); - printf(" running a shell directly as PID 1 (e.g. for debugging).\n\n"); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs " - "--init=/bin/bash start\n\n", - bin); - - printf("%sBind Mounts & Environment:%s\n", bold, reset); - printf(" --bind /host:/cont[:ro] Bind mount host path (append :ro for " - "read-only)\n"); - printf(" --env /path/to/env.list Load environment variables\n\n"); - - printf("%sConfig Management:%s\n", bold, reset); - printf(" --config=PATH Load specific config file\n"); - printf(" --reset Reset config to defaults\n"); - printf(" --help Show summary of all flags\n"); + p_printf(" (All changes are stored in RAM and lost on exit)\n\n"); + + p_printf("%sCustom Init (--init):%s\n", bold, reset); + p_printf(" Override the default init binary (/sbin/init).\n"); + p_printf(" Useful for distros with non-standard init paths, or for\n"); + p_printf(" running a shell directly as PID 1 (e.g. for debugging).\n\n"); + p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs " + "--init=/bin/bash start\n\n", + bin); + + p_printf("%sBind Mounts & Environment:%s\n", bold, reset); + p_printf(" --bind /host:/cont[:ro] Bind mount host path (append :ro for " + "read-only)\n"); + p_printf(" --env /path/to/env.list Load environment variables\n\n"); + + p_printf("%sConfig Management:%s\n", bold, reset); + p_printf(" --config=PATH Load specific config file\n"); + p_printf(" --reset Reset config to defaults\n"); + p_printf(" --help Show summary of all flags\n"); break; } -#undef printf } /* Read arrow key (escape sequence) */ From 95fcdf6e7222ed5c02a9c08b00d62b9d5e24875f Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:24:40 +0530 Subject: [PATCH 153/211] refactor: shell-quote multi-arg su -c command in run_in_rootfs When running a command as another user, run_in_rootfs() joins argv[] with spaces into a single su -c string. su's shell then re-splits on those spaces, so 'run ... -- rm "a b"' became 'rm a b' (three words). Quote each argument as a single-quoted shell word (with the '\'' idiom for embedded quotes) so word boundaries survive. The single-argument case is unchanged: it is still passed verbatim so a caller can hand su a full shell command string. Signed-off-by: ravindu644 --- src/container.c | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/container.c b/src/container.c index 5207e6c1..c117bcdf 100644 --- a/src/container.c +++ b/src/container.c @@ -1280,6 +1280,31 @@ int enter_rootfs(struct ds_config *cfg, const char *user) { return 0; } +/* Append arg to buf as a single-quoted shell word, space-separated from any + * prior content unless first. Embedded single quotes use the '\'' idiom. + * Writes at most size-1 chars and always NUL-terminates; returns the new + * offset. Preserves argv word boundaries across su -c's shell. */ +static size_t shell_quote_append(char *buf, size_t off, size_t size, + const char *arg, int first) { + if (!first && off < size - 1) + buf[off++] = ' '; + if (off < size - 1) + buf[off++] = '\''; + for (const char *p = arg; *p; p++) { + if (*p == '\'') { + const char *esc = "'\\''"; /* close quote, escaped quote, reopen quote */ + for (const char *e = esc; *e && off < size - 1; e++) + buf[off++] = *e; + } else if (off < size - 1) { + buf[off++] = *p; + } + } + if (off < size - 1) + buf[off++] = '\''; + buf[off] = '\0'; + return off; +} + int run_in_rootfs(struct ds_config *cfg, int argc, char **argv, const char *as_user) { (void)argc; @@ -1359,19 +1384,17 @@ int run_in_rootfs(struct ds_config *cfg, int argc, char **argv, * Otherwise join all args into a single shell string. */ char cmd_buf[4096]; if (argv[1] == NULL) { + /* Single argument is treated as a shell command string as-is, so a + * caller can pass e.g. `-- "ls -la | grep foo"`. */ safe_strncpy(cmd_buf, argv[0], sizeof(cmd_buf)); } else { + /* Multiple args: shell-quote each so word boundaries (spaces, globs, + * metacharacters) survive su's shell -- e.g. `-- rm "a b"` stays two + * arguments instead of being resplit into three. */ size_t off = 0; - for (int k = 0; argv[k] && off < sizeof(cmd_buf) - 1; k++) { - if (k > 0 && off < sizeof(cmd_buf) - 2) - cmd_buf[off++] = ' '; - size_t al = strlen(argv[k]); - if (off + al >= sizeof(cmd_buf) - 1) - al = sizeof(cmd_buf) - 1 - off; - memcpy(cmd_buf + off, argv[k], al); - off += al; - } - cmd_buf[off] = '\0'; + for (int k = 0; argv[k]; k++) + off = shell_quote_append(cmd_buf, off, sizeof(cmd_buf), argv[k], + k == 0); } char *su_argv[] = {"su", "-", (char *)(uintptr_t)as_user, "-c", cmd_buf, NULL}; From cb6de8ab72f855b492a88e61315df07fd073f847 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:28:12 +0530 Subject: [PATCH 154/211] refactor: share pidfile resolve/prune helpers between scan loops count_running_containers() and show_containers() both open the pids dir and, per entry, resolve the container name + pidfile and prune stale pidfiles with the same unlink + remove_mount_path + remove_init_type sequence. Extract resolve_scanned_container() and prune_stale_pidfile() and use them in both. Each function keeps its own control flow (counting vs the dynamic array with realloc/OOM handling), so behavior is unchanged. Signed-off-by: ravindu644 --- src/pid.c | 51 +++++++++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/src/pid.c b/src/pid.c index b5e71208..ebc44571 100644 --- a/src/pid.c +++ b/src/pid.c @@ -191,6 +191,25 @@ static void get_container_name_from_pidfile(const char *pidfile, char *name, *dot = '\0'; } +/* Resolve a scanned pidfile dirent into tmp_cfg->container_name / ->pidfile. */ +static void resolve_scanned_container(const char *dirent_name, + struct ds_config *tmp_cfg) { + char clean_name[256]; + get_container_name_from_pidfile(dirent_name, clean_name, sizeof(clean_name)); + safe_strncpy(tmp_cfg->container_name, clean_name, + sizeof(tmp_cfg->container_name)); + resolve_pidfile_from_name(clean_name, tmp_cfg->pidfile, + sizeof(tmp_cfg->pidfile)); +} + +/* Remove a stale container's pidfile and its sidecar metadata (.mount/.init). + */ +static void prune_stale_pidfile(const char *pidfile) { + unlink(pidfile); + remove_mount_path(pidfile); + remove_init_type(pidfile); +} + int count_running_containers(char *first_name, size_t size) { DIR *d = opendir(get_pids_dir()); if (!d) @@ -202,26 +221,16 @@ int count_running_containers(char *first_name, size_t size) { while ((ent = readdir(d)) != NULL) { if (is_pid_file(ent->d_name)) { struct ds_config tmp_cfg = {0}; - char clean_name[256]; - get_container_name_from_pidfile(ent->d_name, clean_name, - sizeof(clean_name)); - - safe_strncpy(tmp_cfg.container_name, clean_name, - sizeof(tmp_cfg.container_name)); - resolve_pidfile_from_name(clean_name, tmp_cfg.pidfile, - sizeof(tmp_cfg.pidfile)); + resolve_scanned_container(ent->d_name, &tmp_cfg); pid_t pid; if (is_container_running(&tmp_cfg, &pid)) { if (count == 0 && first_name && size > 0) { - safe_strncpy(first_name, clean_name, size); + safe_strncpy(first_name, tmp_cfg.container_name, size); } count++; } else if (pid == 0 && access(tmp_cfg.pidfile, F_OK) == 0) { - /* Explicit pruning during scan */ - unlink(tmp_cfg.pidfile); - remove_mount_path(tmp_cfg.pidfile); - remove_init_type(tmp_cfg.pidfile); + prune_stale_pidfile(tmp_cfg.pidfile); } } } @@ -453,14 +462,7 @@ int show_containers(struct ds_config *cfg) { while ((ent = readdir(d)) != NULL) { if (is_pid_file(ent->d_name)) { struct ds_config tmp_cfg = {0}; - char clean_name[128]; - get_container_name_from_pidfile(ent->d_name, clean_name, - sizeof(clean_name)); - - safe_strncpy(tmp_cfg.container_name, clean_name, - sizeof(tmp_cfg.container_name)); - resolve_pidfile_from_name(clean_name, tmp_cfg.pidfile, - sizeof(tmp_cfg.pidfile)); + resolve_scanned_container(ent->d_name, &tmp_cfg); pid_t pid; if (is_container_running(&tmp_cfg, &pid)) { @@ -481,7 +483,7 @@ int show_containers(struct ds_config *cfg) { containers = tmp; } - safe_strncpy(containers[count].name, clean_name, + safe_strncpy(containers[count].name, tmp_cfg.container_name, sizeof(containers[count].name)); containers[count].pid = pid; size_t nlen = strlen(containers[count].name); @@ -489,10 +491,7 @@ int show_containers(struct ds_config *cfg) { max_name_len = nlen; count++; } else if (pid == 0 && access(tmp_cfg.pidfile, F_OK) == 0) { - /* Explicit pruning during scan */ - unlink(tmp_cfg.pidfile); - remove_mount_path(tmp_cfg.pidfile); - remove_init_type(tmp_cfg.pidfile); + prune_stale_pidfile(tmp_cfg.pidfile); } } } From 334c006feccf2a93071dd60554d7b00ca474f0ab Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 14:30:59 +0530 Subject: [PATCH 155/211] refactor: wrap repeated payload-drain idiom in socketd_drain_payload Four opcode handlers (PING, CAPABILITIES, INFO, LIST_IMAGES) repeated the same 'discard the unused payload, reply BAD_REQUEST on short read, return' block verbatim. Wrap it in socketd_drain_payload() returning 0/-1 so each site is a single guarded call. The optional-payload reads (LIST_CONTAINERS, POLL_EVENTS) and the one mandatory read (INSPECT) are left as-is since their conditions differ. Signed-off-by: ravindu644 --- src/socketd_bridge.c | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 23f53ca8..7931c441 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -110,6 +110,17 @@ static int socketd_read_payload(int fd, void *buf, uint32_t expected_len, return socketd_read_exact(fd, buf, expected_len); } +/* Drain a payload the current opcode does not consume; on a short read reply + * BAD_REQUEST. Returns 0 to continue, -1 if the caller should stop handling + * the connection. */ +static int socketd_drain_payload(int conn, uint32_t payload_len) { + if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { + socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + return -1; + } + return 0; +} + /* * Wire-format 64-bit host -> network conversion. * @@ -925,10 +936,8 @@ static void socketd_handle_conn(int conn) { switch ((enum ds_socketd_opcode)opcode) { case DS_SOCKETD_OP_PING: { - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + if (socketd_drain_payload(conn, payload_len) < 0) return; - } static const char pong[] = "PONG"; socketd_send_response(conn, DS_SOCKETD_STATUS_OK, pong, @@ -937,10 +946,8 @@ static void socketd_handle_conn(int conn) { } case DS_SOCKETD_OP_CAPABILITIES: { - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + if (socketd_drain_payload(conn, payload_len) < 0) return; - } uint32_t caps_be = htonl(DS_SOCKETD_CAP_PROTOCOL_V1 | DS_SOCKETD_CAP_PING | @@ -955,10 +962,8 @@ static void socketd_handle_conn(int conn) { } case DS_SOCKETD_OP_INFO: { - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + if (socketd_drain_payload(conn, payload_len) < 0) return; - } uint32_t installed_count = 0; if (socketd_count_installed_containers(&installed_count) < 0) { @@ -1150,10 +1155,8 @@ static void socketd_handle_conn(int conn) { } case DS_SOCKETD_OP_LIST_IMAGES: { - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + if (socketd_drain_payload(conn, payload_len) < 0) return; - } size_t capacity = 16; struct ds_socketd_image_record *records = From f87862fe0f67adf7f36ec9de9ca3bddae01b8b2e Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 15:27:28 +0530 Subject: [PATCH 156/211] fix(net): keep uplink route monitor alive across internal reboots The route monitor thread died on some internal container reboots, leaving WiFi<->mobile handoff broken until a full stop/start. Two compounding causes: 1. route_monitor_loop treated any recv() error other than EINTR/EAGAIN as fatal and broke out of the loop. A reboot's veth teardown/recreate emits a burst of netlink multicast messages that can overflow the socket receive buffer (ENOBUFS) - a recoverable condition. Now ENOBUFS triggers a resync (do_uplink_reprobe) and continues; a 0-length read is likewise non-fatal unless stopping; genuinely fatal errors are logged before exiting. This alone keeps the single monitor instance alive across the reboot, as intended. 2. On any self-exit the loop never cleared g_route_monitor_started, so the per-cycle ds_net_start_route_monitor() call no-op'd on a stale flag and the monitor never came back. Now self-exit clears started (keeping the thread joinable), and start reaps a self-exited thread before spawning a replacement. stop now gates its join on a new needs_join flag so a thread that already self-exited is still reaped rather than leaked. The failure was intermittent (only when the burst overflowed), which is why handoff worked after most reboots and silently stopped after the unlucky ones. Signed-off-by: ravindu644 --- src/net/network.c | 69 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/src/net/network.c b/src/net/network.c index 103e2d0d..523919eb 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -253,7 +253,9 @@ static pthread_mutex_t g_gw_mutex = PTHREAD_MUTEX_INITIALIZER; static int g_route_monitor_sock = -1; static volatile sig_atomic_t g_stop_monitor = 0; static pthread_t g_route_monitor_tid; -static int g_route_monitor_started = 0; /* guarded by g_gw_mutex */ +static int g_route_monitor_started = 0; /* loop active; guarded by g_gw_mutex */ +static int g_route_monitor_needs_join = 0; /* thread created, not yet joined; + guarded by g_gw_mutex */ /* Set once any container installs localhost port-forward DNAT rules. * Sticky - never cleared, since route_localnet=1 is harmless to leave on * and other containers may still depend on it. */ @@ -1970,6 +1972,8 @@ static void *route_monitor_loop(void *arg) { break; if (errno == EINTR) continue; + ds_warn("[NET] Route monitor: poll failed (%s) - stopping", + strerror(errno)); break; } @@ -1979,13 +1983,31 @@ static void *route_monitor_loop(void *arg) { } ssize_t len = recv(sock, buf, sizeof(buf), 0); - if (len <= 0) { + if (len < 0) { if (g_stop_monitor) break; - if (len < 0 && (errno == EINTR || errno == EAGAIN)) + if (errno == EINTR || errno == EAGAIN) + continue; + if (errno == ENOBUFS) { + /* The netlink multicast receive buffer overflowed during an event + * burst (e.g. a container reboot tearing down and recreating veths). + * The kernel dropped notifications but the socket is still valid: + * resync by reprobing the uplink and keep monitoring, rather than + * treating a recoverable condition as fatal and killing the thread. */ + do_uplink_reprobe(); continue; + } + ds_warn("[NET] Route monitor: recv failed (%s) - stopping", + strerror(errno)); break; } + if (len == 0) { + /* Only our own shutdown() during stop yields a 0-length read; keep + * monitoring otherwise instead of exiting. */ + if (g_stop_monitor) + break; + continue; + } int should_reprobe = 0; struct nlmsghdr *h = (struct nlmsghdr *)buf; @@ -2063,6 +2085,11 @@ static void *route_monitor_loop(void *arg) { pthread_mutex_lock(&g_gw_mutex); close(sock); g_route_monitor_sock = -1; + /* Mark the loop inactive so a later ds_net_start_route_monitor() (e.g. the + * next reboot cycle) spawns a fresh thread instead of no-op'ing on a stale + * flag. needs_join stays set: this thread has exited but is still joinable, + * and start/stop will reap it. */ + g_route_monitor_started = 0; pthread_mutex_unlock(&g_gw_mutex); ds_log("[NET] Uplink route monitor stopped"); @@ -2071,18 +2098,21 @@ static void *route_monitor_loop(void *arg) { void ds_net_stop_route_monitor(void) { pthread_mutex_lock(&g_gw_mutex); - int started = g_route_monitor_started; + /* Gate the join on needs_join, not started: a thread that self-exited on a + * fatal error has started==0 but is still joinable and must be reaped. */ + int needs_join = g_route_monitor_needs_join; pthread_t tid = g_route_monitor_tid; g_stop_monitor = 1; if (g_route_monitor_sock >= 0) shutdown(g_route_monitor_sock, SHUT_RDWR); + g_route_monitor_needs_join = 0; pthread_mutex_unlock(&g_gw_mutex); /* Join so the monitor is fully stopped before cleanup removes the shared * MASQUERADE / FIB policy rules - otherwise an in-flight do_uplink_reprobe() * could re-add a rule we just deleted, or rewrite ip_forward after teardown. */ - if (started) { + if (needs_join) { pthread_join(tid, NULL); pthread_mutex_lock(&g_gw_mutex); g_route_monitor_started = 0; @@ -2098,21 +2128,36 @@ void ds_net_start_route_monitor(void) { pthread_mutex_lock(&g_gw_mutex); /* Idempotent: exactly one monitor thread per process. setup_veth_host_side - * calls this on every boot cycle (including reboots, which never stop the - * monitor); without this guard each reboot would spawn another thread, all - * racing the same FIB policy rule. The monitor tracks the host uplink, which - * is independent of the container PID, so a single instance rightly persists - * across the container's reboots. Joinable (default attr) so stop can join. + * calls this on every boot cycle (including reboots); the monitor tracks the + * host uplink, which is independent of the container PID, so a single live + * instance rightly persists across the container's reboots. */ if (g_route_monitor_started) { pthread_mutex_unlock(&g_gw_mutex); return; } + /* Not running. A previous thread may have self-exited on a fatal error + * without being joined (needs_join==1, started==0); reap it before spawning + * a replacement so its resources are freed and g_route_monitor_tid is not + * overwritten while still joinable. Join outside the mutex (the pattern stop + * uses) - the exiting thread takes g_gw_mutex on its way out. */ + int reap = g_route_monitor_needs_join; + pthread_t old_tid = g_route_monitor_tid; + g_route_monitor_needs_join = 0; + pthread_mutex_unlock(&g_gw_mutex); + + if (reap) + pthread_join(old_tid, NULL); + + pthread_mutex_lock(&g_gw_mutex); g_stop_monitor = 0; - if (pthread_create(&g_route_monitor_tid, NULL, route_monitor_loop, NULL) != 0) + if (pthread_create(&g_route_monitor_tid, NULL, route_monitor_loop, NULL) != + 0) { ds_warn("[NET] Failed to start route monitor thread: %s", strerror(errno)); - else + } else { g_route_monitor_started = 1; + g_route_monitor_needs_join = 1; + } pthread_mutex_unlock(&g_gw_mutex); } From 9696c76b841b903fa6316830ecdacfb2280bb451 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 15:57:27 +0530 Subject: [PATCH 157/211] fix(net): actually remove DS iptables rules on cleanup; stop phantom drop logs remove_ds_rules() ignored the return value of every raw-socket remove_matching_rules() call, and a failed IPT_SO_SET_REPLACE commit was swallowed (only the EAGAIN retry was logged). Confirmed on-device: with no container running (empty pids dir, empty /mnt/Droidspaces, no ds-v* veth), all four rules the last cleanup claimed to drop were still installed in the host iptables - they leaked on every container stop while the log said they were removed. The insert path uses the same syscall and works, so the raw removal blob is the culprit; the iptables binary '-D' removes them cleanly. - Check each raw removal's return; on failure (or when no raw socket is available) fall back to 'iptables -D', which deletes reliably. The four binary forms are now defined once and driven from a small table instead of being duplicated in the no-socket branch. - Log the setsockopt errno on the non-EAGAIN failure so the exact kernel rejection is visible (for a later fix of the raw fast path). - The per-rule build-phase log now says 'matched' instead of 'dropping', so it no longer claims a removal that has not committed. Raw fast-path blob fix left as a follow-up; the binary fallback guarantees cleanup in the meantime. Signed-off-by: ravindu644 --- src/net/iptables.c | 130 ++++++++++++++++++++++++--------------------- 1 file changed, 70 insertions(+), 60 deletions(-) diff --git a/src/net/iptables.c b/src/net/iptables.c index c13e7e86..195fb605 100644 --- a/src/net/iptables.c +++ b/src/net/iptables.c @@ -559,7 +559,7 @@ static int remove_matching_rules(int fd, const char *table_name, ei++; continue; } - ds_log("[IPT] remove: dropping '%s' rule at offset %u", target_label(t), + ds_log("[IPT] remove: matched '%s' rule at offset %u", target_label(t), offset); cumulative_gone += e->next_offset; removed_count++; @@ -676,6 +676,10 @@ static int remove_matching_rules(int fd, const char *table_name, continue; } + ds_warn( + "[IPT] remove: table replace on '%s' failed (%s) - caller will fall " + "back to the iptables binary", + table_name, strerror(err)); break; } @@ -1100,69 +1104,75 @@ int ds_ipt_remove_ds_rules(void) { uint32_t ds_src, ds_mask; parse_cidr(DS_DEFAULT_SUBNET, &ds_src, &ds_mask); - /* 1. NAT table: remove our MASQUERADE rule */ - if (fd >= 0) { - struct ipt_getinfo info; - unsigned char *base = NULL; - if (get_table(fd, "nat", &info, &base) == 0) { - remove_matching_rules(fd, "nat", &info, ENTRIES_BLOB(base), - NF_INET_POST_ROUTING, ds_src, ds_mask, NULL, 0); - free(base); - } - - /* 2. Filter table: remove our bridge FORWARD -i rule */ - base = NULL; - if (get_table(fd, "filter", &info, &base) == 0) { - remove_matching_rules(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_FORWARD, 0, 0, DS_NAT_BRIDGE, - DS_IPT_MATCH_IN); - free(base); + /* Binary `iptables -D` forms. Used when there is no raw socket, and as a + * fallback whenever a raw-socket table replace fails to commit: the binary + * deletes reliably (taking the xtables lock), so the rule is actually removed + * rather than merely logged as removed. */ + char *del_masq[] = {"iptables", + "-t", + "nat", + "-D", + "POSTROUTING", + "-s", + DS_DEFAULT_SUBNET, + "!", + "-d", + DS_DEFAULT_SUBNET, + "-j", + "MASQUERADE", + NULL}; + char *del_fwd_in[] = {"iptables", "-D", "FORWARD", "-i", + DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; + char *del_fwd_out[] = {"iptables", "-D", "FORWARD", "-o", + DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; + char *del_inp[] = {"iptables", "-D", "INPUT", "-i", + DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; + + struct ds_ipt_del_target { + const char *table; + unsigned int hook; + uint32_t src; + uint32_t mask; + const char *iface; + unsigned int iface_flags; + char **binary_del; + } targets[] = { + {"nat", NF_INET_POST_ROUTING, ds_src, ds_mask, NULL, 0, del_masq}, + {"filter", NF_INET_FORWARD, 0, 0, DS_NAT_BRIDGE, DS_IPT_MATCH_IN, + del_fwd_in}, + {"filter", NF_INET_FORWARD, 0, 0, DS_NAT_BRIDGE, DS_IPT_MATCH_OUT, + del_fwd_out}, + {"filter", NF_INET_LOCAL_IN, 0, 0, DS_NAT_BRIDGE, DS_IPT_MATCH_IN, + del_inp}, + }; + + for (size_t i = 0; i < sizeof(targets) / sizeof(targets[0]); i++) { + /* rc < 0 means "not removed via the raw path" (no socket, or the kernel + * rejected the table replace) -> use the binary. rc == 0 means the raw + * path committed the delete, or nothing matched. */ + int rc = -1; + if (fd >= 0) { + struct ipt_getinfo info; + unsigned char *base = NULL; + if (get_table(fd, targets[i].table, &info, &base) == 0) { + rc = remove_matching_rules(fd, targets[i].table, &info, + ENTRIES_BLOB(base), targets[i].hook, + targets[i].src, targets[i].mask, + targets[i].iface, targets[i].iface_flags); + free(base); + } } - - /* 3. Filter table: remove our bridge FORWARD -o rule */ - base = NULL; - if (get_table(fd, "filter", &info, &base) == 0) { - remove_matching_rules(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_FORWARD, 0, 0, DS_NAT_BRIDGE, - DS_IPT_MATCH_OUT); - free(base); + if (rc < 0) { + if (fd >= 0) + ds_log("[IPT] remove: raw delete failed for %s/hook%u - using the " + "iptables binary", + targets[i].table, targets[i].hook); + run_command_quiet(targets[i].binary_del); } + } - /* 4. Filter table: remove our bridge INPUT rule */ - base = NULL; - if (get_table(fd, "filter", &info, &base) == 0) { - remove_matching_rules(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_LOCAL_IN, 0, 0, DS_NAT_BRIDGE, - DS_IPT_MATCH_IN); - free(base); - } + if (fd >= 0) close(fd); - } else { - /* Binary fallback for cleanup */ - char *del_masq[] = {"iptables", - "-t", - "nat", - "-D", - "POSTROUTING", - "-s", - DS_DEFAULT_SUBNET, - "!", - "-d", - DS_DEFAULT_SUBNET, - "-j", - "MASQUERADE", - NULL}; - run_command_quiet(del_masq); - char *del_fwd_in[] = {"iptables", "-D", "FORWARD", "-i", - DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; - run_command_quiet(del_fwd_in); - char *del_fwd_out[] = {"iptables", "-D", "FORWARD", "-o", - DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; - run_command_quiet(del_fwd_out); - char *del_inp[] = {"iptables", "-D", "INPUT", "-i", - DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; - run_command_quiet(del_inp); - } /* 3. MSS clamp: binary only */ { From 6b63827eb389c6f2610f914c790833ee93b26083 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 16:09:05 +0530 Subject: [PATCH 158/211] fix(net): re-base jump verdicts on raw iptables delete (fixes ELOOP) The raw-socket rule removal failed on-device with errno ELOOP (Symbolic link loop). In netfilter's IPT_SO_SET_REPLACE path, translate_table() -> mark_source_chains() walks each hook's chain following xt_standard_target verdicts: a negative verdict is a built-in (ACCEPT/DROP/RETURN/policy), while a non-negative verdict is an ABSOLUTE byte offset to a jump target, validated by xt_find_jump_offset(). remove_matching_rules() re-based hook_entry/underflow after deleting a rule but never the jump verdicts, so every verdict pointing past the removed rule kept its stale (now too-high) offset; the kernel could no longer resolve the jump and rejected the whole replace with ELOOP. That is why removal silently failed on Android, where netd installs many jump-laden custom chains. Add fixup_jump_targets_removed(), the removal twin of the existing insert-side fixup_jump_targets(): walk the rebuilt blob and subtract, from each non-negative verdict, the bytes removed before its target offset (old_offsets[k] -> removed_before[k], the same arrays the hook/underflow fixup uses). The binary iptables -D fallback stays as belt-and-suspenders. Signed-off-by: ravindu644 --- src/net/iptables.c | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/net/iptables.c b/src/net/iptables.c index 195fb605..b746dae8 100644 --- a/src/net/iptables.c +++ b/src/net/iptables.c @@ -282,6 +282,58 @@ static void fixup_jump_targets(unsigned char *blob, unsigned int blob_sz, } } +/* --------------------------------------------------------------------------- + * Internal: fixup_jump_targets_removed + * + * Removal twin of fixup_jump_targets. After rules are deleted the surviving + * entries shift to lower offsets, but every xt_standard_target with a + * non-negative verdict (= absolute byte offset = chain jump) still holds its + * OLD offset. Re-base each by the number of bytes removed before that offset: + * old_offsets[k] gives an entry's old start, removed_before[k] the bytes + * removed before it. Without this the kernel's mark_source_chains() validator + * can no longer resolve the shifted jump (xt_find_jump_offset fails) and + * rejects the whole table replace with ELOOP. + * ---------------------------------------------------------------------------*/ + +static void fixup_jump_targets_removed(unsigned char *blob, + unsigned int blob_sz, + const unsigned int *old_offsets, + const unsigned int *removed_before, + unsigned int nents) { + unsigned int off = 0; + + while (off < blob_sz) { + struct ipt_entry *e = (struct ipt_entry *)(blob + off); + + if (e->next_offset < sizeof(*e) || off + e->next_offset > blob_sz) + break; + + if (e->target_offset + sizeof(struct xt_standard_target) <= + e->next_offset) { + struct xt_entry_target *t = + (struct xt_entry_target *)((uint8_t *)e + e->target_offset); + + if (t->u.user.name[0] == '\0' && + t->u.target_size == + (uint16_t)XT_ALIGN(sizeof(struct xt_standard_target))) { + struct xt_standard_target *st = (struct xt_standard_target *)t; + if (st->verdict >= 0) { + /* Jump target: find the tracked entry that started at this old + * offset and subtract the bytes removed before it. */ + for (unsigned int k = 0; k < nents; k++) { + if (old_offsets[k] == (unsigned int)st->verdict) { + st->verdict -= (int)removed_before[k]; + break; + } + } + } + } + } + + off += e->next_offset; + } +} + /* --------------------------------------------------------------------------- * Internal: insert_rule_at_hook * @@ -582,6 +634,12 @@ static int remove_matching_rules(int fd, const char *table_name, break; } + /* Re-base surviving jump verdicts for the bytes just removed; without this + * the kernel rejects the table replace with ELOOP (see + * fixup_jump_targets_removed). */ + fixup_jump_targets_removed(new_blob, new_sz, old_offsets, removed_before, + ei + 1); + /* Build ipt_replace */ size_t replace_sz = sizeof(struct ipt_replace) + new_sz; struct ipt_replace *repl = calloc(1, replace_sz); From 462ddcee0cab90752cf714f5fb2dc6a631dcb56c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 16:34:06 +0530 Subject: [PATCH 159/211] fix(ui): jump directly between tabs instead of scrolling through them The swipe-gesture change routed every tab action through pagerState.animateScrollToPage(). That is a positional scroll, so tapping a non-adjacent tab (e.g. Home -> Control Panel) slid the pager across the intervening page (Containers) instead of switching directly, and animating across the heavy intermediate/target pages - none pre-composed - stuttered. Use pagerState.scrollToPage() for the tab-bar taps, the Home screen's navigate-to-Containers/Control-Panel buttons, and the back handler. That jumps straight to the target page with no intervening scroll (no pass-through, no slide-while-composing jank), restoring the pre-swipe feel for taps while swipe gestures keep working via the pager's native drag. Signed-off-by: ravindu644 --- .../java/com/droidspaces/app/ui/screen/MainTabScreen.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt index 2be25d3a..6b502a54 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt @@ -83,7 +83,7 @@ fun MainTabScreen( // Handle back press - return to Home tab if not already there BackHandler(enabled = selectedTab != TabItem.Home) { scope.launch { - pagerState.animateScrollToPage(tabs.indexOf(TabItem.Home)) + pagerState.scrollToPage(tabs.indexOf(TabItem.Home)) } } @@ -292,12 +292,12 @@ fun MainTabScreen( onNavigateToInstallation = onNavigateToInstallation, onNavigateToContainers = { scope.launch { - pagerState.animateScrollToPage(tabs.indexOf(TabItem.Containers)) + pagerState.scrollToPage(tabs.indexOf(TabItem.Containers)) } }, onNavigateToControlPanel = { scope.launch { - pagerState.animateScrollToPage(tabs.indexOf(TabItem.ControlPanel)) + pagerState.scrollToPage(tabs.indexOf(TabItem.ControlPanel)) } }, containerCount = containerCount, @@ -346,7 +346,7 @@ fun MainTabScreen( selectedTab = selectedTab, onTabSelected = { tab -> scope.launch { - pagerState.animateScrollToPage(tabs.indexOf(tab)) + pagerState.scrollToPage(tabs.indexOf(tab)) } expandedContainerName = null } From 31c3b6918c10be3e87f5d48288a1de504775ad16 Mon Sep 17 00:00:00 2001 From: Axi Date: Sat, 18 Jul 2026 19:46:03 +0800 Subject: [PATCH 160/211] Use fixed latest release URL for kernel download (#244) * Use latest-release permalink for kernel download The build repository now publishes the kernel zip with a fixed filename (`raphael_YAAP-sixteen_KernelSU-by-xx.zip`). This lets us use GitHub's `/releases/latest/download/` permalink, so the link automatically points to the newest build without manual updates. * Use latest-release permalink for kernel download The build repository now publishes the kernel zip with a fixed filename (`raphael_YAAP-sixteen_KernelSU-by-xx.zip`). This lets us use GitHub's `/releases/latest/download/` permalink, so the link automatically points to the newest build without manual updates. --- Documentation/community-supported-devices.md | 2 +- Documentation/zh-CN/community-supported-devices.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 5760ed29..6be616dc 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -30,7 +30,7 @@ This document is a community-maintained compatibility list for Android devices k | **Galaxy S10 (Japanese variant)** | SCV41 / SM-G973J | One UI 4.1 - Android 12 | `SCV41KDU1DWC1` | `4.14.190` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces) | [Download](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces/releases/download/v2/Droidspaces-KSUN-Samsung-SCV41.tar) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | | **Poco X2** | M1912G7Bx | BlissROM-v16.5 - Android 13 | - | `4.14.274` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces) | [Download](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces/releases/download/v1/Droidspaces-KSUNv1.1.1-phoenix.zip) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | | **Realme 6, 6i, 6s** | RMX2001L1 | RUI 2.0 - Android 11 | - | `4.14.336` | KSU-Next v3.2.0 / ReSukiSU v4.1.0 | [Source](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo) | [Download](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo/releases/tag/v1.1) | Both | Virgl | Working | [@Prime-TITAN-CameraMan](https://github.com/Prime-TITAN-CameraMan) | RUI 2.0 only & No Firewall Support (UFW/Fail2ban) | -| **Redmi K20 Pro (China)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | Working | [@Alhkxsj](https://github.com/Alhkxsj) | Kernel source is stock YAAP kernel; Droidspaces configs applied via automated build workflow | +| **Redmi K20 Pro (China)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/latest/download/raphael_YAAP-sixteen_KernelSU-by-xx.zip) | Both | Turnip & Virgl | Working | [@Alhkxsj](https://github.com/Alhkxsj) | Kernel source is stock YAAP kernel; Droidspaces configs applied via automated build workflow | | **Redmi note 7 pro (violet)** | M1901F7S | LineagOS 23.2 - Android 16 | `.c2-00029-SM6150_GEN_PACK-1.352094.1.355031.2` | `4.14.357` | Magisk | [source](https://github.com/subhu2008/android_kernel_xiaomi_sm6150) | [Download](https://github.com/subhu2008/android_kernel_xiaomi_sm6150/releases/tag/stable) | Both | Turnip & Virgl | Working | [@subhu2008](https://github.com/subhu2008) | LineageOS 23.2 only. If it works on other AOSP ROM's too, then good. | | **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | none | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | Working | [@JackA1ltman](https://github.com/JackA1ltman) | Custom ROM only. Supported NTSYNC. | diff --git a/Documentation/zh-CN/community-supported-devices.md b/Documentation/zh-CN/community-supported-devices.md index fb931ba6..3b6e51d3 100644 --- a/Documentation/zh-CN/community-supported-devices.md +++ b/Documentation/zh-CN/community-supported-devices.md @@ -29,7 +29,7 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | **Galaxy S10(日版)** | SCV41 / SM-G973J | One UI 4.1 - Android 12 | `SCV41KDU1DWC1` | `4.14.190` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces) | [Download](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces/releases/download/v2/Droidspaces-KSUN-Samsung-SCV41.tar) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | | **Poco X2** | M1912G7Bx | BlissROM-v16.5 - Android 13 | - | `4.14.274` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces) | [Download](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces/releases/download/v1/Droidspaces-KSUNv1.1.1-phoenix.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | | **Realme 6, 6i, 6s** | RMX2001L1 | RUI 2.0 - Android 11 | - | `4.14.336` | KSU-Next v3.2.0 / ReSukiSU v4.1.0 | [Source](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo) | [Download](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo/releases/tag/v1.1) | Both | Virgl | 正常运行 | [@Prime-TITAN-CameraMan](https://github.com/Prime-TITAN-CameraMan) | 仅限 RUI 2.0 且不支持防火墙(UFW/Fail2ban) | -| **Redmi K20 Pro(国行)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | 正常运行 | [@Alhkxsj](https://github.com/Alhkxsj) | 内核源码为原版 YAAP 内核;通过自动化构建工作流应用 Droidspaces 配置 | +| **Redmi K20 Pro(国行)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/latest/download/raphael_YAAP-sixteen_KernelSU-by-xx.zip) | Both | Turnip & Virgl | 正常运行 | [@Alhkxsj](https://github.com/Alhkxsj) | 内核源码为原版 YAAP 内核;通过自动化构建工作流应用 Droidspaces 配置 | | **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | 无 | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | 正常运行 | [@JackA1ltman](https://github.com/JackA1ltman) | 仅限自定义 ROM。支持 NTSYNC。 | | **OnePlus 7 Pro** | GM1911 | LineageOS 17.1 - Android 10 | `QQ3A.200805.001` | `4.14.117` | Magisk | [Source](https://github.com/charan-gn/guacamole-droidspaces-kernel) | [Download](https://github.com/charan-gn/guacamole-droidspaces-kernel/releases/tag/v1.0) | Both | Turnip & Virgl | 正常运行 | [@charan-gn](https://github.com/charan-gn) | AnyKernel3 可在任何 ROM 上刷入。 | From ecf412cd46d8035961cdfdfe13ba073253611970 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 19:29:37 +0530 Subject: [PATCH 161/211] Revert "fix(security): refuse to load a group/other-writable config" This reverts commit 174b75c92054fe15ee405adac65d84d0e8cf5521. --- src/config.c | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/config.c b/src/config.c index 2cda084a..1a5e507a 100644 --- a/src/config.c +++ b/src/config.c @@ -225,21 +225,6 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { return -1; } - /* A config parsed by the root daemon must only be modifiable by its owner. - * Refuse one that is writable by group or other: otherwise any local user - * could inject rootfs_path / bind / dns / gateway values that the daemon - * then acts on. fstat the opened descriptor (not the path) to avoid a - * check/use race. */ - { - struct stat st; - if (fstat(fileno(f), &st) == 0 && (st.st_mode & (S_IWGRP | S_IWOTH))) { - ds_warn("Refusing config %s: writable by group/other (mode %04o)", - config_path, (unsigned)(st.st_mode & 07777)); - fclose(f); - return -1; - } - } - /* Clear existing unknown lines to avoid duplication on re-load */ free_config_unknown_lines(cfg); From 1f1639f46ddabcd2243947c0dceaa67d098fa99d Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 22:46:11 +0530 Subject: [PATCH 162/211] app: validate tarball is a Linux rootfs before install Inspect the selected tarball (without extracting) and confirm it contains the core top-level directories every Linux rootfs has (bin, sbin, etc, usr) before extraction. This prevents users from installing arbitrary archives such as photo backups or random tarballs. The validator lists the archive with busybox tar and bails out as soon as all markers are found, so a valid rootfs is confirmed without decompressing the whole image. Runs for both directory and sparse-image installs; on failure the install aborts and cleans up. Signed-off-by: ravindu644 --- .../app/src/main/assets/validate_rootfs.sh | 75 +++++++++++++++++++ .../app/util/ContainerInstaller.kt | 60 +++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 Android/app/src/main/assets/validate_rootfs.sh diff --git a/Android/app/src/main/assets/validate_rootfs.sh b/Android/app/src/main/assets/validate_rootfs.sh new file mode 100644 index 00000000..050075bc --- /dev/null +++ b/Android/app/src/main/assets/validate_rootfs.sh @@ -0,0 +1,75 @@ +#!/system/bin/sh +# Rootfs Tarball Validator for Droidspaces +# Copyright (c) 2026 ravindu644 +# Inspects a container tarball (WITHOUT extracting) to confirm it actually +# contains a Linux rootfs, so arbitrary archives cannot be installed. + +# Parameters +TARBALL="$1" +BUSYBOX_PATH="${BUSYBOX_PATH:-/data/local/Droidspaces/bin/busybox}" +BB="$BUSYBOX_PATH" + +log() { $BB echo "$1" >&2; } + +# Check BusyBox +if [ ! -x "$BB" ]; then + log "BusyBox not found or not executable at $BB" + exit 1 +fi + +# Check tarball +if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then + log "Tarball not found: $TARBALL" + exit 1 +fi + +# Pick a decompressor from the extension (mirrors the extractor). +case "$TARBALL" in + *.xz) DECOMP="$BB xzcat" ;; + *) DECOMP="$BB zcat" ;; +esac + +# Stream entry names and confirm the core top-level directories every Linux +# rootfs has (bin, sbin, etc, usr). awk bails out the moment all four are +# found, so a valid rootfs is confirmed without decompressing the whole +# archive. Merged-usr distros still list bin/sbin as symlink entries, so this +# stays compatible. A tarball wrapped in a single top-level dir (e.g. +# rootfs/bin/...) is intentionally rejected - it would extract to the wrong +# layout anyway. +RESULT=$($DECOMP "$TARBALL" 2>/dev/null | $BB tar -tf - 2>/dev/null | $BB awk ' + { + name = $0 + sub(/^\.\//, "", name) + sub(/^\//, "", name) + split(name, parts, "/") + top = parts[1] + if (top == "bin") b = 1 + if (top == "sbin") s = 1 + if (top == "etc") e = 1 + if (top == "usr") u = 1 + if (b && s && e && u) { print "OK"; exit } + } + END { + if (!(b && s && e && u)) { + miss = "" + if (!b) miss = miss " bin" + if (!s) miss = miss " sbin" + if (!e) miss = miss " etc" + if (!u) miss = miss " usr" + print "MISSING" miss + } + }') + +case "$RESULT" in + OK) + exit 0 + ;; + MISSING*) + log "Not a Linux rootfs - missing top-level dirs:${RESULT#MISSING}" + exit 2 + ;; + *) + log "Could not read archive (corrupt or not a .tar.gz/.tar.xz?)" + exit 3 + ;; +esac diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt index 21b7831b..830cece8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt @@ -76,6 +76,10 @@ object ContainerInstaller { logger.i("Tarball copied: ${tempTarball.absolutePath}") + // Step 3.5: Verify the tarball is actually a Linux rootfs before we + // extract anything, so users can't install arbitrary archives. + validateRootfsTarball(context, tempTarball, logger) + // Step 4: Extract tarball (either to directory or sparse image) if (config.useSparseImage) { SparseImageInstaller.extract( @@ -242,6 +246,62 @@ object ContainerInstaller { + /** + * Inspect the tarball (without extracting) to confirm it contains a Linux + * rootfs, so users can't install arbitrary archives (photo backups, source + * zips, etc.). Throws on a confirmed-invalid archive; a validator that fails + * to load is treated as non-fatal (warn and continue). + */ + private suspend fun validateRootfsTarball( + context: Context, + tarball: File, + logger: ContainerLogger + ) { + logger.i("Inspecting tarball to verify it is a Linux rootfs...") + + // Copy validator script from assets + val scriptFile = File("${context.cacheDir}/validate_rootfs.sh") + try { + context.assets.open("validate_rootfs.sh").use { inputStream -> + FileOutputStream(scriptFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } catch (e: Exception) { + // Don't block installation if the validator itself can't be loaded. + logger.w("Warning: Failed to load rootfs validator, skipping check: ${e.message}") + return + } + + try { + // Make script executable + val chmodResult = Shell.cmd("chmod 755 \"${scriptFile.absolutePath}\" 2>&1").exec() + if (!chmodResult.isSuccess) { + logger.w("Warning: Failed to make validator executable, skipping check") + return + } + + val result = Shell.cmd( + "BUSYBOX_PATH=$BUSYBOX_PATH \"${scriptFile.absolutePath}\" \"${tarball.absolutePath}\" 2>&1" + ).exec() + + if (!result.isSuccess) { + val reason = result.out + .map { it.trim() } + .firstOrNull { it.isNotEmpty() } + ?: "selected file does not look like a Linux rootfs" + logger.e(reason) + throw Exception(reason) + } + } finally { + try { + scriptFile.delete() + } catch (e: Exception) { + logger.w("Warning: Failed to clean up validator script: ${e.message}") + } + } + } + /** * Apply post-extraction fixes to the rootfs (both sparse and directory modes). */ From 7ba97eec12a25edcb6fde217571d795f97093972 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sat, 18 Jul 2026 23:23:31 +0530 Subject: [PATCH 163/211] app: add launcher long-press shortcuts Add static app shortcuts for Containers, Panel and Settings, shown when long-pressing the launcher icon. Each deep-links into the same screen as the in-app navigation: Containers/Panel select their tab on the reused Home screen, and Settings is pushed on top of it. Routing is gated on completed onboarding, and a launched-from-history guard prevents the shortcut from re-firing when the app is reopened from Recents. Shortcut icons reuse the tab glyphs (Storage, Dashboard, Settings gear) on the app's launcher-background disc so they stay legible on any launcher. Signed-off-by: ravindu644 --- Android/app/src/main/AndroidManifest.xml | 5 +++ .../java/com/droidspaces/app/MainActivity.kt | 35 ++++++++++++++- .../ui/navigation/DroidspacesNavigation.kt | 35 ++++++++++++++- .../app/ui/screen/MainTabScreen.kt | 12 ++++++ .../res/drawable/ic_shortcut_containers.xml | 19 ++++++++ .../main/res/drawable/ic_shortcut_panel.xml | 19 ++++++++ .../res/drawable/ic_shortcut_settings.xml | 19 ++++++++ Android/app/src/main/res/xml/shortcuts.xml | 43 +++++++++++++++++++ 8 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 Android/app/src/main/res/drawable/ic_shortcut_containers.xml create mode 100644 Android/app/src/main/res/drawable/ic_shortcut_panel.xml create mode 100644 Android/app/src/main/res/drawable/ic_shortcut_settings.xml create mode 100644 Android/app/src/main/res/xml/shortcuts.xml diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index 8f254a33..eabe305f 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -35,6 +35,11 @@ + + + (null) + // ── POST_NOTIFICATIONS permission (Android 13+ / API 33+) ───────────────── // Samsung's SecFgsManagerController suppresses any FGS notification from apps // that don't hold this permission, which immediately strips the ONGOING flag @@ -107,6 +117,12 @@ class MainActivity : AppCompatActivity() { // suppressed by Samsung's notification manager before it starts. requestNotificationPermission() + // Skip shortcut handling when the activity is relaunched from Recents, + // otherwise the stale shortcut intent would re-fire the deep link. + if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) { + handleIntent(intent) + } + // Render UI immediately - no blocking operations setContent { ThemeWrapper { @@ -116,12 +132,29 @@ class MainActivity : AppCompatActivity() { color = MaterialTheme.colorScheme.background ) { DroidspacesNavigation( - onContentReady = { isLoading = false } + onContentReady = { isLoading = false }, + pendingShortcut = pendingShortcut, + onShortcutHandled = { pendingShortcut = null } ) } } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + pendingShortcut = when (intent.action) { + ACTION_SHORTCUT_CONTAINERS -> "containers" + ACTION_SHORTCUT_PANEL -> "panel" + ACTION_SHORTCUT_SETTINGS -> "settings" + else -> pendingShortcut + } + } } @Composable diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index 0df66ee8..12405793 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -21,6 +21,7 @@ import com.droidspaces.app.util.Constants import com.droidspaces.app.util.AnimationUtils import com.droidspaces.app.ui.screen.InstallationScreen import com.droidspaces.app.ui.screen.MainTabScreen +import com.droidspaces.app.ui.screen.TabItem import com.droidspaces.app.ui.screen.RootCheckScreen import com.droidspaces.app.ui.screen.SettingsScreen import com.droidspaces.app.ui.screen.RequirementsScreen @@ -108,11 +109,17 @@ sealed class Screen(val route: String) { @Composable fun DroidspacesNavigation( navController: NavHostController = rememberNavController(), - onContentReady: () -> Unit = {} + onContentReady: () -> Unit = {}, + pendingShortcut: String? = null, + onShortcutHandled: () -> Unit = {} ) { val context = LocalContext.current val prefsManager = remember { PreferencesManager.getInstance(context) } + // Tab requested by a launcher shortcut (Containers / Panel). Fed into + // MainTabScreen, which scrolls the pager to it and clears the request. + var requestedTab by remember { mutableStateOf(null) } + // Shared ViewModels scoped to Activity to ensure state consistency across screens val activity = context as? ComponentActivity val sharedContainerViewModel: ContainerViewModel = if (activity != null) { @@ -144,6 +151,30 @@ fun DroidspacesNavigation( } } + // Route a launcher long-press shortcut to its destination. Containers/Panel + // select a tab on the (reused) Home screen; Settings is pushed on top of it. + // Ignored until onboarding is complete so we never deep-link past setup. + LaunchedEffect(pendingShortcut) { + val shortcut = pendingShortcut ?: return@LaunchedEffect + if (prefsManager.isSetupCompleted) { + when (shortcut) { + "settings" -> navController.navigate(Screen.Settings.route) { + popUpTo(Screen.Home.route) { inclusive = false } + launchSingleTop = true + } + "containers", "panel" -> { + requestedTab = + if (shortcut == "containers") TabItem.Containers else TabItem.ControlPanel + navController.navigate(Screen.Home.createRoute()) { + popUpTo(Screen.Home.route) { inclusive = false } + launchSingleTop = true + } + } + } + } + onShortcutHandled() + } + // Define animation specs - consistent 200ms for snappy feel val defaultEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { fadeIn(animationSpec = AnimationUtils.fastSpec()) @@ -241,6 +272,8 @@ fun DroidspacesNavigation( containerViewModel = sharedContainerViewModel, // skipInitialRefresh=false when fromInstallation=true (invert the logic) skipInitialRefresh = !fromInstallation, + requestedTab = requestedTab, + onRequestedTabConsumed = { requestedTab = null }, onNavigateToSettings = { navController.navigate(Screen.Settings.route) }, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt index 6b502a54..a33159b2 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt @@ -58,6 +58,8 @@ enum class TabItem(val titleResId: Int, val icon: androidx.compose.ui.graphics.v fun MainTabScreen( containerViewModel: ContainerViewModel, skipInitialRefresh: Boolean = false, + requestedTab: TabItem? = null, + onRequestedTabConsumed: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onNavigateToInstallation: () -> Unit = {}, onNavigateToContainerInstallation: (android.net.Uri) -> Unit = {}, @@ -77,6 +79,16 @@ fun MainTabScreen( val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabs.size }) val selectedTab = tabs[pagerState.currentPage] + // Jump to the tab requested by a launcher shortcut, then clear the request. + LaunchedEffect(requestedTab) { + val target = requestedTab ?: return@LaunchedEffect + val index = tabs.indexOf(target) + if (index >= 0) { + pagerState.scrollToPage(index) + } + onRequestedTabConsumed() + } + // Track which container has its action drawer expanded (hoisted for global collapse) var expandedContainerName by rememberSaveable { mutableStateOf(null) } diff --git a/Android/app/src/main/res/drawable/ic_shortcut_containers.xml b/Android/app/src/main/res/drawable/ic_shortcut_containers.xml new file mode 100644 index 00000000..49dafba4 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_shortcut_containers.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/Android/app/src/main/res/drawable/ic_shortcut_panel.xml b/Android/app/src/main/res/drawable/ic_shortcut_panel.xml new file mode 100644 index 00000000..24d683f9 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_shortcut_panel.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/Android/app/src/main/res/drawable/ic_shortcut_settings.xml b/Android/app/src/main/res/drawable/ic_shortcut_settings.xml new file mode 100644 index 00000000..417ccc07 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_shortcut_settings.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/Android/app/src/main/res/xml/shortcuts.xml b/Android/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..c7eefc36 --- /dev/null +++ b/Android/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + From 0b270d3d3b80eb4e0c3267a969c5ba4a7610c533 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 19 Jul 2026 14:58:12 +0200 Subject: [PATCH 164/211] Translated using Weblate (Romanian) (#243) Currently translated at 76.5% (394 of 515 strings) Translated using Weblate (Romanian) Currently translated at 100.0% (3 of 3 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (515 of 515 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/ro/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/ro/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/zh_Hans/ Translation: Droidspaces/Plurals Translation: Droidspaces/Strings Co-authored-by: PixelThrived Co-authored-by: seriaTvT <63958697+seriaTvT@users.noreply.github.com> --- .../app/src/main/res/values-ro/plurals.xml | 5 +++++ .../app/src/main/res/values-ro/strings.xml | 22 +++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 3 +++ 3 files changed, 30 insertions(+) diff --git a/Android/app/src/main/res/values-ro/plurals.xml b/Android/app/src/main/res/values-ro/plurals.xml index 702af8da..9025c341 100644 --- a/Android/app/src/main/res/values-ro/plurals.xml +++ b/Android/app/src/main/res/values-ro/plurals.xml @@ -10,4 +10,9 @@ %d commit-uri Commit-urile %d + + %d sesiune activă + %d sesiuni active + %d de sesiuni active + diff --git a/Android/app/src/main/res/values-ro/strings.xml b/Android/app/src/main/res/values-ro/strings.xml index e82e7408..7992c139 100644 --- a/Android/app/src/main/res/values-ro/strings.xml +++ b/Android/app/src/main/res/values-ro/strings.xml @@ -379,4 +379,26 @@ Scrie \"NU VOI RAPORTA NICIUN BUG\" în majuscule pentru a confirma: NU VOI RAPORTA NICIUN BUG Începând cu Droidspaces v6.1.0, udevd are acces adevărat la subsistemele USB, block, input, TTY și network (rețea). Container-ul tău poate accesa hardware pe care îl utilizează Android.\n\nDacă tastatura, cardul SD, sau dispozitivele USB nu mai funcționează în Android după ce pornești un container - acela NU este un bug. Container-ul l-a luat, fiindcă ai dorit acces complet la hardware.\n\nNU raporta probleme cauzate de conflicte de hardware sau kernel panics în timp ce utilizezi acest mod. + Backend + Cerințe pentru Termux + Culoare de accent + GitHub + OK + Introduceți numele exact al containerului pentru confirmare: + PID: %1$d + HW + GPU + X11 + VirGL + PulseAudio + Cgroup V1 + Namespace-uri de utilizator (user namespaces) + Arhivă tar + %1$s: %2$s + %1$s: %2$s + Utilizare spațiu de stocare + %1$s / %2$s utilizat + Se calculează… + Systemd + Configurare PulseAudio diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index b0b5c15b..b52946cc 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -512,4 +512,7 @@ 允许用户命名空间 用户命名空间 允许非特权用户命名空间,Flatpak/bwrap/Firefox等软件需要此功能。但是,若内核未打补丁,设备可能易受某些漏洞利用攻击影响(如 Fragnesia) + 强调色 + 完整输入容器名称以确认: + 调试 From 56c8f758faff1b5dec0631d6675fd8bbabfd7eb4 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:26 +0530 Subject: [PATCH 165/211] refactor: unify Edit/Config screens into ContainerConfigForm + ContainerConfigState EditContainerScreen and ContainerConfigScreen were field-for-field duplicates of the same ~28-field container config form (~700 lines cloned, plus a duplicated bind-mount dialog). Extract the shared body into a single ContainerConfigForm composable with fully hoisted state, and introduce a ContainerConfigState data class with centralized ContainerInfo.toConfigState()/withConfig() mappings. - EditContainerScreen: 28 editable vars + 28 parallel saved* vars + a 30-line derivedStateOf + two more copies of the field list collapse to 'state != savedState || hostname != savedHostname' and one withConfig() call. - ContainerConfigScreen: the 28 initial* params and 28-positional-arg onNext callback (DT-2 transposition landmine) become a single ContainerConfigState in and one onNext(state) out. - ContainerInstallationViewModel: 28 mutableStateOf fields + 28-arg setConfig + hand-listed reset() collapse to one configState value. Net -1184 lines. No behavior change. Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/component/ContainerConfigForm.kt | 755 ++++++++++ .../ui/navigation/DroidspacesNavigation.kt | 33 +- .../app/ui/screen/ContainerConfigScreen.kt | 958 +------------ .../app/ui/screen/EditContainerScreen.kt | 1253 ++--------------- .../ContainerInstallationViewModel.kt | 207 +-- .../app/util/ContainerConfigState.kt | 114 ++ 6 files changed, 1068 insertions(+), 2252 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt create mode 100644 Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt new file mode 100644 index 00000000..e4d18a21 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -0,0 +1,755 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.animation.AnimatedVisibility +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.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.imePadding +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.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AutoDelete +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Cyclone +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.GppBad +import androidx.compose.material.icons.filled.GppMaybe +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droidspaces.app.R +import com.droidspaces.app.ui.util.rememberClearFocus +import com.droidspaces.app.util.BindMount +import com.droidspaces.app.util.Constants +import com.droidspaces.app.util.ContainerConfigState +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.GatewayErrors + +/** Count non-comment `key=value` lines. Shared by the config screens. */ +private fun countEnvVars(content: String): Int = + content.lines() + .map { it.trim() } + .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } + +/** + * The single, shared container-configuration form used by both the Create wizard + * ([com.droidspaces.app.ui.screen.ContainerConfigScreen]) and the Edit screen + * ([com.droidspaces.app.ui.screen.EditContainerScreen]). + * + * State is fully hoisted: the caller owns a [ContainerConfigState] and receives + * every edit via [onStateChange]. Transient UI (dialog visibility, NAT octet + * text) stays local. [gatewayErrors]/[collisionContainer] are computed by the + * caller (which also needs them to gate its action button) and passed in for + * display. [leadingContent] renders caller-specific header rows (e.g. the Edit + * screen's hostname field) at the top of the scrolling column. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContainerConfigForm( + state: ContainerConfigState, + onStateChange: (ContainerConfigState) -> Unit, + installedContainers: List, + selfName: String, + gatewayErrors: GatewayErrors, + collisionContainer: ContainerInfo?, + modifier: Modifier = Modifier, + leadingContent: @Composable ColumnScope.() -> Unit = {}, +) { + val context = LocalContext.current + val clearFocus = rememberClearFocus() + + var showFilePicker by remember { mutableStateOf(false) } + var showDestDialog by remember { mutableStateOf(false) } + var tempSrcPath by remember { mutableStateOf("") } + var showEnvDialog by remember { mutableStateOf(false) } + var showPrivilegedDialog by remember { mutableStateOf(false) } + var showHwAccessDialog by remember { mutableStateOf(false) } + + val modernFieldShape = RoundedCornerShape(16.dp) + val modernFieldColors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + + if (showFilePicker) { + FilePickerDialog( + onDismiss = { showFilePicker = false }, + onConfirm = { path -> + tempSrcPath = path + showFilePicker = false + showDestDialog = true + } + ) + } + + if (showDestDialog) { + var destPath by remember { mutableStateOf("") } + var roEnabled by remember { mutableStateOf(false) } + Dialog( + onDismissRequest = { showDestDialog = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .imePadding(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(context.getString(R.string.enter_container_path), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + OutlinedTextField( + value = destPath, + onValueChange = { destPath = it }, + label = { Text(context.getString(R.string.container_path_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = modernFieldShape, + colors = modernFieldColors + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(context.getString(R.string.read_only), style = MaterialTheme.typography.bodyMedium) + Switch(checked = roEnabled, onCheckedChange = { roEnabled = it }) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { clearFocus(); showDestDialog = false }), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + } + } + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( + enabled = destPath.startsWith("/"), + onClick = { + clearFocus() + if (destPath.isNotBlank()) { + onStateChange(state.copy(bindMounts = state.bindMounts + BindMount(tempSrcPath, destPath, roEnabled))) + showDestDialog = false + } + } + ), + shape = RoundedCornerShape(14.dp), + color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text( + context.getString(R.string.ok), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } + } + } + } + } + + if (showPrivilegedDialog) { + PrivilegedModeDialog( + initialPrivileged = state.privileged, + onConfirm = { tags -> + onStateChange(state.copy(privileged = tags)) + showPrivilegedDialog = false + }, + onDismiss = { showPrivilegedDialog = false } + ) + } + + if (showHwAccessDialog) { + HardwareAccessDialog( + onConfirm = { + onStateChange(state.copy(enableHwAccess = true)) + showHwAccessDialog = false + }, + onDismiss = { showHwAccessDialog = false } + ) + } + + if (showEnvDialog) { + EnvironmentVariablesDialog( + initialContent = state.envFileContent, + onConfirm = { newContent -> + onStateChange(state.copy(envFileContent = newContent)) + showEnvDialog = false + }, + onDismiss = { showEnvDialog = false } + ) + } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + leadingContent() + + Text( + text = context.getString(R.string.cat_networking), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) + + DsDropdown( + label = context.getString(R.string.network_mode), + selected = state.netMode, + options = listOf("nat", "host", "none", "gateway"), + displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; else -> R.string.network_mode_host }) }, + onSelect = { mode -> + clearFocus() + onStateChange(state.copy(netMode = mode, disableIPv6 = if (mode != "host") false else state.disableIPv6)) + }, + leadingIcon = Icons.Default.Public + ) + + GatewaySettingsSection( + visible = state.netMode == "gateway", + gatewayContainer = state.gatewayContainer, + onGatewayContainerChange = { clearFocus(); onStateChange(state.copy(gatewayContainer = it)) }, + gatewayNet = state.gatewayNet, + onGatewayNetChange = { onStateChange(state.copy(gatewayNet = it)) }, + gatewayIface = state.gatewayIface, + onGatewayIfaceChange = { onStateChange(state.copy(gatewayIface = it)) }, + gatewayBridge = state.gatewayBridge, + onGatewayBridgeChange = { onStateChange(state.copy(gatewayBridge = it)) }, + selfName = selfName, + installedContainers = installedContainers, + errors = gatewayErrors + ) + + if (state.netMode == "nat") { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = context.getString(R.string.nat_settings), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = context.getString(R.string.static_ip_address), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp) + ) + Text( + text = context.getString(R.string.static_ip_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + val octets = remember(state.staticNatIp) { + val parts = state.staticNatIp.split(".") + if (parts.size == 4) Pair(parts[2], parts[3]) else Pair("", "") + } + var octet3 by remember(octets) { mutableStateOf(octets.first) } + var octet4 by remember(octets) { mutableStateOf(octets.second) } + + val updateIp = { o3: String, o4: String -> + onStateChange( + state.copy( + staticNatIp = if (o3.isBlank() && o4.isBlank()) "" else "${Constants.NAT_IP_PREFIX}.$o3.$o4" + ) + ) + } + + val isOctet3Valid = remember(octet3) { + octet3.isEmpty() || (octet3.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) + } + val isOctet4Valid = remember(octet4) { + octet4.isEmpty() || (octet4.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${Constants.NAT_IP_PREFIX}.", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp) + ) + OutlinedTextField( + value = octet3, + onValueChange = { + if (it.length <= 3 && it.all { c -> c.isDigit() }) { + octet3 = it + updateIp(it, octet4) + } + }, + label = { Text(context.getString(R.string.octet_label, 3)) }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + isError = !isOctet3Valid, + supportingText = { if (!isOctet3Valid) Text(context.getString(R.string.error_octet_range)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + Text( + text = ".", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp) + ) + OutlinedTextField( + value = octet4, + onValueChange = { + if (it.length <= 3 && it.all { c -> c.isDigit() }) { + octet4 = it + updateIp(octet3, it) + } + }, + label = { Text(context.getString(R.string.octet_label, 4)) }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + isError = !isOctet4Valid, + supportingText = { if (!isOctet4Valid) Text(context.getString(R.string.error_octet_range)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + } + + if (collisionContainer != null) { + Text( + text = context.getString(R.string.error_ip_collision, collisionContainer.name), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp) + ) + } + + Text( + text = context.getString(R.string.upstream_interface_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = context.getString(R.string.upstream_interface_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + UpstreamInterfaceList( + upstreamInterfaces = state.upstreamInterfaces, + onInterfacesChange = { onStateChange(state.copy(upstreamInterfaces = it)) } + ) + + Text( + text = context.getString(R.string.port_forwarding), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp) + ) + PortForwardingList( + portForwards = state.portForwards, + onPortForwardsChange = { onStateChange(state.copy(portForwards = it)) } + ) + } + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) + + val isDnsError = remember(state.dnsServers) { + state.dnsServers.isNotEmpty() && !state.dnsServers.all { it.isDigit() || it == '.' || it == ':' || it == ',' } + } + OutlinedTextField( + value = state.dnsServers, + onValueChange = { onStateChange(state.copy(dnsServers = it)) }, + label = { Text(context.getString(R.string.dns_servers_label)) }, + supportingText = { if (isDnsError) Text(context.getString(R.string.dns_servers_hint)) }, + isError = isDnsError, + placeholder = { Text(context.getString(R.string.dns_servers_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(Icons.Default.Dns, contentDescription = null) } + ) + + val ipv6IsForced = state.netMode != "host" + ToggleCard( + icon = Icons.Default.NetworkCheck, + title = context.getString(R.string.disable_ipv6), + description = if (ipv6IsForced) context.getString(R.string.disable_ipv6_nat_forced) else context.getString(R.string.disable_ipv6_description), + checked = if (ipv6IsForced) true else state.disableIPv6, + onCheckedChange = { clearFocus(); onStateChange(state.copy(disableIPv6 = it)) }, + enabled = !ipv6IsForced + ) + + Text( + text = context.getString(R.string.cat_integration), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp) + ) + + ToggleCard( + icon = Icons.Default.Storage, + title = context.getString(R.string.android_storage), + description = context.getString(R.string.android_storage_description), + checked = state.enableAndroidStorage, + onCheckedChange = { clearFocus(); onStateChange(state.copy(enableAndroidStorage = it)) } + ) + + ToggleCard( + icon = Icons.Default.Devices, + title = context.getString(R.string.hardware_access), + description = context.getString(R.string.hardware_access_description), + checked = state.enableHwAccess, + onCheckedChange = { newValue -> + clearFocus() + if (newValue) showHwAccessDialog = true else onStateChange(state.copy(enableHwAccess = false)) + } + ) + + ToggleCard( + icon = Icons.Default.Memory, + title = context.getString(R.string.gpu_access), + description = context.getString(R.string.gpu_access_description), + checked = if (state.enableHwAccess) true else state.enableGpuMode, + onCheckedChange = { if (!state.enableHwAccess) { clearFocus(); onStateChange(state.copy(enableGpuMode = it)) } }, + enabled = !state.enableHwAccess + ) + + ToggleCard( + painter = painterResource(R.drawable.ic_x11), + title = context.getString(R.string.termux_x11), + description = context.getString(R.string.termux_x11_description), + checked = state.enableTermuxX11, + onCheckedChange = { onStateChange(state.copy(enableTermuxX11 = it)) }, + enabled = true + ) + + ToggleCard( + icon = Icons.Default.Layers, + title = context.getString(R.string.enable_virgl), + description = context.getString(R.string.enable_virgl_description), + checked = state.enableVirgl, + onCheckedChange = { clearFocus(); onStateChange(state.copy(enableVirgl = it)) }, + enabled = true + ) + + ToggleCard( + icon = Icons.AutoMirrored.Filled.VolumeUp, + title = context.getString(R.string.enable_pulseaudio), + description = context.getString(R.string.enable_pulseaudio_description), + checked = state.enablePulseaudio, + onCheckedChange = { clearFocus(); onStateChange(state.copy(enablePulseaudio = it)) }, + enabled = true + ) + + Text( + text = context.getString(R.string.cat_security), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp) + ) + + ToggleCard( + icon = Icons.Default.Security, + title = context.getString(R.string.selinux_permissive), + description = context.getString(R.string.selinux_permissive_description), + checked = state.selinuxPermissive, + onCheckedChange = { clearFocus(); onStateChange(state.copy(selinuxPermissive = it)) } + ) + + val isSeccompDisabled = state.privileged.contains("noseccomp") || state.privileged.contains("full") + // /proc/self/setgroups only exists when CONFIG_USER_NS is enabled. + val usernsSupported = remember { java.io.File("/proc/self/setgroups").exists() } + + LaunchedEffect(isSeccompDisabled, usernsSupported) { + var s = state + if (isSeccompDisabled) s = s.copy(blockNestedNs = false) + if (isSeccompDisabled && usernsSupported) s = s.copy(allowUserns = true) + if (!usernsSupported) s = s.copy(allowUserns = false) + if (s != state) onStateChange(s) + } + + ToggleCard( + icon = Icons.Default.Groups, + title = context.getString(R.string.allow_userns), + description = if (usernsSupported) context.getString(R.string.allow_userns_description) else context.getString(R.string.allow_userns_description_not_supported), + checked = state.allowUserns, + onCheckedChange = { clearFocus(); onStateChange(state.copy(allowUserns = it)) }, + enabled = !isSeccompDisabled && usernsSupported + ) + + ToggleCard( + icon = Icons.Default.AutoDelete, + title = context.getString(R.string.volatile_mode), + description = context.getString(R.string.volatile_mode_description), + checked = state.volatileMode, + onCheckedChange = { clearFocus(); onStateChange(state.copy(volatileMode = it)) } + ) + + ToggleCard( + icon = Icons.Default.Cyclone, + title = context.getString(R.string.force_cgroupv1), + description = context.getString(R.string.force_cgroupv1_description), + checked = state.forceCgroupv1, + onCheckedChange = { clearFocus(); onStateChange(state.copy(forceCgroupv1 = it)) } + ) + + ToggleCard( + icon = Icons.Default.GppBad, + title = context.getString(R.string.manual_deadlock_shield), + description = context.getString(R.string.manual_deadlock_shield_description), + checked = if (isSeccompDisabled) false else state.blockNestedNs, + onCheckedChange = { clearFocus(); onStateChange(state.copy(blockNestedNs = it)) }, + enabled = !isSeccompDisabled + ) + + SettingsRowCard( + title = context.getString(R.string.privileged_mode), + subtitle = if (state.privileged.isEmpty()) context.getString(R.string.not_configured) else state.privileged, + description = context.getString(R.string.privileged_mode_description), + icon = Icons.Default.GppMaybe, + onClick = { clearFocus(); showPrivilegedDialog = true } + ) + + ToggleCard( + icon = Icons.Default.PowerSettingsNew, + title = context.getString(R.string.run_at_boot), + description = context.getString(R.string.run_at_boot_description), + checked = state.runAtBoot, + onCheckedChange = { clearFocus(); onStateChange(state.copy(runAtBoot = it)) } + ) + + Text( + text = context.getString(R.string.cat_advanced), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp) + ) + + val envCount = countEnvVars(state.envFileContent) + val envSubtitle = if (envCount > 0) { + context.getString(R.string.environment_variables_configured, envCount) + } else { + context.getString(R.string.not_configured) + } + SettingsRowCard( + title = context.getString(R.string.environment_variables), + subtitle = envSubtitle, + icon = Icons.Default.Code, + onClick = { clearFocus(); showEnvDialog = true } + ) + + if (state.customInit.isNotEmpty()) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(imageVector = Icons.Default.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp)) + Text(text = context.getString(R.string.custom_init_warning), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + } + + OutlinedTextField( + value = state.customInit, + onValueChange = { newValue -> onStateChange(state.copy(customInit = newValue.filter { !it.isWhitespace() })) }, + label = { Text(context.getString(R.string.custom_init_label)) }, + placeholder = { Text(context.getString(R.string.custom_init_placeholder)) }, + supportingText = { + if (state.customInit.isNotEmpty() && !state.customInit.startsWith("/")) { + Text(context.getString(R.string.custom_init_error_absolute), color = MaterialTheme.colorScheme.error) + } else { + Text(context.getString(R.string.custom_init_hint)) + } + }, + isError = state.customInit.isNotEmpty() && !state.customInit.startsWith("/"), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(Icons.Default.Terminal, contentDescription = null) } + ) + + AnimatedVisibility( + visible = state.enableTermuxX11, + enter = expandVertically(animationSpec = tween(durationMillis = 300)) + fadeIn(animationSpec = tween(durationMillis = 300)), + exit = shrinkVertically(animationSpec = tween(durationMillis = 300)) + fadeOut(animationSpec = tween(durationMillis = 300)) + ) { + OutlinedTextField( + value = state.tx11ExtraFlags, + onValueChange = { onStateChange(state.copy(tx11ExtraFlags = it)) }, + label = { Text(context.getString(R.string.tx11_extra_flags_label)) }, + placeholder = { Text(context.getString(R.string.tx11_extra_flags_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(painter = painterResource(R.drawable.ic_x11), contentDescription = null, modifier = Modifier.size(15.dp)) } + ) + } + + AnimatedVisibility( + visible = state.enableVirgl, + enter = expandVertically(animationSpec = tween(durationMillis = 300)) + fadeIn(animationSpec = tween(durationMillis = 300)), + exit = shrinkVertically(animationSpec = tween(durationMillis = 300)) + fadeOut(animationSpec = tween(durationMillis = 300)) + ) { + OutlinedTextField( + value = state.virglExtraFlags, + onValueChange = { onStateChange(state.copy(virglExtraFlags = it)) }, + label = { Text(context.getString(R.string.virgl_extra_flags_label)) }, + placeholder = { Text(context.getString(R.string.virgl_extra_flags_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(Icons.Default.Layers, contentDescription = null) } + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = context.getString(R.string.bind_mounts), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + + state.bindMounts.forEach { mount -> + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(text = context.getString(R.string.host_path, mount.src), style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Ellipsis, maxLines = 1) + Text(text = context.getString(R.string.container_path, mount.dest), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, overflow = TextOverflow.Ellipsis, maxLines = 1) + if (mount.ro) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.padding(top = 4.dp)) { + Text(text = context.getString(R.string.read_only), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) + } + } + } + IconButton(onClick = { onStateChange(state.copy(bindMounts = state.bindMounts - mount)) }) { + Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + } + } + } + } + + val addBindBtnShape = RoundedCornerShape(16.dp) + Surface( + modifier = Modifier.fillMaxWidth().clip(addBindBtnShape).clickable( + onClick = { showFilePicker = true }, + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ), + shape = addBindBtnShape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = context.getString(R.string.add_bind_mount), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index 12405793..cfb0f5e1 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -339,38 +339,11 @@ fun DroidspacesNavigation( ) ContainerConfigScreen( - initialNetMode = viewModel.netMode, - initialDisableIPv6 = viewModel.disableIPv6, - initialEnableAndroidStorage = viewModel.enableAndroidStorage, - initialEnableHwAccess = viewModel.enableHwAccess, - initialEnableGpuMode = viewModel.enableGpuMode, - initialEnableTermuxX11 = viewModel.enableTermuxX11, - initialTx11ExtraFlags = viewModel.tx11ExtraFlags, - initialEnableVirgl = viewModel.enableVirgl, - initialVirglExtraFlags = viewModel.virglExtraFlags, - initialEnablePulseaudio = viewModel.enablePulseaudio, - initialSelinuxPermissive = viewModel.selinuxPermissive, - initialAllowUserns = viewModel.allowUserns, - initialVolatileMode = viewModel.volatileMode, - initialBindMounts = viewModel.bindMounts, - initialDnsServers = viewModel.dnsServers, - initialRunAtBoot = viewModel.runAtBoot, - initialCustomInit = viewModel.customInit, - initialStaticNatIp = viewModel.staticNatIp, - initialForceCgroupv1 = viewModel.forceCgroupv1, - initialBlockNestedNs = viewModel.blockNestedNs, - initialPrivileged = viewModel.privileged, - initialEnvFileContent = viewModel.envFileContent ?: "", - initialUpstreamInterfaces = viewModel.upstreamInterfaces, - initialPortForwards = viewModel.portForwards, - initialGatewayContainer = viewModel.gatewayContainer, - initialGatewayNet = viewModel.gatewayNet, - initialGatewayIface = viewModel.gatewayIface, - initialGatewayBridge = viewModel.gatewayBridge, + initialState = viewModel.configState, containerName = viewModel.containerName, installedContainers = sharedContainerViewModel.containerList, - onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, allowUserns, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge -> - viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, allowUserns, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, upstreamInterfaces, portForwards, forceCgroupv1, blockNestedNs, privileged, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) + onNext = { state -> + viewModel.setConfig(state) navController.navigate(Screen.SparseImageConfig.route) }, onBack = { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index 854095ce..bd8c8888 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -1,304 +1,78 @@ package com.droidspaces.app.ui.screen -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.droidspaces.app.ui.component.ToggleCard -import com.droidspaces.app.ui.component.DsDropdown -import androidx.compose.material.icons.filled.Public -import com.droidspaces.app.ui.component.UpstreamInterfaceList -import com.droidspaces.app.ui.component.PortForwardingList -import androidx.compose.ui.platform.LocalContext import com.droidspaces.app.R +import com.droidspaces.app.ui.component.ContainerConfigForm import com.droidspaces.app.ui.util.ClearFocusOnClickOutside - -import androidx.compose.ui.text.style.TextOverflow -import com.droidspaces.app.util.BindMount -import com.droidspaces.app.util.PortForward +import com.droidspaces.app.util.ContainerConfigState import com.droidspaces.app.util.ContainerInfo -import com.droidspaces.app.util.ContainerManager -import com.droidspaces.app.ui.component.GatewaySettingsSection -import kotlinx.coroutines.launch -import com.droidspaces.app.ui.component.FilePickerDialog -import com.droidspaces.app.ui.component.SettingsRowCard -import com.droidspaces.app.ui.component.EnvironmentVariablesDialog -import com.droidspaces.app.ui.component.PrivilegedModeDialog -import com.droidspaces.app.ui.component.HardwareAccessDialog -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.LinearEasing -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.foundation.shape.RoundedCornerShape -import kotlinx.coroutines.delay -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.ExperimentalLayoutApi +import com.droidspaces.app.util.ValidationUtils -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ContainerConfigScreen( - initialNetMode: String = "nat", - initialDisableIPv6: Boolean = false, - initialEnableAndroidStorage: Boolean = false, - initialEnableHwAccess: Boolean = false, - initialEnableGpuMode: Boolean = false, - initialEnableTermuxX11: Boolean = false, - initialTx11ExtraFlags: String = "", - initialEnableVirgl: Boolean = false, - initialVirglExtraFlags: String = "", - initialEnablePulseaudio: Boolean = false, - initialSelinuxPermissive: Boolean = false, - initialAllowUserns: Boolean = false, - initialVolatileMode: Boolean = false, - initialBindMounts: List = emptyList(), - initialDnsServers: String = "", - initialRunAtBoot: Boolean = false, - initialCustomInit: String = "", - initialStaticNatIp: String = "", - initialForceCgroupv1: Boolean = false, - initialBlockNestedNs: Boolean = false, - initialPrivileged: String = "", - initialEnvFileContent: String = "", - initialUpstreamInterfaces: List = emptyList(), - initialPortForwards: List = emptyList(), - initialGatewayContainer: String = "", - initialGatewayNet: String = "", - initialGatewayIface: String = "", - initialGatewayBridge: String = "", + initialState: ContainerConfigState = ContainerConfigState(), containerName: String = "", installedContainers: List = emptyList(), - onNext: ( - netMode: String, - disableIPv6: Boolean, - enableAndroidStorage: Boolean, - enableHwAccess: Boolean, - enableGpuMode: Boolean, - enableTermuxX11: Boolean, - tx11ExtraFlags: String, - enableVirgl: Boolean, - virglExtraFlags: String, - enablePulseaudio: Boolean, - selinuxPermissive: Boolean, - allowUserns: Boolean, - volatileMode: Boolean, - bindMounts: List, - dnsServers: String, - runAtBoot: Boolean, - customInit: String, - staticNatIp: String, - forceCgroupv1: Boolean, - blockNestedNs: Boolean, - privileged: String, - envFileContent: String?, - upstreamInterfaces: List, - portForwards: List, - gatewayContainer: String, - gatewayNet: String, - gatewayIface: String, - gatewayBridge: String - ) -> Unit, + onNext: (ContainerConfigState) -> Unit, onBack: () -> Unit ) { - var netMode by remember { mutableStateOf(initialNetMode) } - var disableIPv6 by remember { mutableStateOf(initialDisableIPv6) } - var enableAndroidStorage by remember { mutableStateOf(initialEnableAndroidStorage) } - var enableHwAccess by remember { mutableStateOf(initialEnableHwAccess) } - var enableGpuMode by remember { mutableStateOf(initialEnableGpuMode) } - var enableTermuxX11 by remember { mutableStateOf(initialEnableTermuxX11) } - var tx11ExtraFlags by remember { mutableStateOf(initialTx11ExtraFlags) } - var enableVirgl by remember { mutableStateOf(initialEnableVirgl) } - var virglExtraFlags by remember { mutableStateOf(initialVirglExtraFlags) } - var enablePulseaudio by remember { mutableStateOf(initialEnablePulseaudio) } - var selinuxPermissive by remember { mutableStateOf(initialSelinuxPermissive) } - var allowUserns by remember { mutableStateOf(initialAllowUserns) } - var volatileMode by remember { mutableStateOf(initialVolatileMode) } - var bindMounts by remember { mutableStateOf(initialBindMounts) } - var dnsServers by remember { mutableStateOf(initialDnsServers) } - var runAtBoot by remember { mutableStateOf(initialRunAtBoot) } - var customInit by remember { mutableStateOf(initialCustomInit) } - var staticNatIp by remember { mutableStateOf(initialStaticNatIp) } - var forceCgroupv1 by remember { mutableStateOf(initialForceCgroupv1) } - var blockNestedNs by remember { mutableStateOf(initialBlockNestedNs) } - var envFileContent by remember { mutableStateOf(initialEnvFileContent) } - var upstreamInterfaces by remember { mutableStateOf(initialUpstreamInterfaces) } - var portForwards by remember { mutableStateOf(initialPortForwards) } - var privileged by remember { mutableStateOf(initialPrivileged) } - var gatewayContainer by remember { mutableStateOf(initialGatewayContainer) } - var gatewayNet by remember { mutableStateOf(initialGatewayNet) } - var gatewayIface by remember { mutableStateOf(initialGatewayIface) } - var gatewayBridge by remember { mutableStateOf(initialGatewayBridge) } val context = LocalContext.current + var state by remember { mutableStateOf(initialState) } - val gatewayErrors = com.droidspaces.app.util.ValidationUtils.validateGatewayConfig( + val gatewayErrors = ValidationUtils.validateGatewayConfig( selfName = containerName, - gatewayContainer = gatewayContainer, - net = gatewayNet, - iface = gatewayIface, - bridge = gatewayBridge, + gatewayContainer = state.gatewayContainer, + net = state.gatewayNet, + iface = state.gatewayIface, + bridge = state.gatewayBridge, installed = installedContainers, context = context ) - val collisionContainer = remember(netMode, staticNatIp, installedContainers) { - if (netMode != "nat" || staticNatIp.isEmpty()) null - else installedContainers.find { it.name != containerName && it.staticNatIp == staticNatIp } + val collisionContainer = remember(state.netMode, state.staticNatIp, installedContainers) { + if (state.netMode != "nat" || state.staticNatIp.isEmpty()) null + else installedContainers.find { it.name != containerName && it.staticNatIp == state.staticNatIp } } - val canProceed = (netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null - - // Internal UI States - var showFilePicker by remember { mutableStateOf(false) } - var showDestDialog by remember { mutableStateOf(false) } - var tempSrcPath by remember { mutableStateOf("") } - - if (showFilePicker) { - FilePickerDialog( - onDismiss = { showFilePicker = false }, - onConfirm = { path -> - tempSrcPath = path - showFilePicker = false - showDestDialog = true - } - ) - } - - if (showDestDialog) { - var destPath by remember { mutableStateOf("") } - var roEnabled by remember { mutableStateOf(false) } - val clearFocus = com.droidspaces.app.ui.util.rememberClearFocus() - Dialog( - onDismissRequest = { showDestDialog = false }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .imePadding(), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text(context.getString(R.string.enter_container_path), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - OutlinedTextField( - value = destPath, - onValueChange = { destPath = it }, - label = { Text(context.getString(R.string.container_path_placeholder)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(context.getString(R.string.read_only), style = MaterialTheme.typography.bodyMedium) - Switch(checked = roEnabled, onCheckedChange = { roEnabled = it }) - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { clearFocus(); showDestDialog = false }), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = destPath.startsWith("/"), - onClick = { - clearFocus() - if (destPath.isNotBlank()) { - bindMounts = bindMounts + BindMount(tempSrcPath, destPath, roEnabled) - showDestDialog = false - } - } - ), - shape = RoundedCornerShape(14.dp), - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } - } - } - } - } - - var showEnvDialog by remember { mutableStateOf(false) } - var showPrivilegedDialog by remember { mutableStateOf(false) } - var showHwAccessDialog by remember { mutableStateOf(false) } - - if (showPrivilegedDialog) { - PrivilegedModeDialog( - initialPrivileged = privileged, - onConfirm = { tags -> - privileged = tags - showPrivilegedDialog = false - }, - onDismiss = { showPrivilegedDialog = false } - ) - } - - if (showHwAccessDialog) { - HardwareAccessDialog( - onConfirm = { - enableHwAccess = true - showHwAccessDialog = false - }, - onDismiss = { showHwAccessDialog = false } - ) - } - - if (showEnvDialog) { - EnvironmentVariablesDialog( - initialContent = envFileContent, - onConfirm = { newContent -> - envFileContent = newContent - showEnvDialog = false - }, - onDismiss = { showEnvDialog = false } - ) - } + val canProceed = (state.netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null Scaffold( topBar = { @@ -331,18 +105,19 @@ fun ContainerConfigScreen( .clip(btnShape) .clickable( enabled = canProceed, - onClick = { - onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, allowUserns, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge) - }, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } + onClick = { onNext(state) }, + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } ), shape = btnShape, color = if (canProceed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { Icon( Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, @@ -368,629 +143,22 @@ fun ContainerConfigScreen( .consumeWindowInsets(innerPadding) .imePadding() ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp) - .padding(top = 24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = context.getString(R.string.container_options), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = context.getString(R.string.cat_networking), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 8.dp) - ) - - DsDropdown( - label = context.getString(R.string.network_mode), - selected = netMode, - options = listOf("nat", "host", "none", "gateway"), - displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; else -> R.string.network_mode_host }) }, - onSelect = { mode -> netMode = mode; if (mode != "host") disableIPv6 = false }, - leadingIcon = Icons.Default.Public - ) - - GatewaySettingsSection( - visible = netMode == "gateway", - gatewayContainer = gatewayContainer, - onGatewayContainerChange = { gatewayContainer = it }, - gatewayNet = gatewayNet, - onGatewayNetChange = { gatewayNet = it }, - gatewayIface = gatewayIface, - onGatewayIfaceChange = { gatewayIface = it }, - gatewayBridge = gatewayBridge, - onGatewayBridgeChange = { gatewayBridge = it }, - selfName = containerName, + ContainerConfigForm( + state = state, + onStateChange = { state = it }, installedContainers = installedContainers, - errors = gatewayErrors - ) - - // Instant show/hide (no expand/shrink) so switching modes doesn't fight - // the gateway section's animation — see the gateway/NAT divider note. - if (netMode == "nat") { - Column( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = context.getString(R.string.nat_settings), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - - // Static IP Address Configuration - Text( - text = context.getString(R.string.static_ip_address), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - Text( - text = context.getString(R.string.static_ip_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(bottom = 8.dp) - ) - - val octets = remember(staticNatIp) { - val parts = staticNatIp.split(".") - if (parts.size == 4) { - Pair(parts[2], parts[3]) - } else { - Pair("", "") - } - } - - var octet3 by remember(octets) { mutableStateOf(octets.first) } - var octet4 by remember(octets) { mutableStateOf(octets.second) } - - val updateIp = { o3: String, o4: String -> - staticNatIp = if (o3.isBlank() && o4.isBlank()) { - "" - } else { - "${com.droidspaces.app.util.Constants.NAT_IP_PREFIX}.$o3.$o4" - } - } - - val isOctet3Valid = remember(octet3) { - octet3.isEmpty() || (octet3.toIntOrNull()?.let { it in com.droidspaces.app.util.Constants.NAT_OCTET_MIN..com.droidspaces.app.util.Constants.NAT_OCTET_MAX } ?: false) - } - val isOctet4Valid = remember(octet4) { - octet4.isEmpty() || (octet4.toIntOrNull()?.let { it in com.droidspaces.app.util.Constants.NAT_OCTET_MIN..com.droidspaces.app.util.Constants.NAT_OCTET_MAX } ?: false) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${com.droidspaces.app.util.Constants.NAT_IP_PREFIX}.", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - - OutlinedTextField( - value = octet3, - onValueChange = { - if (it.length <= 3 && it.all { c -> c.isDigit() }) { - octet3 = it - updateIp(it, octet4) - } - }, - label = { Text(context.getString(R.string.octet_label, 3)) }, - modifier = Modifier.weight(1f), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - isError = !isOctet3Valid, - supportingText = { if (!isOctet3Valid) Text(context.getString(R.string.error_octet_range)) }, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number) - ) - - Text( - text = ".", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - - OutlinedTextField( - value = octet4, - onValueChange = { - if (it.length <= 3 && it.all { c -> c.isDigit() }) { - octet4 = it - updateIp(octet3, it) - } - }, - label = { Text(context.getString(R.string.octet_label, 4)) }, - modifier = Modifier.weight(1f), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - isError = !isOctet4Valid, - supportingText = { if (!isOctet4Valid) Text(context.getString(R.string.error_octet_range)) }, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number) - ) - } - - if (collisionContainer != null) { - Text( - text = context.getString(R.string.error_ip_collision, collisionContainer.name), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp) - ) - } - - // Upstream Interface (optional pin) - Text( - text = context.getString(R.string.upstream_interface_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = context.getString(R.string.upstream_interface_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - UpstreamInterfaceList( - upstreamInterfaces = upstreamInterfaces, - onInterfacesChange = { upstreamInterfaces = it } - ) - - // Port Forwards + selfName = containerName, + gatewayErrors = gatewayErrors, + collisionContainer = collisionContainer, + modifier = Modifier.fillMaxSize(), + leadingContent = { Text( - text = context.getString(R.string.port_forwarding), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - - PortForwardingList( - portForwards = portForwards, - onPortForwardsChange = { portForwards = it } + text = context.getString(R.string.container_options), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold ) } - } - - // Static divider (outside the animated blocks so mode switches stay - // smooth) separating the networking sub-settings from the DNS field. - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - - // DNS Servers input - val isDnsError = remember(dnsServers) { - dnsServers.isNotEmpty() && !dnsServers.all { it.isDigit() || it == '.' || it == ':' || it == ',' } - } - - OutlinedTextField( - value = dnsServers, - onValueChange = { dnsServers = it }, - label = { Text(context.getString(R.string.dns_servers_label)) }, - supportingText = { - if (isDnsError) { - Text(context.getString(R.string.dns_servers_hint)) - } - }, - isError = isDnsError, - placeholder = { Text(context.getString(R.string.dns_servers_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { - Icon(Icons.Default.Dns, contentDescription = null) - } - ) - - // In NAT/NONE mode, IPv6 is always disabled (forced). In host mode the user can opt in. - val ipv6IsForced = netMode != "host" - ToggleCard( - icon = Icons.Default.NetworkCheck, - title = context.getString(R.string.disable_ipv6), - description = if (ipv6IsForced) - context.getString(R.string.disable_ipv6_nat_forced) - else - context.getString(R.string.disable_ipv6_description), - checked = if (ipv6IsForced) true else disableIPv6, - onCheckedChange = { disableIPv6 = it }, - enabled = !ipv6IsForced - ) - - Text( - text = context.getString(R.string.cat_integration), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - ToggleCard( - icon = Icons.Default.Storage, - title = context.getString(R.string.android_storage), - description = context.getString(R.string.android_storage_description), - checked = enableAndroidStorage, - onCheckedChange = { enableAndroidStorage = it } - ) - - ToggleCard( - icon = Icons.Default.Devices, - title = context.getString(R.string.hardware_access), - description = context.getString(R.string.hardware_access_description), - checked = enableHwAccess, - onCheckedChange = { newValue -> - if (newValue) { - showHwAccessDialog = true - } else { - enableHwAccess = false - } - } - ) - - ToggleCard( - icon = Icons.Default.Memory, - title = context.getString(R.string.gpu_access), - description = context.getString(R.string.gpu_access_description), - checked = if (enableHwAccess) true else enableGpuMode, - onCheckedChange = { if (!enableHwAccess) enableGpuMode = it }, - enabled = !enableHwAccess - ) - - ToggleCard( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_x11), - title = context.getString(R.string.termux_x11), - description = context.getString(R.string.termux_x11_description), - checked = enableTermuxX11, - onCheckedChange = { enableTermuxX11 = it }, - enabled = true - ) - - ToggleCard( - icon = Icons.Default.Layers, - title = context.getString(R.string.enable_virgl), - description = context.getString(R.string.enable_virgl_description), - checked = enableVirgl, - onCheckedChange = { enableVirgl = it }, - enabled = true - ) - - ToggleCard( - icon = Icons.AutoMirrored.Filled.VolumeUp, - title = context.getString(R.string.enable_pulseaudio), - description = context.getString(R.string.enable_pulseaudio_description), - checked = enablePulseaudio, - onCheckedChange = { enablePulseaudio = it }, - enabled = true - ) - - Text( - text = context.getString(R.string.cat_security), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - ToggleCard( - icon = Icons.Default.Security, - title = context.getString(R.string.selinux_permissive), - description = context.getString(R.string.selinux_permissive_description), - checked = selinuxPermissive, - onCheckedChange = { selinuxPermissive = it } - ) - - val isSeccompDisabled = privileged.contains("noseccomp") || privileged.contains("full") - val usernsSupported = remember { java.io.File("/proc/self/setgroups").exists() } - - LaunchedEffect(isSeccompDisabled, usernsSupported) { - if (isSeccompDisabled) blockNestedNs = false - if (isSeccompDisabled && usernsSupported) allowUserns = true - if (!usernsSupported) allowUserns = false - } - - ToggleCard( - icon = Icons.Default.Groups, - title = context.getString(R.string.allow_userns), - description = if (usernsSupported) - context.getString(R.string.allow_userns_description) - else - context.getString(R.string.allow_userns_description_not_supported), - checked = allowUserns, - onCheckedChange = { allowUserns = it }, - enabled = !isSeccompDisabled && usernsSupported - ) - - ToggleCard( - icon = Icons.Default.AutoDelete, - title = context.getString(R.string.volatile_mode), - description = context.getString(R.string.volatile_mode_description), - checked = volatileMode, - onCheckedChange = { volatileMode = it } - ) - - ToggleCard( - icon = Icons.Default.Cyclone, - title = context.getString(R.string.force_cgroupv1), - description = context.getString(R.string.force_cgroupv1_description), - checked = forceCgroupv1, - onCheckedChange = { forceCgroupv1 = it } - ) - - ToggleCard( - icon = Icons.Default.GppBad, - title = context.getString(R.string.manual_deadlock_shield), - description = context.getString(R.string.manual_deadlock_shield_description), - checked = if (isSeccompDisabled) false else blockNestedNs, - onCheckedChange = { blockNestedNs = it }, - enabled = !isSeccompDisabled - ) - - SettingsRowCard( - title = context.getString(R.string.privileged_mode), - subtitle = if (privileged.isEmpty()) context.getString(R.string.not_configured) else privileged, - description = context.getString(R.string.privileged_mode_description), - icon = Icons.Default.GppMaybe, - onClick = { - showPrivilegedDialog = true - } - ) - - ToggleCard( - icon = Icons.Default.PowerSettingsNew, - title = context.getString(R.string.run_at_boot), - description = context.getString(R.string.run_at_boot_description), - checked = runAtBoot, - onCheckedChange = { runAtBoot = it } - ) - - Text( - text = context.getString(R.string.cat_advanced), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - // Environment Variables Row - fun countEnvVars(content: String): Int { - return content.lines() - .map { it.trim() } - .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } - } - - val envCount = countEnvVars(envFileContent) - val envSubtitle = if (envCount > 0) { - context.getString(R.string.environment_variables_configured, envCount) - } else { - context.getString(R.string.not_configured) - } - - SettingsRowCard( - title = context.getString(R.string.environment_variables), - subtitle = envSubtitle, - icon = Icons.Default.Code, - onClick = { - showEnvDialog = true - } ) - - // Custom Init Binary - if (customInit.isNotEmpty()) { - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - Text( - text = context.getString(R.string.custom_init_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - - OutlinedTextField( - value = customInit, - onValueChange = { customInit = it.filter { !it.isWhitespace() } }, - label = { Text(context.getString(R.string.custom_init_label)) }, - placeholder = { Text(context.getString(R.string.custom_init_placeholder)) }, - supportingText = { - if (customInit.isNotEmpty() && !customInit.startsWith("/")) { - Text(context.getString(R.string.custom_init_error_absolute), - color = MaterialTheme.colorScheme.error) - } else { - Text(context.getString(R.string.custom_init_hint)) - } - }, - isError = customInit.isNotEmpty() && !customInit.startsWith("/"), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { - Icon(Icons.Default.Terminal, contentDescription = null) - } - ) - - androidx.compose.animation.AnimatedVisibility( - visible = enableTermuxX11, - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - OutlinedTextField( - value = tx11ExtraFlags, - onValueChange = { tx11ExtraFlags = it }, - label = { Text(context.getString(R.string.tx11_extra_flags_label)) }, - placeholder = { Text(context.getString(R.string.tx11_extra_flags_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { - Icon( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_x11), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) - } - ) - } - - androidx.compose.animation.AnimatedVisibility( - visible = enableVirgl, - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - OutlinedTextField( - value = virglExtraFlags, - onValueChange = { virglExtraFlags = it }, - label = { Text(context.getString(R.string.virgl_extra_flags_label)) }, - placeholder = { Text(context.getString(R.string.virgl_extra_flags_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { Icon(Icons.Default.Layers, contentDescription = null) } - ) - } - - - // Bind Mounts Section - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = context.getString(R.string.bind_mounts), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - - bindMounts.forEach { mount -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = context.getString(R.string.host_path, mount.src), - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - Text( - text = context.getString(R.string.container_path, mount.dest), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - if (mount.ro) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.padding(top = 4.dp) - ) { - Text( - text = context.getString(R.string.read_only), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - IconButton(onClick = { - bindMounts = bindMounts - mount - }) { - Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) - } - } - } - } - - OutlinedButton( - onClick = { showFilePicker = true }, - modifier = Modifier.fillMaxWidth(), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(context.getString(R.string.add_bind_mount)) - } - - Spacer(modifier = Modifier.height(16.dp)) } } } -} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index 3ac640aa..858b780e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -1,75 +1,72 @@ package com.droidspaces.app.ui.screen -import androidx.compose.ui.graphics.Color -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.* -import com.droidspaces.app.ui.util.LoadingIndicator -import com.droidspaces.app.ui.util.LoadingSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.droidspaces.app.ui.util.rememberClearFocus +import com.droidspaces.app.R +import com.droidspaces.app.ui.component.ContainerConfigForm import com.droidspaces.app.ui.util.ClearFocusOnClickOutside -import com.droidspaces.app.ui.util.FocusUtils -import androidx.compose.foundation.clickable -import com.droidspaces.app.ui.component.ToggleCard +import com.droidspaces.app.ui.util.LoadingIndicator +import com.droidspaces.app.ui.util.rememberClearFocus +import com.droidspaces.app.ui.viewmodel.ContainerViewModel import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.SystemInfoManager -import com.droidspaces.app.util.Constants import com.droidspaces.app.util.ValidationUtils -import com.droidspaces.app.ui.viewmodel.ContainerViewModel +import com.droidspaces.app.util.toConfigState +import com.droidspaces.app.util.withConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.droidspaces.app.R -import com.droidspaces.app.ui.component.FilePickerDialog -import com.droidspaces.app.util.BindMount -import androidx.compose.ui.text.style.TextOverflow -import com.droidspaces.app.ui.component.SettingsRowCard -import com.droidspaces.app.ui.component.EnvironmentVariablesDialog -import com.droidspaces.app.util.PortForward -import com.droidspaces.app.ui.component.PrivilegedModeDialog -import com.droidspaces.app.ui.component.HardwareAccessDialog -import com.droidspaces.app.ui.component.DsDropdown -import com.droidspaces.app.ui.component.GatewaySettingsSection -import androidx.compose.material.icons.filled.Public -import com.droidspaces.app.ui.component.UpstreamInterfaceList -import com.droidspaces.app.ui.component.PortForwardingList -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.LinearEasing -import androidx.compose.ui.graphics.graphicsLayer -import kotlinx.coroutines.delay -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.ExperimentalLayoutApi - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditContainerScreen( container: ContainerInfo, @@ -80,8 +77,13 @@ fun EditContainerScreen( val scope = rememberCoroutineScope() val clearFocus = rememberClearFocus() - // State for editable fields + // Editable config + baseline for change detection. + var state by remember { mutableStateOf(container.toConfigState()) } + var savedState by remember { mutableStateOf(container.toConfigState()) } + + // Hostname is edited here (the create wizard collects it on a separate screen). var hostname by remember { mutableStateOf(container.hostname) } + var savedHostname by remember { mutableStateOf(container.hostname) } var hostnameError by remember { mutableStateOf(null) } LaunchedEffect(hostname) { @@ -91,131 +93,29 @@ fun EditContainerScreen( ).errorMessage } - var netMode by remember { mutableStateOf(container.netMode) } - var disableIPv6 by remember { mutableStateOf(container.disableIPv6) } - var enableAndroidStorage by remember { mutableStateOf(container.enableAndroidStorage) } - var enableHwAccess by remember { mutableStateOf(container.enableHwAccess) } - var enableGpuMode by remember { mutableStateOf(container.enableGpuMode) } - var enableTermuxX11 by remember { mutableStateOf(container.enableTermuxX11) } - var tx11ExtraFlags by remember { mutableStateOf(container.tx11ExtraFlags) } - var enableVirgl by remember { mutableStateOf(container.enableVirgl) } - var virglExtraFlags by remember { mutableStateOf(container.virglExtraFlags) } - var enablePulseaudio by remember { mutableStateOf(container.enablePulseaudio) } - var selinuxPermissive by remember { mutableStateOf(container.selinuxPermissive) } - var allowUserns by remember { mutableStateOf(container.allowUserns) } - var volatileMode by remember { mutableStateOf(container.volatileMode) } - var bindMounts by remember { mutableStateOf(container.bindMounts) } - var dnsServers by remember { mutableStateOf(container.dnsServers) } - var runAtBoot by remember { mutableStateOf(container.runAtBoot) } - var envFileContent by remember { mutableStateOf(container.envFileContent ?: "") } - var upstreamInterfaces by remember { mutableStateOf(container.upstreamInterfaces) } - var portForwards by remember { mutableStateOf(container.portForwards) } - var forceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } - var blockNestedNs by remember { mutableStateOf(container.blockNestedNs) } - var staticNatIp by remember { mutableStateOf(container.staticNatIp) } - var gatewayContainer by remember { mutableStateOf(container.gatewayContainer) } - var gatewayNet by remember { mutableStateOf(container.gatewayNet) } - var gatewayIface by remember { mutableStateOf(container.gatewayIface) } - var gatewayBridge by remember { mutableStateOf(container.gatewayBridge) } - var privileged by remember { mutableStateOf(container.privileged) } - var customInit by remember { mutableStateOf(container.customInit) } - val gatewayErrors = ValidationUtils.validateGatewayConfig( selfName = container.name, - gatewayContainer = gatewayContainer, - net = gatewayNet, - iface = gatewayIface, - bridge = gatewayBridge, + gatewayContainer = state.gatewayContainer, + net = state.gatewayNet, + iface = state.gatewayIface, + bridge = state.gatewayBridge, installed = containerViewModel.containerList, context = context ) - val collisionContainer = remember(netMode, staticNatIp, containerViewModel.containerList) { - if (netMode != "nat" || staticNatIp.isEmpty()) null - else containerViewModel.containerList.find { it.name != container.name && it.staticNatIp == staticNatIp } + val collisionContainer = remember(state.netMode, state.staticNatIp, containerViewModel.containerList) { + if (state.netMode != "nat" || state.staticNatIp.isEmpty()) null + else containerViewModel.containerList.find { it.name != container.name && it.staticNatIp == state.staticNatIp } } - // Track the "saved" baseline values - updated after each successful save - var savedHostname by remember { mutableStateOf(container.hostname) } - var savedNetMode by remember { mutableStateOf(container.netMode) } - var savedDisableIPv6 by remember { mutableStateOf(container.disableIPv6) } - var savedEnableAndroidStorage by remember { mutableStateOf(container.enableAndroidStorage) } - var savedEnableHwAccess by remember { mutableStateOf(container.enableHwAccess) } - var savedEnableGpuMode by remember { mutableStateOf(container.enableGpuMode) } - var savedEnableTermuxX11 by remember { mutableStateOf(container.enableTermuxX11) } - var savedTx11ExtraFlags by remember { mutableStateOf(container.tx11ExtraFlags) } - var savedEnableVirgl by remember { mutableStateOf(container.enableVirgl) } - var savedVirglExtraFlags by remember { mutableStateOf(container.virglExtraFlags) } - var savedEnablePulseaudio by remember { mutableStateOf(container.enablePulseaudio) } - var savedSelinuxPermissive by remember { mutableStateOf(container.selinuxPermissive) } - var savedAllowUserns by remember { mutableStateOf(container.allowUserns) } - var savedVolatileMode by remember { mutableStateOf(container.volatileMode) } - var savedBindMounts by remember { mutableStateOf(container.bindMounts) } - var savedDnsServers by remember { mutableStateOf(container.dnsServers) } - var savedRunAtBoot by remember { mutableStateOf(container.runAtBoot) } - var savedEnvFileContent by remember { mutableStateOf(container.envFileContent ?: "") } - var savedUpstreamInterfaces by remember { mutableStateOf(container.upstreamInterfaces) } - var savedPortForwards by remember { mutableStateOf(container.portForwards) } - var savedForceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } - var savedBlockNestedNs by remember { mutableStateOf(container.blockNestedNs) } - var savedStaticNatIp by remember { mutableStateOf(container.staticNatIp) } - var savedGatewayContainer by remember { mutableStateOf(container.gatewayContainer) } - var savedGatewayNet by remember { mutableStateOf(container.gatewayNet) } - var savedGatewayIface by remember { mutableStateOf(container.gatewayIface) } - var savedGatewayBridge by remember { mutableStateOf(container.gatewayBridge) } - var savedPrivileged by remember { mutableStateOf(container.privileged) } - var savedCustomInit by remember { mutableStateOf(container.customInit) } - - // Navigation and internal UI states - var showFilePicker by remember { mutableStateOf(false) } - var showDestDialog by remember { mutableStateOf(false) } - var tempSrcPath by remember { mutableStateOf("") } + val hasChanges by remember { derivedStateOf { state != savedState || hostname != savedHostname } } - // Loading and error states var isSaving by remember { mutableStateOf(false) } var isSaved by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } - // Track if any field has changed from SAVED values (not original) - val hasChanges by remember { - derivedStateOf { - hostname != savedHostname || - netMode != savedNetMode || - disableIPv6 != savedDisableIPv6 || - enableAndroidStorage != savedEnableAndroidStorage || - enableHwAccess != savedEnableHwAccess || - enableGpuMode != savedEnableGpuMode || - enableTermuxX11 != savedEnableTermuxX11 || - tx11ExtraFlags != savedTx11ExtraFlags || - enableVirgl != savedEnableVirgl || - virglExtraFlags != savedVirglExtraFlags || - enablePulseaudio != savedEnablePulseaudio || - selinuxPermissive != savedSelinuxPermissive || - allowUserns != savedAllowUserns || - volatileMode != savedVolatileMode || - bindMounts != savedBindMounts || - dnsServers != savedDnsServers || - runAtBoot != savedRunAtBoot || - envFileContent != savedEnvFileContent || - upstreamInterfaces != savedUpstreamInterfaces || - portForwards != savedPortForwards || - forceCgroupv1 != savedForceCgroupv1 || - blockNestedNs != savedBlockNestedNs || - staticNatIp != savedStaticNatIp || - gatewayContainer != savedGatewayContainer || - gatewayNet != savedGatewayNet || - gatewayIface != savedGatewayIface || - gatewayBridge != savedGatewayBridge || - privileged != savedPrivileged || - customInit != savedCustomInit - } - } - - // Reset saved state when user makes changes LaunchedEffect(hasChanges) { - if (hasChanges && isSaved) { - isSaved = false - } + if (hasChanges && isSaved) isSaved = false } fun saveChanges() { @@ -223,85 +123,19 @@ fun EditContainerScreen( isSaving = true isSaved = false errorMessage = null - try { val finalHostname = hostname.ifEmpty { ValidationUtils.sanitizeHostname(container.name) } - // Create updated ContainerInfo with new values - val updatedConfig = container.copy( - hostname = finalHostname, - netMode = netMode, - disableIPv6 = disableIPv6, - enableAndroidStorage = enableAndroidStorage, - enableHwAccess = enableHwAccess, - enableGpuMode = enableGpuMode, - enableTermuxX11 = enableTermuxX11, - tx11ExtraFlags = tx11ExtraFlags, - enableVirgl = enableVirgl, - virglExtraFlags = virglExtraFlags, - enablePulseaudio = enablePulseaudio, - selinuxPermissive = selinuxPermissive, - allowUserns = allowUserns, - volatileMode = volatileMode, - bindMounts = bindMounts, - dnsServers = dnsServers, - runAtBoot = runAtBoot, - envFileContent = if (envFileContent.isBlank()) null else envFileContent, - upstreamInterfaces = upstreamInterfaces, - portForwards = portForwards, - forceCgroupv1 = forceCgroupv1, - blockNestedNs = blockNestedNs, - staticNatIp = staticNatIp, - gatewayContainer = gatewayContainer, - gatewayNet = gatewayNet, - gatewayIface = gatewayIface, - gatewayBridge = gatewayBridge, - privileged = privileged, - customInit = customInit - ) - - // Update config file + val updatedConfig = container.withConfig(state).copy(hostname = finalHostname) val result = withContext(Dispatchers.IO) { ContainerManager.updateContainerConfig(context, container.name, updatedConfig) } - result.fold( onSuccess = { - // Success - update saved baseline values to current values hostname = finalHostname savedHostname = finalHostname - savedNetMode = netMode - savedDisableIPv6 = disableIPv6 - savedEnableAndroidStorage = enableAndroidStorage - savedEnableHwAccess = enableHwAccess - savedEnableGpuMode = enableGpuMode - savedEnableTermuxX11 = enableTermuxX11 - savedTx11ExtraFlags = tx11ExtraFlags - savedEnableVirgl = enableVirgl - savedVirglExtraFlags = virglExtraFlags - savedEnablePulseaudio = enablePulseaudio - savedSelinuxPermissive = selinuxPermissive - savedAllowUserns = allowUserns - savedVolatileMode = volatileMode - savedBindMounts = bindMounts - savedDnsServers = dnsServers - savedRunAtBoot = runAtBoot - savedEnvFileContent = envFileContent - savedUpstreamInterfaces = upstreamInterfaces - savedPortForwards = portForwards - savedForceCgroupv1 = forceCgroupv1 - savedBlockNestedNs = blockNestedNs - savedStaticNatIp = staticNatIp - savedGatewayContainer = gatewayContainer - savedGatewayNet = gatewayNet - savedGatewayIface = gatewayIface - savedGatewayBridge = gatewayBridge - savedPrivileged = privileged - savedCustomInit = customInit - - // Refresh container list and SELinux status using ViewModel + savedState = state containerViewModel.refresh() SystemInfoManager.refreshSELinuxStatus() - isSaving = false isSaved = true }, @@ -319,149 +153,13 @@ fun EditContainerScreen( } } - if (showFilePicker) { - FilePickerDialog( - onDismiss = { showFilePicker = false }, - onConfirm = { path -> - tempSrcPath = path - showFilePicker = false - showDestDialog = true - } - ) - } - - if (showDestDialog) { - var destPath by remember { mutableStateOf("") } - var roEnabled by remember { mutableStateOf(false) } - Dialog( - onDismissRequest = { showDestDialog = false }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .imePadding(), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text(context.getString(R.string.enter_container_path), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - OutlinedTextField( - value = destPath, - onValueChange = { destPath = it }, - label = { Text(context.getString(R.string.container_path_placeholder)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(context.getString(R.string.read_only), style = MaterialTheme.typography.bodyMedium) - Switch(checked = roEnabled, onCheckedChange = { roEnabled = it }) - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { clearFocus(); showDestDialog = false }), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = destPath.startsWith("/"), - onClick = { - clearFocus() - if (destPath.isNotBlank()) { - bindMounts = bindMounts + BindMount(tempSrcPath, destPath, roEnabled) - showDestDialog = false - } - } - ), - shape = RoundedCornerShape(14.dp), - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } - } - } - } - } - - var showEnvDialog by remember { mutableStateOf(false) } - var showPrivilegedDialog by remember { mutableStateOf(false) } - var showHwAccessDialog by remember { mutableStateOf(false) } - - if (showPrivilegedDialog) { - PrivilegedModeDialog( - initialPrivileged = privileged, - onConfirm = { tags -> - privileged = tags - showPrivilegedDialog = false - }, - onDismiss = { showPrivilegedDialog = false } - ) - } - - if (showHwAccessDialog) { - HardwareAccessDialog( - onConfirm = { - enableHwAccess = true - showHwAccessDialog = false - }, - onDismiss = { showHwAccessDialog = false } - ) - } - - if (showEnvDialog) { - EnvironmentVariablesDialog( - initialContent = envFileContent, - onConfirm = { newContent -> - envFileContent = newContent - showEnvDialog = false - }, - onDismiss = { showEnvDialog = false }, - confirmLabel = context.getString(R.string.save_changes) - ) - } - Scaffold( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { - Text(context.getString(R.string.edit_container_title, container.name)) - }, + title = { Text(context.getString(R.string.edit_container_title, container.name)) }, navigationIcon = { - IconButton(onClick = { - clearFocus() - onBack() - }) { + IconButton(onClick = { clearFocus(); onBack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = context.getString(R.string.back)) } } @@ -470,7 +168,7 @@ fun EditContainerScreen( bottomBar = { val btnShape = RoundedCornerShape(20.dp) val isReadyToSave = !isSaving && !isSaved && hasChanges && hostnameError == null && - (netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null + (state.netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null val targetBtnColor = when { isSaved -> MaterialTheme.colorScheme.primaryContainer isSaving || isReadyToSave -> MaterialTheme.colorScheme.primary @@ -499,12 +197,9 @@ fun EditContainerScreen( .clip(btnShape) .clickable( enabled = isReadyToSave, - onClick = { - clearFocus() - saveChanges() - }, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } + onClick = { clearFocus(); saveChanges() }, + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } ), shape = btnShape, color = animatedBtnColor, @@ -514,48 +209,20 @@ fun EditContainerScreen( when { isSaved -> { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = context.getString(R.string.saved), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) + Icon(imageVector = Icons.Default.Check, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer) + Text(text = context.getString(R.string.saved), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimaryContainer) } } isSaving -> { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - LoadingIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = context.getString(R.string.saving), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary - ) + LoadingIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary) + Text(text = context.getString(R.string.saving), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimary) } } else -> { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - Text( - text = context.getString(R.string.save_changes), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) + Icon(imageVector = Icons.Default.Save, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)) + Text(text = context.getString(R.string.save_changes), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)) } } } @@ -571,740 +238,70 @@ fun EditContainerScreen( .consumeWindowInsets(innerPadding) .imePadding() ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Warning if container is running - if (container.isRunning) { - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), - shape = RoundedCornerShape(20.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.error - ) - Column { - Text( - text = context.getString(R.string.container_is_running), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error - ) + ContainerConfigForm( + state = state, + onStateChange = { state = it }, + installedContainers = containerViewModel.containerList, + selfName = container.name, + gatewayErrors = gatewayErrors, + collisionContainer = collisionContainer, + modifier = Modifier.fillMaxSize(), + leadingContent = { + errorMessage?.let { error -> + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + modifier = Modifier.fillMaxWidth().clickable { clearFocus() } + ) { Text( - text = context.getString(R.string.changes_take_effect_after_restart), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + text = error, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer ) } } - } - } - - // Hostname input - val modernFieldShape = RoundedCornerShape(16.dp) - val modernFieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - OutlinedTextField( - value = hostname, - onValueChange = { hostname = it }, - label = { Text(context.getString(R.string.hostname)) }, - placeholder = { Text(ValidationUtils.sanitizeHostname(container.name)) }, - isError = hostnameError != null, - supportingText = hostnameError?.let { { Text(it) } } ?: { - Text(context.getString(R.string.hostname_hint)) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - leadingIcon = { - Icon(Icons.Default.Computer, contentDescription = null) - } - ) - - Text( - text = context.getString(R.string.cat_networking), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 8.dp) - ) - DsDropdown( - label = context.getString(R.string.network_mode), - selected = netMode, - options = listOf("nat", "host", "none", "gateway"), - displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; else -> R.string.network_mode_host }) }, - onSelect = { mode -> clearFocus(); netMode = mode; if (mode != "host") disableIPv6 = false }, - leadingIcon = Icons.Default.Public - ) - - GatewaySettingsSection( - visible = netMode == "gateway", - gatewayContainer = gatewayContainer, - onGatewayContainerChange = { clearFocus(); gatewayContainer = it }, - gatewayNet = gatewayNet, - onGatewayNetChange = { gatewayNet = it }, - gatewayIface = gatewayIface, - onGatewayIfaceChange = { gatewayIface = it }, - gatewayBridge = gatewayBridge, - onGatewayBridgeChange = { gatewayBridge = it }, - selfName = container.name, - installedContainers = containerViewModel.containerList, - errors = gatewayErrors - ) - - // Instant show/hide (no expand/shrink) so switching modes doesn't fight - // the gateway section's animation in the opposite direction. - if (netMode == "nat") { - Column( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = context.getString(R.string.nat_settings), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - - // Static IP Address Configuration - Text( - text = context.getString(R.string.static_ip_address), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - Text( - text = context.getString(R.string.static_ip_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(bottom = 8.dp) - ) - - val octets = remember(staticNatIp) { - val parts = staticNatIp.split(".") - if (parts.size == 4) { - Pair(parts[2], parts[3]) - } else { - Pair("", "") - } - } - - var octet3 by remember(octets) { mutableStateOf(octets.first) } - var octet4 by remember(octets) { mutableStateOf(octets.second) } - - val updateIp = { o3: String, o4: String -> - staticNatIp = if (o3.isBlank() && o4.isBlank()) { - "" - } else { - "${Constants.NAT_IP_PREFIX}.$o3.$o4" - } - } - - val isOctet3Valid = remember(octet3) { - octet3.isEmpty() || (octet3.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) - } - val isOctet4Valid = remember(octet4) { - octet4.isEmpty() || (octet4.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${Constants.NAT_IP_PREFIX}.", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - - OutlinedTextField( - value = octet3, - onValueChange = { - if (it.length <= 3 && it.all { c -> c.isDigit() }) { - octet3 = it - updateIp(it, octet4) - } - }, - label = { Text(context.getString(R.string.octet_label, 3)) }, - modifier = Modifier.weight(1f), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - isError = !isOctet3Valid, - supportingText = { if (!isOctet3Valid) Text(context.getString(R.string.error_octet_range)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - - Text( - text = ".", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - - OutlinedTextField( - value = octet4, - onValueChange = { - if (it.length <= 3 && it.all { c -> c.isDigit() }) { - octet4 = it - updateIp(octet3, it) + if (container.isRunning) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(imageVector = Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.error) + Column { + Text(text = context.getString(R.string.container_is_running), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) + Text(text = context.getString(R.string.changes_take_effect_after_restart), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) } - }, - label = { Text(context.getString(R.string.octet_label, 4)) }, - modifier = Modifier.weight(1f), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - isError = !isOctet4Valid, - supportingText = { if (!isOctet4Valid) Text(context.getString(R.string.error_octet_range)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - } - - if (collisionContainer != null) { - Text( - text = context.getString(R.string.error_ip_collision, collisionContainer.name), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp) - ) - } - - // Upstream Interface (optional pin) - Text( - text = context.getString(R.string.upstream_interface_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = context.getString(R.string.upstream_interface_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - UpstreamInterfaceList( - upstreamInterfaces = upstreamInterfaces, - onInterfacesChange = { upstreamInterfaces = it } - ) - - // Port Forwards - Text( - text = context.getString(R.string.port_forwarding), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - - PortForwardingList( - portForwards = portForwards, - onPortForwardsChange = { portForwards = it } - ) - } - } - - // Static divider (outside the animated blocks so mode switches stay - // smooth) separating the networking sub-settings from the DNS field. - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - - // DNS Servers input - val isDnsError = remember(dnsServers) { - dnsServers.isNotEmpty() && !dnsServers.all { it.isDigit() || it == '.' || it == ':' || it == ',' } - } - - OutlinedTextField( - value = dnsServers, - onValueChange = { dnsServers = it }, - label = { Text(context.getString(R.string.dns_servers_label)) }, - supportingText = { - if (isDnsError) Text(context.getString(R.string.dns_servers_hint)) - }, - isError = isDnsError, - placeholder = { Text(context.getString(R.string.dns_servers_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - leadingIcon = { - Icon(Icons.Default.Dns, contentDescription = null) - } - ) - - // In NAT/NONE mode, IPv6 is always disabled (forced). In host mode the user can opt in. - val ipv6IsForced = netMode != "host" - ToggleCard( - icon = Icons.Default.NetworkCheck, - title = context.getString(R.string.disable_ipv6), - description = if (ipv6IsForced) - context.getString(R.string.disable_ipv6_nat_forced) - else - context.getString(R.string.disable_ipv6_description), - checked = if (ipv6IsForced) true else disableIPv6, - onCheckedChange = { - clearFocus() - disableIPv6 = it - }, - enabled = !ipv6IsForced - ) - - Text( - text = context.getString(R.string.cat_integration), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - ToggleCard( - icon = Icons.Default.Storage, - title = context.getString(R.string.android_storage), - description = context.getString(R.string.android_storage_description), - checked = enableAndroidStorage, - onCheckedChange = { - clearFocus() - enableAndroidStorage = it - } - ) - - ToggleCard( - icon = Icons.Default.Devices, - title = context.getString(R.string.hardware_access), - description = context.getString(R.string.hardware_access_description), - checked = enableHwAccess, - onCheckedChange = { newValue -> - clearFocus() - if (newValue) { - showHwAccessDialog = true - } else { - enableHwAccess = false - } - } - ) - - ToggleCard( - icon = Icons.Default.Memory, - title = context.getString(R.string.gpu_access), - description = context.getString(R.string.gpu_access_description), - checked = if (enableHwAccess) true else enableGpuMode, - onCheckedChange = { - if (!enableHwAccess) { - clearFocus() - enableGpuMode = it - } - }, - enabled = !enableHwAccess - ) - - ToggleCard( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_x11), - title = context.getString(R.string.termux_x11), - description = context.getString(R.string.termux_x11_description), - checked = enableTermuxX11, - onCheckedChange = { enableTermuxX11 = it }, - enabled = true - ) - - ToggleCard( - icon = Icons.Default.Layers, - title = context.getString(R.string.enable_virgl), - description = context.getString(R.string.enable_virgl_description), - checked = enableVirgl, - onCheckedChange = { - clearFocus() - enableVirgl = it - }, - enabled = true - ) - - ToggleCard( - icon = Icons.AutoMirrored.Filled.VolumeUp, - title = context.getString(R.string.enable_pulseaudio), - description = context.getString(R.string.enable_pulseaudio_description), - checked = enablePulseaudio, - onCheckedChange = { - clearFocus() - enablePulseaudio = it - }, - enabled = true - ) - - Text( - text = context.getString(R.string.cat_security), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - ToggleCard( - icon = Icons.Default.Security, - title = context.getString(R.string.selinux_permissive), - description = context.getString(R.string.selinux_permissive_description), - checked = selinuxPermissive, - onCheckedChange = { - clearFocus() - selinuxPermissive = it - } - ) - - val isSeccompDisabled = privileged.contains("noseccomp") || privileged.contains("full") - - //use /proc/self/setgroups to detect userns (only exists when CONFIG_USER_NS is enabled) - val usernsSupported = remember { java.io.File("/proc/self/setgroups").exists() } - - LaunchedEffect(isSeccompDisabled, usernsSupported) { - if (isSeccompDisabled) blockNestedNs = false - if (isSeccompDisabled && usernsSupported) allowUserns = true - if (!usernsSupported) allowUserns = false - } - - ToggleCard( - icon = Icons.Default.Groups, - title = context.getString(R.string.allow_userns), - description = if (usernsSupported) - context.getString(R.string.allow_userns_description) - else - context.getString(R.string.allow_userns_description_not_supported), - checked = allowUserns, - onCheckedChange = { - clearFocus() - allowUserns = it - }, - enabled = !isSeccompDisabled && usernsSupported - ) - - ToggleCard( - icon = Icons.Default.AutoDelete, - title = context.getString(R.string.volatile_mode), - description = context.getString(R.string.volatile_mode_description), - checked = volatileMode, - onCheckedChange = { - clearFocus() - volatileMode = it - } - ) - - ToggleCard( - icon = Icons.Default.Cyclone, - title = context.getString(R.string.force_cgroupv1), - description = context.getString(R.string.force_cgroupv1_description), - checked = forceCgroupv1, - onCheckedChange = { - clearFocus() - forceCgroupv1 = it - } - ) - - ToggleCard( - icon = Icons.Default.GppBad, - title = context.getString(R.string.manual_deadlock_shield), - description = context.getString(R.string.manual_deadlock_shield_description), - checked = if (isSeccompDisabled) false else blockNestedNs, - onCheckedChange = { - clearFocus() - blockNestedNs = it - }, - enabled = !isSeccompDisabled - ) - - SettingsRowCard( - title = context.getString(R.string.privileged_mode), - subtitle = if (privileged.isEmpty()) context.getString(R.string.not_configured) else privileged, - description = context.getString(R.string.privileged_mode_description), - icon = Icons.Default.GppMaybe, - onClick = { - clearFocus() - showPrivilegedDialog = true - } - ) - - ToggleCard( - icon = Icons.Default.PowerSettingsNew, - title = context.getString(R.string.run_at_boot), - description = context.getString(R.string.run_at_boot_description), - checked = runAtBoot, - onCheckedChange = { - clearFocus() - runAtBoot = it - } - ) - - Text( - text = context.getString(R.string.cat_advanced), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - // Environment Variables Row - fun countEnvVars(content: String): Int { - return content.lines() - .map { it.trim() } - .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } - } - - val envCount = countEnvVars(envFileContent) - val envSubtitle = if (envCount > 0) { - context.getString(R.string.environment_variables_configured, envCount) - } else { - context.getString(R.string.not_configured) - } - - SettingsRowCard( - title = context.getString(R.string.environment_variables), - subtitle = envSubtitle, - icon = Icons.Default.Code, - onClick = { - clearFocus() - showEnvDialog = true - } - ) - - // Custom Init Binary - if (customInit.isNotEmpty()) { - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - Text( - text = context.getString(R.string.custom_init_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - - OutlinedTextField( - value = customInit, - onValueChange = { customInit = it.filter { !it.isWhitespace() } }, - label = { Text(context.getString(R.string.custom_init_label)) }, - placeholder = { Text(context.getString(R.string.custom_init_placeholder)) }, - supportingText = { - if (customInit.isNotEmpty() && !customInit.startsWith("/")) { - Text(context.getString(R.string.custom_init_error_absolute), - color = MaterialTheme.colorScheme.error) - } else { - Text(context.getString(R.string.custom_init_hint)) - } - }, - isError = customInit.isNotEmpty() && !customInit.startsWith("/"), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - leadingIcon = { - Icon(Icons.Default.Terminal, contentDescription = null) - } - ) - - androidx.compose.animation.AnimatedVisibility( - visible = enableTermuxX11, - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - OutlinedTextField( - value = tx11ExtraFlags, - onValueChange = { tx11ExtraFlags = it }, - label = { Text(context.getString(R.string.tx11_extra_flags_label)) }, - placeholder = { Text(context.getString(R.string.tx11_extra_flags_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { - Icon( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_x11), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) + } + } } - ) - } - androidx.compose.animation.AnimatedVisibility( - visible = enableVirgl, - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - OutlinedTextField( - value = virglExtraFlags, - onValueChange = { virglExtraFlags = it }, - label = { Text(context.getString(R.string.virgl_extra_flags_label)) }, - placeholder = { Text(context.getString(R.string.virgl_extra_flags_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( + val hostnameFieldShape = RoundedCornerShape(16.dp) + val hostnameFieldColors = OutlinedTextFieldDefaults.colors( unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { Icon(Icons.Default.Layers, contentDescription = null) } - ) - } - - // Bind Mounts Section - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = context.getString(R.string.bind_mounts), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - } - - bindMounts.forEach { mount -> - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = context.getString(R.string.host_path, mount.src), - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - Text( - text = context.getString(R.string.container_path, mount.dest), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - if (mount.ro) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.padding(top = 4.dp) - ) { - Text( - text = context.getString(R.string.read_only), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - IconButton(onClick = { - bindMounts = bindMounts - mount - }) { - Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) - } - } - } - } - - val addBindBtnShape = RoundedCornerShape(16.dp) - Surface( - modifier = Modifier.fillMaxWidth().clip(addBindBtnShape).clickable( - onClick = { showFilePicker = true }, - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() } - ), - shape = addBindBtnShape, - color = MaterialTheme.colorScheme.surfaceContainerLow, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - tonalElevation = 0.dp - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = context.getString(R.string.add_bind_mount), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant ) - } - } - - // Error message - errorMessage?.let { error -> - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(20.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - .clickable { clearFocus() } - ) { - Text( - text = error, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer + OutlinedTextField( + value = hostname, + onValueChange = { hostname = it }, + label = { Text(context.getString(R.string.hostname)) }, + placeholder = { Text(ValidationUtils.sanitizeHostname(container.name)) }, + isError = hostnameError != null, + supportingText = hostnameError?.let { { Text(it) } } ?: { Text(context.getString(R.string.hostname_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = hostnameFieldShape, + colors = hostnameFieldColors, + leadingIcon = { Icon(Icons.Default.Computer, contentDescription = null) } ) } - } - - Spacer(modifier = Modifier.height(16.dp)) + ) } } } -} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt index d54ec971..e18a6ecf 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt @@ -10,9 +10,9 @@ import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.ContainerStatus import com.droidspaces.app.util.Constants -import com.droidspaces.app.util.BindMount -import com.droidspaces.app.util.PortForward +import com.droidspaces.app.util.ContainerConfigState import com.droidspaces.app.util.ValidationUtils +import com.droidspaces.app.util.withConfig class ContainerInstallationViewModel : ViewModel() { var tarballUri: Uri? by mutableStateOf(null) @@ -24,94 +24,14 @@ class ContainerInstallationViewModel : ViewModel() { var hostname: String by mutableStateOf("") private set - var netMode: String by mutableStateOf("nat") - private set - - var disableIPv6: Boolean by mutableStateOf(false) - private set - - var enableAndroidStorage: Boolean by mutableStateOf(false) - private set - - var enableHwAccess: Boolean by mutableStateOf(false) - private set - - var enableGpuMode: Boolean by mutableStateOf(false) - private set - - var enableTermuxX11: Boolean by mutableStateOf(false) - private set - - var tx11ExtraFlags: String by mutableStateOf("") - private set - - var enableVirgl: Boolean by mutableStateOf(false) - private set - - var virglExtraFlags: String by mutableStateOf("") - private set - - var enablePulseaudio: Boolean by mutableStateOf(false) - private set - - var selinuxPermissive: Boolean by mutableStateOf(false) - private set - - var allowUserns: Boolean by mutableStateOf(false) - private set - - var volatileMode: Boolean by mutableStateOf(false) - private set - - var bindMounts: List by mutableStateOf(emptyList()) - private set - - var dnsServers: String by mutableStateOf("") - private set - - var runAtBoot: Boolean by mutableStateOf(false) - private set - - var customInit: String by mutableStateOf("") - private set - - var staticNatIp: String by mutableStateOf("") - private set - - var gatewayContainer: String by mutableStateOf("") - private set - - var gatewayNet: String by mutableStateOf("") - private set - - var gatewayIface: String by mutableStateOf("") - private set - - var gatewayBridge: String by mutableStateOf("") - private set - - var envFileContent: String? by mutableStateOf(null) - private set - var useSparseImage: Boolean by mutableStateOf(true) private set var sparseImageSizeGB: Int by mutableStateOf(8) private set - var upstreamInterfaces: List by mutableStateOf(emptyList()) - private set - - var portForwards: List by mutableStateOf(emptyList()) - private set - - var forceCgroupv1: Boolean by mutableStateOf(false) - private set - - var blockNestedNs: Boolean by mutableStateOf(false) - private set - - var privileged: String by mutableStateOf("") + /** All editable networking/security/advanced config, hoisted as one value. */ + var configState: ContainerConfigState by mutableStateOf(ContainerConfigState()) private set fun setTarball(uri: Uri) { @@ -128,64 +48,8 @@ class ContainerInstallationViewModel : ViewModel() { this.sparseImageSizeGB = sizeGB } - fun setConfig( - netMode: String, - disableIPv6: Boolean, - enableAndroidStorage: Boolean, - enableHwAccess: Boolean, - enableGpuMode: Boolean, - enableTermuxX11: Boolean, - tx11ExtraFlags: String, - enableVirgl: Boolean, - virglExtraFlags: String, - enablePulseaudio: Boolean, - selinuxPermissive: Boolean, - allowUserns: Boolean, - volatileMode: Boolean, - bindMounts: List, - dnsServers: String, - runAtBoot: Boolean, - customInit: String, - staticNatIp: String, - envFileContent: String?, - upstreamInterfaces: List, - portForwards: List, - forceCgroupv1: Boolean, - blockNestedNs: Boolean, - privileged: String, - gatewayContainer: String, - gatewayNet: String, - gatewayIface: String, - gatewayBridge: String - ) { - this.netMode = netMode - this.disableIPv6 = disableIPv6 - this.enableAndroidStorage = enableAndroidStorage - this.enableHwAccess = enableHwAccess - this.enableGpuMode = enableGpuMode - this.enableTermuxX11 = enableTermuxX11 - this.tx11ExtraFlags = tx11ExtraFlags - this.enableVirgl = enableVirgl - this.virglExtraFlags = virglExtraFlags - this.enablePulseaudio = enablePulseaudio - this.selinuxPermissive = selinuxPermissive - this.allowUserns = allowUserns - this.volatileMode = volatileMode - this.bindMounts = bindMounts - this.dnsServers = dnsServers - this.runAtBoot = runAtBoot - this.customInit = customInit - this.staticNatIp = staticNatIp - this.envFileContent = envFileContent - this.upstreamInterfaces = upstreamInterfaces - this.portForwards = portForwards - this.forceCgroupv1 = forceCgroupv1 - this.blockNestedNs = blockNestedNs - this.privileged = privileged - this.gatewayContainer = gatewayContainer - this.gatewayNet = gatewayNet - this.gatewayIface = gatewayIface - this.gatewayBridge = gatewayBridge + fun setConfig(config: ContainerConfigState) { + this.configState = config } fun buildConfig(): ContainerInfo? { @@ -200,74 +64,19 @@ class ContainerInstallationViewModel : ViewModel() { } else { ContainerManager.getRootfsPath(containerName) }, - netMode = netMode, - disableIPv6 = disableIPv6, - enableAndroidStorage = enableAndroidStorage, - enableHwAccess = enableHwAccess, - enableGpuMode = enableGpuMode, - enableTermuxX11 = enableTermuxX11, - tx11ExtraFlags = tx11ExtraFlags, - enableVirgl = enableVirgl, - virglExtraFlags = virglExtraFlags, - enablePulseaudio = enablePulseaudio, - selinuxPermissive = selinuxPermissive, - allowUserns = allowUserns, - volatileMode = volatileMode, - bindMounts = bindMounts, - dnsServers = dnsServers, - runAtBoot = runAtBoot, - customInit = customInit, - staticNatIp = staticNatIp, - gatewayContainer = gatewayContainer, - gatewayNet = gatewayNet, - gatewayIface = gatewayIface, - gatewayBridge = gatewayBridge, - envFileContent = envFileContent, status = ContainerStatus.STOPPED, // Default status for new container useSparseImage = useSparseImage, sparseImageSizeGB = if (useSparseImage) sparseImageSizeGB else null, - upstreamInterfaces = upstreamInterfaces, - portForwards = portForwards, - forceCgroupv1 = forceCgroupv1, - blockNestedNs = blockNestedNs, - privileged = privileged - ) + ).withConfig(configState) } fun reset() { tarballUri = null containerName = "" hostname = "" - netMode = "nat" - disableIPv6 = false - enableAndroidStorage = false - enableHwAccess = false - enableGpuMode = false - enableTermuxX11 = false - tx11ExtraFlags = "" - enableVirgl = false - virglExtraFlags = "" - enablePulseaudio = false - selinuxPermissive = false - allowUserns = false - volatileMode = false - bindMounts = emptyList() - dnsServers = "" - runAtBoot = false - customInit = "" - staticNatIp = "" - gatewayContainer = "" - gatewayNet = "" - gatewayIface = "" - gatewayBridge = "" - envFileContent = null useSparseImage = true sparseImageSizeGB = 8 - upstreamInterfaces = emptyList() - portForwards = emptyList() - forceCgroupv1 = false - blockNestedNs = false - privileged = "" + configState = ContainerConfigState() } } diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt new file mode 100644 index 00000000..2de58003 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt @@ -0,0 +1,114 @@ +package com.droidspaces.app.util + +/** + * Immutable holder for the ~28 editable container-configuration fields that the + * Create wizard ([com.droidspaces.app.ui.screen.ContainerConfigScreen]) and the + * Edit screen ([com.droidspaces.app.ui.screen.EditContainerScreen]) both drive + * through the shared [com.droidspaces.app.ui.component.ContainerConfigForm]. + * + * Replaces the four hand-maintained copies of this field list that previously + * lived as: editable `var`s, parallel `saved*` `var`s, a 28-arg `onNext` + * callback, and a 28-arg `setConfig`. A single data class means `hasChanges` + * is just `current != saved`, and config plumbing can never transpose fields. + * + * Note: `name`, `hostname`, `rootfsPath`, sparse-image and runtime fields are + * NOT part of this state — they are owned by the surrounding screens/ViewModel. + */ +data class ContainerConfigState( + val netMode: String = "nat", + val disableIPv6: Boolean = false, + val enableAndroidStorage: Boolean = false, + val enableHwAccess: Boolean = false, + val enableGpuMode: Boolean = false, + val enableTermuxX11: Boolean = false, + val tx11ExtraFlags: String = "", + val enableVirgl: Boolean = false, + val virglExtraFlags: String = "", + val enablePulseaudio: Boolean = false, + val selinuxPermissive: Boolean = false, + val allowUserns: Boolean = false, + val volatileMode: Boolean = false, + val bindMounts: List = emptyList(), + val dnsServers: String = "", + val runAtBoot: Boolean = false, + val customInit: String = "", + val staticNatIp: String = "", + val forceCgroupv1: Boolean = false, + val blockNestedNs: Boolean = false, + val privileged: String = "", + val envFileContent: String = "", + val upstreamInterfaces: List = emptyList(), + val portForwards: List = emptyList(), + val gatewayContainer: String = "", + val gatewayNet: String = "", + val gatewayIface: String = "", + val gatewayBridge: String = "", +) + +/** Extract the editable config fields from an existing container. */ +fun ContainerInfo.toConfigState(): ContainerConfigState = ContainerConfigState( + netMode = netMode, + disableIPv6 = disableIPv6, + enableAndroidStorage = enableAndroidStorage, + enableHwAccess = enableHwAccess, + enableGpuMode = enableGpuMode, + enableTermuxX11 = enableTermuxX11, + tx11ExtraFlags = tx11ExtraFlags, + enableVirgl = enableVirgl, + virglExtraFlags = virglExtraFlags, + enablePulseaudio = enablePulseaudio, + selinuxPermissive = selinuxPermissive, + allowUserns = allowUserns, + volatileMode = volatileMode, + bindMounts = bindMounts, + dnsServers = dnsServers, + runAtBoot = runAtBoot, + customInit = customInit, + staticNatIp = staticNatIp, + forceCgroupv1 = forceCgroupv1, + blockNestedNs = blockNestedNs, + privileged = privileged, + envFileContent = envFileContent ?: "", + upstreamInterfaces = upstreamInterfaces, + portForwards = portForwards, + gatewayContainer = gatewayContainer, + gatewayNet = gatewayNet, + gatewayIface = gatewayIface, + gatewayBridge = gatewayBridge, +) + +/** + * Return a copy of this container with the editable config fields replaced by + * [state]. Non-config fields (name, rootfsPath, status, sparse image, + * runAtBootPriority, uuid, …) are preserved. + */ +fun ContainerInfo.withConfig(state: ContainerConfigState): ContainerInfo = copy( + netMode = state.netMode, + disableIPv6 = state.disableIPv6, + enableAndroidStorage = state.enableAndroidStorage, + enableHwAccess = state.enableHwAccess, + enableGpuMode = state.enableGpuMode, + enableTermuxX11 = state.enableTermuxX11, + tx11ExtraFlags = state.tx11ExtraFlags, + enableVirgl = state.enableVirgl, + virglExtraFlags = state.virglExtraFlags, + enablePulseaudio = state.enablePulseaudio, + selinuxPermissive = state.selinuxPermissive, + allowUserns = state.allowUserns, + volatileMode = state.volatileMode, + bindMounts = state.bindMounts, + dnsServers = state.dnsServers, + runAtBoot = state.runAtBoot, + customInit = state.customInit, + staticNatIp = state.staticNatIp, + forceCgroupv1 = state.forceCgroupv1, + blockNestedNs = state.blockNestedNs, + privileged = state.privileged, + envFileContent = state.envFileContent.ifBlank { null }, + upstreamInterfaces = state.upstreamInterfaces, + portForwards = state.portForwards, + gatewayContainer = state.gatewayContainer, + gatewayNet = state.gatewayNet, + gatewayIface = state.gatewayIface, + gatewayBridge = state.gatewayBridge, +) From 842c8166547e08a180b95dd45ee0dc4a73273a8a Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:26 +0530 Subject: [PATCH 166/211] fix(security): validate systemd/openrc service names to prevent host-root command injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContainerSystemdManager and ContainerOpenRCManager interpolated a container-discovered service name, unquoted, into the single-quoted `droidspaces --name= run ' ...'` payload that runs in the app's real-host-root libsu shell. A service/unit name containing a single quote (or $, ;, backtick, whitespace) — trivially planted by a hostile rootfs — broke out and executed as host root on a single UI tap. Introduce ServiceManagerBase.isSafeServiceName as the single allow-list choke point (letters/digits and the punctuation real unit names use; no shell metacharacters) and route every systemd/openrc action through a validating helper that rejects unsafe names before building the command. ContainerProcdManager (which already validated) now shares the same allow-list instead of its own copy. Part of DT-3. Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/util/ContainerOpenRCManager.kt | 20 +++++++++---- .../app/util/ContainerProcdManager.kt | 3 +- .../app/util/ContainerSystemdManager.kt | 25 +++++++++++----- .../app/util/ServiceManagerBase.kt | 29 +++++++++++++++++++ 4 files changed, 62 insertions(+), 15 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/util/ServiceManagerBase.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerOpenRCManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerOpenRCManager.kt index 117f9ec5..8a4a8948 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerOpenRCManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerOpenRCManager.kt @@ -201,18 +201,28 @@ object ContainerOpenRCManager { }.sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) } + // Every service name is validated against the shared allow-list + // (ServiceManagerBase) before it is interpolated into the host-root + // `run '...'` payload — see FINDINGS_APP_VULN V2. + private suspend fun runRC(containerName: String, serviceName: String, buildCommand: (String) -> String): CommandResult { + if (!ServiceManagerBase.isSafeServiceName(serviceName)) { + return CommandResult(exitCode = 2, output = emptyList(), error = listOf("Invalid service name: $serviceName")) + } + return executeRCCommand(containerName, buildCommand(serviceName)) + } + suspend fun startService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-service $serviceName start") + runRC(containerName, serviceName) { "rc-service $it start" } suspend fun stopService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-service $serviceName stop") + runRC(containerName, serviceName) { "rc-service $it stop" } suspend fun restartService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-service $serviceName restart") + runRC(containerName, serviceName) { "rc-service $it restart" } suspend fun enableService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-update add $serviceName default") + runRC(containerName, serviceName) { "rc-update add $it default" } suspend fun disableService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-update del $serviceName") + runRC(containerName, serviceName) { "rc-update del $it" } } diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt index 65ba3b83..adabe285 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt @@ -20,7 +20,6 @@ object ContainerProcdManager { private const val TAG = "ContainerProcdManager" private const val COMMAND_TIMEOUT_MS = 15_000L - private val safeServiceName = Regex("^[A-Za-z0-9_.+@-]+$") private val safeActions = setOf("start", "stop", "restart", "reload", "enable", "disable", "status") private var scriptBase64: String? = null @@ -153,7 +152,7 @@ object ContainerProcdManager { }.sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) } - private fun isSafeServiceName(serviceName: String): Boolean = safeServiceName.matches(serviceName) + private fun isSafeServiceName(serviceName: String): Boolean = ServiceManagerBase.isSafeServiceName(serviceName) private fun validateServiceAction(serviceName: String, action: String): CommandResult? { if (!isSafeServiceName(serviceName)) { diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerSystemdManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerSystemdManager.kt index 70024e2a..ada50ed3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerSystemdManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerSystemdManager.kt @@ -291,25 +291,34 @@ object ContainerSystemdManager { }.sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) } - // Systemctl commands + // Systemctl commands. Every service name is validated against the shared + // allow-list (ServiceManagerBase) before it is interpolated into the + // host-root `run 'systemctl ...'` payload — see FINDINGS_APP_VULN V1. + private suspend fun runSystemctl(containerName: String, action: String, serviceName: String): CommandResult { + if (!ServiceManagerBase.isSafeServiceName(serviceName)) { + return CommandResult(exitCode = 2, output = emptyList(), error = listOf("Invalid unit name: $serviceName")) + } + return executeSystemctlCommand(containerName, "$action $serviceName") + } + suspend fun startService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "start --no-block $serviceName") + runSystemctl(containerName, "start --no-block", serviceName) suspend fun stopService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "stop $serviceName") + runSystemctl(containerName, "stop", serviceName) suspend fun restartService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "restart $serviceName") + runSystemctl(containerName, "restart", serviceName) suspend fun enableService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "enable $serviceName") + runSystemctl(containerName, "enable", serviceName) suspend fun disableService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "disable $serviceName") + runSystemctl(containerName, "disable", serviceName) suspend fun maskService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "mask $serviceName") + runSystemctl(containerName, "mask", serviceName) suspend fun unmaskService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "unmask $serviceName") + runSystemctl(containerName, "unmask", serviceName) } diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ServiceManagerBase.kt b/Android/app/src/main/java/com/droidspaces/app/util/ServiceManagerBase.kt new file mode 100644 index 00000000..23b72b9e --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ServiceManagerBase.kt @@ -0,0 +1,29 @@ +package com.droidspaces.app.util + +/** + * Shared safety choke point for the per-init-system service managers + * ([ContainerSystemdManager], [ContainerOpenRCManager], [ContainerProcdManager]). + * + * Service/unit names are DISCOVERED from inside the container — which may be a + * hostile third-party rootfs — and then interpolated into a host-level command: + * + * droidspaces --name= run ' ...' + * + * that executes in the app's real-host-root libsu shell. Because the name sits + * inside the single-quoted `run '...'` payload, a name containing a single quote + * (or `$`, `;`, backtick, whitespace, …) would break out of that payload and run + * arbitrary commands as host root on a single UI tap (see FINDINGS_APP_VULN V1/V2). + * + * Every service manager MUST pass a name through [isSafeServiceName] before + * building a command. The allow-list covers exactly the characters real + * systemd / OpenRC / procd service names use and excludes every shell + * metacharacter, so a validated name can be embedded literally and safely. + */ +object ServiceManagerBase { + // Letters, digits, and the punctuation init systems actually allow in unit / + // service names (systemd templates use '@', some units use ':'). NO quotes, + // '$', ';', '|', '&', backtick, whitespace, parentheses or newlines. + private val SAFE_SERVICE_NAME = Regex("^[A-Za-z0-9_.@:+-]+$") + + fun isSafeServiceName(name: String): Boolean = SAFE_SERVICE_NAME.matches(name) +} From 7c5bf7d757e1c5b4bbb69ea1d3b08927ba5958c2 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:26 +0530 Subject: [PATCH 167/211] refactor: unify Procd/OpenRC/Systemd screens into InitServiceScreen The three init-system service screens were ~450-line near-duplicates: same Loading/NotAvailable/Ready state machine, same fetchJob/actionJob cancellation, same Scaffold + search bar + filter-chip row + service card + empty/not-available states, and a ~30-line dark-mode dropdown-theming hack copy-pasted 3x. Only the status set, filter set and per-service actions actually differed. Introduce a manager-agnostic UI model (InitServiceRow / InitServiceUiStatus / InitCommandResult / InitServiceMenuAction / InitServiceFilterChip) and one generic InitServiceScreen that owns all shared behaviour and composables. Each of SystemdScreen/OpenRCScreen/ProcdScreen becomes a thin wrapper that maps its manager's ServiceInfo -> InitServiceRow and supplies the title, availability probe, fetch and filter set. Public composable signatures are unchanged, so the navigation call sites are untouched. Behaviour preserved exactly (same filters, counts, status colors, actions, sort and fetch/cancel logic). Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/screen/InitServiceScreen.kt | 533 ++++++++++++++++++ .../droidspaces/app/ui/screen/OpenRCScreen.kt | 495 ++-------------- .../droidspaces/app/ui/screen/ProcdScreen.kt | 506 ++--------------- .../app/ui/screen/SystemdScreen.kt | 472 ++-------------- 4 files changed, 667 insertions(+), 1339 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt new file mode 100644 index 00000000..8fe68c52 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt @@ -0,0 +1,533 @@ +package com.droidspaces.app.ui.screen + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droidspaces.app.R +import com.droidspaces.app.ui.util.* +import com.droidspaces.app.util.AnimationUtils +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +// ── UI-level model ─────────────────────────────────────────────────────────── +// Manager-agnostic view of an init-system service, so a single screen can drive +// systemd, OpenRC and procd. Each screen maps its manager's ServiceInfo -> this. + +/** Superset of the per-init-system statuses (color + label are derived from it). */ +enum class InitServiceUiStatus { + ENABLED_RUNNING, ENABLED_STOPPED, DISABLED_STOPPED, ABNORMAL, STATIC, MASKED, UNKNOWN +} + +/** Manager-agnostic command result. */ +data class InitCommandResult(val isSuccess: Boolean, val output: List, val error: List) + +/** An overflow-menu action (restart / mask / reload / …). */ +data class InitServiceMenuAction( + val labelRes: Int, + val icon: ImageVector, + val run: suspend () -> InitCommandResult, +) + +/** One service row plus the actions applicable to it. */ +data class InitServiceRow( + val name: String, + val description: String, + val isRunning: Boolean, + val isEnabled: Boolean, + val isMasked: Boolean, + val isStatic: Boolean, + val status: InitServiceUiStatus, + val startStop: suspend () -> InitCommandResult, + val enableDisable: suspend () -> InitCommandResult, + /** Non-null only when the service is masked (systemd). */ + val unmask: (suspend () -> InitCommandResult)?, + val menu: List, +) + +/** + * A filter chip. [predicate] is used both to filter the list and to compute the + * chip's count, so counts and filtering can never drift. [dotColor] null = "All". + */ +data class InitServiceFilterChip( + val id: String, + val labelRes: Int, + val dotColor: Color?, + val emptyRes: Int, + val predicate: (InitServiceRow) -> Boolean, +) + +private val JetBrainsMono = FontFamily( + Font(R.font.jetbrains_mono_regular, FontWeight.Normal), + Font(R.font.jetbrains_mono_bold, FontWeight.Bold) +) + +private sealed class InitScreenState { + data object Loading : InitScreenState() + data object NotAvailable : InitScreenState() + data class Ready(val rows: List) : InitScreenState() +} + +private sealed class InitActionState { + data object Idle : InitActionState() + data class InProgress(val serviceName: String, val actionName: String) : InitActionState() +} + +/** + * Generic init-system service management screen. Behaviour is identical across + * systemd / OpenRC / procd; the differences (title, availability probe, service + * fetch/mapping, and the filter set) are supplied by the caller. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InitServiceScreen( + containerName: String, + titleRes: Int, + onNavigateBack: () -> Unit, + isAvailable: suspend (String) -> Boolean, + fetchRows: suspend (String) -> List, + filters: List, + defaultFilterId: String, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var screenState by remember { mutableStateOf(InitScreenState.Loading) } + var actionState by remember { mutableStateOf(InitActionState.Idle) } + var selectedFilterId by remember { mutableStateOf(defaultFilterId) } + var logsDialogContent by remember { mutableStateOf?>(null) } + var searchQuery by remember { mutableStateOf("") } + + var fetchJob by remember { mutableStateOf(null) } + var actionJob by remember { mutableStateOf(null) } + + fun fetchServices() { + fetchJob?.cancel() + screenState = InitScreenState.Loading + fetchJob = scope.launch { + try { + if (!isAvailable(containerName)) { + screenState = InitScreenState.NotAvailable + return@launch + } + screenState = InitScreenState.Ready(fetchRows(containerName)) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + screenState = InitScreenState.NotAvailable + } + } + } + + fun executeAction(serviceName: String, actionName: String, action: suspend () -> InitCommandResult) { + actionJob?.cancel() + fetchJob?.cancel() + actionState = InitActionState.InProgress(serviceName, actionName) + actionJob = scope.launch { + try { + val result = action() + actionState = InitActionState.Idle + if (result.isSuccess) { + screenState = InitScreenState.Loading + scope.showSuccess(snackbarHostState, context.getString(R.string.action_successful, actionName, serviceName)) + fetchServices() + } else { + val allLogs = result.output + result.error + if (allLogs.isNotEmpty()) logsDialogContent = allLogs + else scope.showError(snackbarHostState, context.getString(R.string.failed_to_action, actionName, serviceName)) + } + } catch (e: Exception) { + actionState = InitActionState.Idle + if (e is kotlinx.coroutines.CancellationException) throw e + scope.showError(snackbarHostState, context.getString(R.string.error_unknown, e.message ?: context.getString(R.string.unknown))) + } + } + } + + LaunchedEffect(containerName) { fetchServices() } + + val allRows = (screenState as? InitScreenState.Ready)?.rows ?: emptyList() + val selectedFilter = filters.firstOrNull { it.id == selectedFilterId } ?: filters.first() + val filteredRows = remember(allRows, selectedFilterId, searchQuery) { + if (searchQuery.isBlank()) { + allRows.filter(selectedFilter.predicate) + .sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) + } else { + allRows.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + } + val counts = remember(allRows) { + filters.associate { chip -> chip.id to allRows.count(chip.predicate) } + } + + val clearFocus = rememberClearFocus() + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(context.getString(titleRes), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) }, + navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.back)) } }, + actions = { IconButton(onClick = { fetchServices() }, enabled = screenState !is InitScreenState.Loading && actionState is InitActionState.Idle) { Icon(Icons.Default.Refresh, context.getString(R.string.refresh)) } }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent + ) { padding -> + ClearFocusOnClickOutside(modifier = Modifier.padding(padding).fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + when (screenState) { + is InitScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) + is InitScreenState.NotAvailable -> InitServiceNotAvailable() + is InitScreenState.Ready -> { + Column(modifier = Modifier.fillMaxSize()) { + InitServiceSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) + InitServiceFilterChipsRow( + filters = filters, + counts = counts, + selectedFilterId = selectedFilterId, + onFilterSelected = { selectedFilterId = it; clearFocus() } + ) + if (filteredRows.isEmpty()) { + InitServiceEmptyState(emptyRes = selectedFilter.emptyRes, modifier = Modifier.weight(1f)) + } else { + LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(filteredRows) { row -> + InitServiceCard(row = row, onAction = { actionName, act -> executeAction(row.name, actionName, act) }) + } + } + } + } + } + } + } + } + } + } + + (actionState as? InitActionState.InProgress)?.let { state -> ProgressDialog(message = context.getString(R.string.actioning_service, state.actionName, state.serviceName)) } + logsDialogContent?.let { logs -> ErrorLogsDialog(logs = logs, onDismiss = { logsDialogContent = null }) } +} + +@Composable +private fun InitServiceSearchBar(query: String, onQueryChange: (String) -> Unit) { + val context = LocalContext.current + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor by animateColorAsState( + targetValue = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), + animationSpec = AnimationUtils.fastSpec() + ) + + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, borderColor) + ) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + interactionSource = interactionSource, + placeholder = { Text(context.getString(R.string.search_services), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, + leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, + trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } } }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary + ), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + keyboardOptions = FocusUtils.searchKeyboardOptions, + keyboardActions = FocusUtils.clearFocusKeyboardActions() + ) + } +} + +@Composable +private fun InitServiceFilterChipsRow( + filters: List, + counts: Map, + selectedFilterId: String, + onFilterSelected: (String) -> Unit +) { + val context = LocalContext.current + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + filters.forEach { chip -> + val count = counts[chip.id] ?: 0 + val isSelected = selectedFilterId == chip.id + FilterChip( + selected = isSelected, + onClick = { onFilterSelected(chip.id) }, + label = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (chip.dotColor != null) { + Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = chip.dotColor) {} + } + Text("${context.getString(chip.labelRes)} ($count)", style = MaterialTheme.typography.labelLarge) + } + }, + shape = RoundedCornerShape(12.dp), + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer), + border = FilterChipDefaults.filterChipBorder(selected = isSelected, enabled = true, + borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + selectedBorderColor = MaterialTheme.colorScheme.primary) + ) + } + } +} + +@Composable +private fun statusColorFor(status: InitServiceUiStatus): Color = when (status) { + InitServiceUiStatus.ENABLED_RUNNING -> Color(0xFF4CAF50) + InitServiceUiStatus.ENABLED_STOPPED -> Color(0xFFFFCA28) + InitServiceUiStatus.DISABLED_STOPPED -> Color(0xFFEF5350) + InitServiceUiStatus.ABNORMAL -> Color(0xFFFF7043) + InitServiceUiStatus.STATIC -> Color(0xFF607D8B) + InitServiceUiStatus.MASKED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + InitServiceUiStatus.UNKNOWN -> Color(0xFF90A4AE) +} + +private fun statusLabelRes(status: InitServiceUiStatus): Int = when (status) { + InitServiceUiStatus.ENABLED_RUNNING -> R.string.running + InitServiceUiStatus.ENABLED_STOPPED -> R.string.enabled_legend + InitServiceUiStatus.DISABLED_STOPPED -> R.string.disabled_legend + InitServiceUiStatus.ABNORMAL -> R.string.abnormal_legend + InitServiceUiStatus.STATIC -> R.string.static_legend + InitServiceUiStatus.MASKED -> R.string.masked_legend + InitServiceUiStatus.UNKNOWN -> R.string.unknown_legend +} + +@Composable +private fun InitServiceCard( + row: InitServiceRow, + onAction: (String, suspend () -> InitCommandResult) -> Unit +) { + val context = LocalContext.current + var showMenu by remember { mutableStateOf(false) } + val statusColor = statusColorFor(row.status) + + Surface( + modifier = Modifier.fillMaxWidth().animateContentSize(AnimationUtils.mediumSpec()), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp).height(32.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = row.name, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = JetBrainsMono, + fontSize = if (row.name.length > 25) 13.sp else 16.sp + ), + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Surface( + color = statusColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} + Text( + text = context.getString(statusLabelRes(row.status)).uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Black, + letterSpacing = 0.5.sp, + color = statusColor + ) + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (row.description.isNotEmpty()) { + Text( + text = row.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + val unmask = row.unmask + if (row.isMasked && unmask != null) { + Surface( + onClick = { onAction(context.getString(R.string.unmask), unmask) }, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.LockOpen, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Text(context.getString(R.string.unmask), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + } + } + } else { + val btnColor = if (row.isRunning) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + val accentColor = if (row.isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + Surface( + onClick = { onAction(if (row.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), row.startStop) }, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + color = btnColor, + border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f)) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(if (row.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, null, Modifier.size(18.dp), tint = accentColor) + Text(if (row.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = accentColor) + } + } + } + if (!row.isStatic) { + Surface( + onClick = { onAction(if (row.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), row.enableDisable) }, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(if (row.isEnabled) Icons.Default.Block else Icons.Default.CheckCircle, null, Modifier.size(18.dp)) + Text(if (row.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + } + } + } + } + if (row.menu.isNotEmpty()) { + Box { + Surface(onClick = { showMenu = true }, modifier = Modifier.size(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))) { + Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + } + // Dark-mode dropdown theming: force an opaque surface + rounded corners. + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = dropdownColor, + surfaceContainer = dropdownColor, + surfaceTint = Color.Transparent + ), + shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(20.dp)) + ) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier.border(width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), shape = RoundedCornerShape(20.dp)) + ) { + row.menu.forEach { item -> + DropdownMenuItem( + text = { Text(context.getString(item.labelRes)) }, + leadingIcon = { Icon(item.icon, null) }, + onClick = { showMenu = false; onAction(context.getString(item.labelRes), item.run) } + ) + } + } + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun InitServiceNotAvailable() { + val context = LocalContext.current + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(32.dp)) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f))) { + Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Warning, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) } + } + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(context.getString(R.string.init_system_not_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(context.getString(R.string.init_system_not_available_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun InitServiceEmptyState(emptyRes: Int, modifier: Modifier = Modifier) { + val context = LocalContext.current + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(Icons.Default.SearchOff, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) + Text(text = context.getString(emptyRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt index 248eaacf..0a1de5aa 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt @@ -1,54 +1,39 @@ package com.droidspaces.app.ui.screen -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.droidspaces.app.R -import com.droidspaces.app.ui.util.* -import com.droidspaces.app.util.AnimationUtils import com.droidspaces.app.util.ContainerOpenRCManager -import com.droidspaces.app.util.ContainerOpenRCManager.ServiceFilter -import com.droidspaces.app.util.ContainerOpenRCManager.ServiceInfo -import com.droidspaces.app.util.ContainerOpenRCManager.ServiceStatus -import kotlinx.coroutines.launch -private val JetBrainsMono = FontFamily( - Font(R.font.jetbrains_mono_regular, FontWeight.Normal), - Font(R.font.jetbrains_mono_bold, FontWeight.Bold) -) +private fun ContainerOpenRCManager.CommandResult.toInit() = InitCommandResult(isSuccess, output, error) -private sealed class OpenRCScreenState { - data object Loading : OpenRCScreenState() - data object OpenRCNotAvailable : OpenRCScreenState() - data class Ready(val services: List) : OpenRCScreenState() -} - -private sealed class OpenRCActionState { - data object Idle : OpenRCActionState() - data class InProgress(val serviceName: String, val actionName: String) : OpenRCActionState() +private fun ContainerOpenRCManager.ServiceInfo.toRow(containerName: String): InitServiceRow { + val uiStatus = when (status) { + ContainerOpenRCManager.ServiceStatus.ENABLED_RUNNING -> InitServiceUiStatus.ENABLED_RUNNING + ContainerOpenRCManager.ServiceStatus.ENABLED_STOPPED -> InitServiceUiStatus.ENABLED_STOPPED + ContainerOpenRCManager.ServiceStatus.DISABLED_STOPPED -> InitServiceUiStatus.DISABLED_STOPPED + ContainerOpenRCManager.ServiceStatus.ABNORMAL -> InitServiceUiStatus.ABNORMAL + } + return InitServiceRow( + name = name, + description = description, + isRunning = isRunning, + isEnabled = isEnabled, + isMasked = false, + isStatic = false, + status = uiStatus, + startStop = { (if (isRunning) ContainerOpenRCManager.stopService(containerName, name) else ContainerOpenRCManager.startService(containerName, name)).toInit() }, + enableDisable = { (if (isEnabled) ContainerOpenRCManager.disableService(containerName, name) else ContainerOpenRCManager.enableService(containerName, name)).toInit() }, + unmask = null, + menu = buildList { + if (isRunning) add(InitServiceMenuAction(R.string.restart_service, Icons.Default.Refresh) { ContainerOpenRCManager.restartService(containerName, name).toInit() }) + } + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -58,421 +43,23 @@ fun OpenRCScreen( onNavigateBack: () -> Unit ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { ContainerOpenRCManager.initialize(context) } - var screenState by remember { mutableStateOf(OpenRCScreenState.Loading) } - var actionState by remember { mutableStateOf(OpenRCActionState.Idle) } - var selectedFilter by remember { mutableStateOf(ServiceFilter.RUNNING) } - var logsDialogContent by remember { mutableStateOf?>(null) } - var searchQuery by remember { mutableStateOf("") } - - var fetchJob by remember { mutableStateOf(null) } - var actionJob by remember { mutableStateOf(null) } - - fun fetchServices() { - fetchJob?.cancel() - screenState = OpenRCScreenState.Loading - fetchJob = scope.launch { - try { - val available = ContainerOpenRCManager.isOpenRCAvailable(containerName) - if (!available) { - screenState = OpenRCScreenState.OpenRCNotAvailable - return@launch - } - val services = ContainerOpenRCManager.getAllServices(containerName) - screenState = OpenRCScreenState.Ready(services) - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - screenState = OpenRCScreenState.OpenRCNotAvailable - } - } - } - - fun executeAction(serviceName: String, actionName: String, action: suspend () -> ContainerOpenRCManager.CommandResult) { - actionJob?.cancel() - fetchJob?.cancel() - actionState = OpenRCActionState.InProgress(serviceName, actionName) - actionJob = scope.launch { - try { - val result = action() - actionState = OpenRCActionState.Idle - if (result.isSuccess) { - screenState = OpenRCScreenState.Loading - scope.showSuccess(snackbarHostState, context.getString(R.string.action_successful, actionName, serviceName)) - fetchServices() - } else { - val allLogs = result.output + result.error - if (allLogs.isNotEmpty()) logsDialogContent = allLogs - else scope.showError(snackbarHostState, context.getString(R.string.failed_to_action, actionName, serviceName)) - } - } catch (e: Exception) { - actionState = OpenRCActionState.Idle - if (e is kotlinx.coroutines.CancellationException) throw e - scope.showError(snackbarHostState, context.getString(R.string.error_unknown, e.message ?: context.getString(R.string.unknown))) - } - } - } - - LaunchedEffect(containerName) { fetchServices() } - - val allServices = (screenState as? OpenRCScreenState.Ready)?.services ?: emptyList() - val filteredServices = remember(allServices, selectedFilter, searchQuery) { - if (searchQuery.isBlank()) ContainerOpenRCManager.filterServices(allServices, selectedFilter) - else allServices.filter { it.name.contains(searchQuery, ignoreCase = true) } - } - - val serviceCounts = remember(allServices) { - mapOf( - ServiceFilter.RUNNING to allServices.count { it.isRunning && it.isEnabled }, - ServiceFilter.ENABLED to allServices.count { it.isEnabled && !it.isRunning }, - ServiceFilter.DISABLED to allServices.count { !it.isEnabled && !it.isRunning }, - ServiceFilter.ABNORMAL to allServices.count { it.isRunning && !it.isEnabled }, - ServiceFilter.ALL to allServices.size - ) - } - - val clearFocus = rememberClearFocus() - - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(context.getString(R.string.openrc_services), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) }, - navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.back)) } }, - actions = { IconButton(onClick = { fetchServices() }, enabled = screenState !is OpenRCScreenState.Loading && actionState is OpenRCActionState.Idle) { Icon(Icons.Default.Refresh, context.getString(R.string.refresh)) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = Color.Transparent - ) { padding -> - ClearFocusOnClickOutside(modifier = Modifier.padding(padding).fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - when (screenState) { - is OpenRCScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) - is OpenRCScreenState.OpenRCNotAvailable -> OpenRCNotAvailable() - is OpenRCScreenState.Ready -> { - Column(modifier = Modifier.fillMaxSize()) { - OpenRCSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) - OpenRCFilterChipsRow(selectedFilter = selectedFilter, serviceCounts = serviceCounts, onFilterSelected = { selectedFilter = it; clearFocus() }) - if (filteredServices.isEmpty()) { - OpenRCEmptyServicesState(filter = selectedFilter, modifier = Modifier.weight(1f)) - } else { - LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - items(filteredServices) { service -> - OpenRCServiceCard(service = service, containerName = containerName, onAction = { name, act -> executeAction(service.name, name, act) }) - } - } - } - } - } - } - } - } - } - } - - (actionState as? OpenRCActionState.InProgress)?.let { state -> ProgressDialog(message = context.getString(R.string.actioning_service, state.actionName, state.serviceName)) } - logsDialogContent?.let { logs -> ErrorLogsDialog(logs = logs, onDismiss = { logsDialogContent = null }) } -} - -@Composable -private fun OpenRCSearchBar(query: String, onQueryChange: (String) -> Unit) { - val context = LocalContext.current - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val borderColor by animateColorAsState( - targetValue = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), - animationSpec = AnimationUtils.fastSpec() - ) - - Surface( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, borderColor) - ) { - TextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - interactionSource = interactionSource, - placeholder = { Text(context.getString(R.string.search_services), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } } }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - keyboardOptions = FocusUtils.searchKeyboardOptions, - keyboardActions = FocusUtils.clearFocusKeyboardActions() - ) - } -} - -@Composable -private fun OpenRCFilterChipsRow( - selectedFilter: ServiceFilter, - serviceCounts: Map, - onFilterSelected: (ServiceFilter) -> Unit -) { - val context = LocalContext.current - // OpenRC has no masked/static - simpler filter set val filters = listOf( - Triple(ServiceFilter.RUNNING, R.string.running, Color(0xFF4CAF50)), - Triple(ServiceFilter.ENABLED, R.string.enabled_legend, Color(0xFFFFCA28)), - Triple(ServiceFilter.DISABLED, R.string.disabled_legend, Color(0xFFEF5350)), - Triple(ServiceFilter.ABNORMAL, R.string.abnormal_legend, Color(0xFFFF7043)), - Triple(ServiceFilter.ALL, R.string.all_legend, null) + InitServiceFilterChip("RUNNING", R.string.running, Color(0xFF4CAF50), R.string.no_running_services) { it.isRunning && it.isEnabled }, + InitServiceFilterChip("ENABLED", R.string.enabled_legend, Color(0xFFFFCA28), R.string.no_enabled_services) { it.isEnabled && !it.isRunning }, + InitServiceFilterChip("DISABLED", R.string.disabled_legend, Color(0xFFEF5350), R.string.no_disabled_services) { !it.isEnabled && !it.isRunning }, + InitServiceFilterChip("ABNORMAL", R.string.abnormal_legend, Color(0xFFFF7043), R.string.no_abnormal_services) { it.isRunning && !it.isEnabled }, + InitServiceFilterChip("ALL", R.string.all_legend, null, R.string.no_services_found) { true }, ) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - filters.forEach { (filter, labelRes, dotColor) -> - val count = serviceCounts[filter] ?: 0 - val isSelected = selectedFilter == filter - - FilterChip( - selected = isSelected, - onClick = { onFilterSelected(filter) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (dotColor != null) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = dotColor) {} - } - Text("${context.getString(labelRes)} ($count)", style = MaterialTheme.typography.labelLarge) - } - }, - shape = RoundedCornerShape(12.dp), - colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer), - border = FilterChipDefaults.filterChipBorder(selected = isSelected, enabled = true, - borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - selectedBorderColor = MaterialTheme.colorScheme.primary) - ) - } - } -} - -@Composable -private fun OpenRCServiceCard( - service: ServiceInfo, - containerName: String, - onAction: (String, suspend () -> ContainerOpenRCManager.CommandResult) -> Unit -) { - val context = LocalContext.current - var showMenu by remember { mutableStateOf(false) } - - val statusColor = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> Color(0xFF4CAF50) - ServiceStatus.ENABLED_STOPPED -> Color(0xFFFFCA28) - ServiceStatus.DISABLED_STOPPED -> Color(0xFFEF5350) - ServiceStatus.ABNORMAL -> Color(0xFFFF7043) - } - - Surface( - modifier = Modifier.fillMaxWidth().animateContentSize(AnimationUtils.mediumSpec()), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Column { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp).height(32.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = service.name, - style = MaterialTheme.typography.titleMedium.copy( - fontFamily = JetBrainsMono, - fontSize = if (service.name.length > 25) 13.sp else 16.sp - ), - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis - ) - - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> context.getString(R.string.running) - ServiceStatus.ENABLED_STOPPED -> context.getString(R.string.enabled_legend) - ServiceStatus.DISABLED_STOPPED -> context.getString(R.string.disabled_legend) - ServiceStatus.ABNORMAL -> context.getString(R.string.abnormal_legend) - }.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (service.description.isNotEmpty()) { - Text( - text = service.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { - val btnColor = if (service.isRunning) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - val accentColor = if (service.isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - - // Start / Stop - Surface( - onClick = { - if (service.isRunning) onAction(context.getString(R.string.stop)) { ContainerOpenRCManager.stopService(containerName, service.name) } - else onAction(context.getString(R.string.start)) { ContainerOpenRCManager.startService(containerName, service.name) } - }, - modifier = Modifier.weight(1f).height(48.dp), - shape = RoundedCornerShape(16.dp), - color = btnColor, - border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f)) - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(if (service.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, null, Modifier.size(18.dp), tint = accentColor) - Text(if (service.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = accentColor) - } - } - } - - // Enable / Disable - Surface( - onClick = { - if (service.isEnabled) onAction(context.getString(R.string.disable_service)) { ContainerOpenRCManager.disableService(containerName, service.name) } - else onAction(context.getString(R.string.enable_service)) { ContainerOpenRCManager.enableService(containerName, service.name) } - }, - modifier = Modifier.weight(1f).height(48.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(if (service.isEnabled) Icons.Default.Block else Icons.Default.CheckCircle, null, Modifier.size(18.dp)) - Text(if (service.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) - } - } - } - - // More (Restart) - if (service.isRunning) { - Box { - Surface( - onClick = { showMenu = true }, - modifier = Modifier.size(48.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } - } - val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f - val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer - MaterialTheme( - colorScheme = MaterialTheme.colorScheme.copy( - surface = dropdownColor, - surfaceContainer = dropdownColor, - surfaceTint = Color.Transparent - ), - shapes = MaterialTheme.shapes.copy( - extraSmall = RoundedCornerShape(20.dp) - ) - ) { - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - modifier = Modifier - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), - shape = RoundedCornerShape(20.dp) - ) - ) { - DropdownMenuItem( - text = { Text(context.getString(R.string.restart_service)) }, - leadingIcon = { Icon(Icons.Default.Refresh, null) }, - onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerOpenRCManager.restartService(containerName, service.name) } } - ) - } - } - } - } - } - } - } - } - } -} - -@Composable -private fun OpenRCNotAvailable() { - val context = LocalContext.current - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(32.dp)) { - Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f))) { - Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Warning, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) } - } - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(context.getString(R.string.init_system_not_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(context.getString(R.string.init_system_not_available_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } - } - } -} - -@Composable -private fun OpenRCEmptyServicesState(filter: ServiceFilter, modifier: Modifier = Modifier) { - val context = LocalContext.current - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - Icon(Icons.Default.SearchOff, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) - Text( - text = when (filter) { - ServiceFilter.RUNNING -> context.getString(R.string.no_running_services) - ServiceFilter.ENABLED -> context.getString(R.string.no_enabled_services) - ServiceFilter.DISABLED -> context.getString(R.string.no_disabled_services) - ServiceFilter.ABNORMAL -> context.getString(R.string.no_abnormal_services) - ServiceFilter.ALL -> context.getString(R.string.no_services_found) - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - } + InitServiceScreen( + containerName = containerName, + titleRes = R.string.openrc_services, + onNavigateBack = onNavigateBack, + isAvailable = { cn -> ContainerOpenRCManager.isOpenRCAvailable(cn) }, + fetchRows = { cn -> ContainerOpenRCManager.getAllServices(cn).map { it.toRow(cn) } }, + filters = filters, + defaultFilterId = "RUNNING", + ) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt index 0c30b6d3..1b255e55 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt @@ -1,54 +1,41 @@ package com.droidspaces.app.ui.screen -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.droidspaces.app.R -import com.droidspaces.app.ui.util.* -import com.droidspaces.app.util.AnimationUtils import com.droidspaces.app.util.ContainerProcdManager -import com.droidspaces.app.util.ContainerProcdManager.ServiceFilter -import com.droidspaces.app.util.ContainerProcdManager.ServiceInfo -import com.droidspaces.app.util.ContainerProcdManager.ServiceStatus -import kotlinx.coroutines.launch -private val JetBrainsMono = FontFamily( - Font(R.font.jetbrains_mono_regular, FontWeight.Normal), - Font(R.font.jetbrains_mono_bold, FontWeight.Bold) -) +private fun ContainerProcdManager.CommandResult.toInit() = InitCommandResult(isSuccess, output, error) -private sealed class ProcdScreenState { - data object Loading : ProcdScreenState() - data object ProcdNotAvailable : ProcdScreenState() - data class Ready(val services: List) : ProcdScreenState() -} - -private sealed class ProcdActionState { - data object Idle : ProcdActionState() - data class InProgress(val serviceName: String, val actionName: String) : ProcdActionState() +private fun ContainerProcdManager.ServiceInfo.toRow(containerName: String): InitServiceRow { + val uiStatus = when (status) { + ContainerProcdManager.ServiceStatus.ENABLED_RUNNING -> InitServiceUiStatus.ENABLED_RUNNING + ContainerProcdManager.ServiceStatus.ENABLED_STOPPED -> InitServiceUiStatus.ENABLED_STOPPED + ContainerProcdManager.ServiceStatus.DISABLED_STOPPED -> InitServiceUiStatus.DISABLED_STOPPED + ContainerProcdManager.ServiceStatus.ABNORMAL -> InitServiceUiStatus.ABNORMAL + ContainerProcdManager.ServiceStatus.UNKNOWN -> InitServiceUiStatus.UNKNOWN + } + return InitServiceRow( + name = name, + description = description, + isRunning = isRunning, + isEnabled = isEnabled, + isMasked = false, + isStatic = false, + status = uiStatus, + startStop = { (if (isRunning) ContainerProcdManager.stopService(containerName, name) else ContainerProcdManager.startService(containerName, name)).toInit() }, + enableDisable = { (if (isEnabled) ContainerProcdManager.disableService(containerName, name) else ContainerProcdManager.enableService(containerName, name)).toInit() }, + unmask = null, + menu = listOf( + InitServiceMenuAction(R.string.restart_service, Icons.Default.Refresh) { ContainerProcdManager.restartService(containerName, name).toInit() }, + InitServiceMenuAction(R.string.reload_service, Icons.Default.Refresh) { ContainerProcdManager.reloadService(containerName, name).toInit() }, + ) + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -58,429 +45,24 @@ fun ProcdScreen( onNavigateBack: () -> Unit ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { ContainerProcdManager.initialize(context) } - var screenState by remember { mutableStateOf(ProcdScreenState.Loading) } - var actionState by remember { mutableStateOf(ProcdActionState.Idle) } - var selectedFilter by remember { mutableStateOf(ServiceFilter.RUNNING) } - var logsDialogContent by remember { mutableStateOf?>(null) } - var searchQuery by remember { mutableStateOf("") } - - var fetchJob by remember { mutableStateOf(null) } - var actionJob by remember { mutableStateOf(null) } - - fun fetchServices() { - fetchJob?.cancel() - screenState = ProcdScreenState.Loading - fetchJob = scope.launch { - try { - val available = ContainerProcdManager.isProcdAvailable(containerName) - if (!available) { - screenState = ProcdScreenState.ProcdNotAvailable - return@launch - } - val services = ContainerProcdManager.getAllServices(containerName) - screenState = ProcdScreenState.Ready(services) - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - screenState = ProcdScreenState.ProcdNotAvailable - } - } - } - - fun executeAction(serviceName: String, actionName: String, action: suspend () -> ContainerProcdManager.CommandResult) { - actionJob?.cancel() - fetchJob?.cancel() - actionState = ProcdActionState.InProgress(serviceName, actionName) - actionJob = scope.launch { - try { - val result = action() - actionState = ProcdActionState.Idle - if (result.isSuccess) { - screenState = ProcdScreenState.Loading - scope.showSuccess(snackbarHostState, context.getString(R.string.action_successful, actionName, serviceName)) - fetchServices() - } else { - val allLogs = result.output + result.error - if (allLogs.isNotEmpty()) logsDialogContent = allLogs - else scope.showError(snackbarHostState, context.getString(R.string.failed_to_action, actionName, serviceName)) - } - } catch (e: Exception) { - actionState = ProcdActionState.Idle - if (e is kotlinx.coroutines.CancellationException) throw e - scope.showError(snackbarHostState, context.getString(R.string.error_unknown, e.message ?: context.getString(R.string.unknown))) - } - } - } - - LaunchedEffect(containerName) { fetchServices() } - - val allServices = (screenState as? ProcdScreenState.Ready)?.services ?: emptyList() - val filteredServices = remember(allServices, selectedFilter, searchQuery) { - if (searchQuery.isBlank()) ContainerProcdManager.filterServices(allServices, selectedFilter) - else allServices.filter { it.name.contains(searchQuery, ignoreCase = true) } - } - - val serviceCounts = remember(allServices) { - mapOf( - ServiceFilter.RUNNING to allServices.count { it.isRunning && it.isEnabled }, - ServiceFilter.ENABLED to allServices.count { it.isEnabled && !it.isRunning && it.status != ServiceStatus.UNKNOWN }, - ServiceFilter.DISABLED to allServices.count { !it.isEnabled && !it.isRunning && it.status != ServiceStatus.UNKNOWN }, - ServiceFilter.ABNORMAL to allServices.count { it.isRunning && !it.isEnabled }, - ServiceFilter.UNKNOWN to allServices.count { it.status == ServiceStatus.UNKNOWN }, - ServiceFilter.ALL to allServices.size - ) - } - - val clearFocus = rememberClearFocus() - - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(context.getString(R.string.openwrt_services), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) }, - navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.back)) } }, - actions = { IconButton(onClick = { fetchServices() }, enabled = screenState !is ProcdScreenState.Loading && actionState is ProcdActionState.Idle) { Icon(Icons.Default.Refresh, context.getString(R.string.refresh)) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = Color.Transparent - ) { padding -> - ClearFocusOnClickOutside(modifier = Modifier.padding(padding).fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - when (screenState) { - is ProcdScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) - is ProcdScreenState.ProcdNotAvailable -> ProcdNotAvailable() - is ProcdScreenState.Ready -> { - Column(modifier = Modifier.fillMaxSize()) { - ProcdSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) - ProcdFilterChipsRow(selectedFilter = selectedFilter, serviceCounts = serviceCounts, onFilterSelected = { selectedFilter = it; clearFocus() }) - if (filteredServices.isEmpty()) { - ProcdEmptyServicesState(filter = selectedFilter, modifier = Modifier.weight(1f)) - } else { - LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - items(filteredServices) { service -> - ProcdServiceCard(service = service, containerName = containerName, onAction = { name, act -> executeAction(service.name, name, act) }) - } - } - } - } - } - } - } - } - } - } - - (actionState as? ProcdActionState.InProgress)?.let { state -> ProgressDialog(message = context.getString(R.string.actioning_service, state.actionName, state.serviceName)) } - logsDialogContent?.let { logs -> ErrorLogsDialog(logs = logs, onDismiss = { logsDialogContent = null }) } -} - -@Composable -private fun ProcdSearchBar(query: String, onQueryChange: (String) -> Unit) { - val context = LocalContext.current - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val borderColor by animateColorAsState( - targetValue = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), - animationSpec = AnimationUtils.fastSpec() - ) - - Surface( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, borderColor) - ) { - TextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - interactionSource = interactionSource, - placeholder = { Text(context.getString(R.string.search_services), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } } }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - keyboardOptions = FocusUtils.searchKeyboardOptions, - keyboardActions = FocusUtils.clearFocusKeyboardActions() - ) - } -} - -@Composable -private fun ProcdFilterChipsRow( - selectedFilter: ServiceFilter, - serviceCounts: Map, - onFilterSelected: (ServiceFilter) -> Unit -) { - val context = LocalContext.current - // procd has no masked/static concepts; UNKNOWN covers scripts without reliable status. val filters = listOf( - Triple(ServiceFilter.RUNNING, R.string.running, Color(0xFF4CAF50)), - Triple(ServiceFilter.ENABLED, R.string.enabled_legend, Color(0xFFFFCA28)), - Triple(ServiceFilter.DISABLED, R.string.disabled_legend, Color(0xFFEF5350)), - Triple(ServiceFilter.ABNORMAL, R.string.abnormal_legend, Color(0xFFFF7043)), - Triple(ServiceFilter.UNKNOWN, R.string.unknown_legend, Color(0xFF90A4AE)), - Triple(ServiceFilter.ALL, R.string.all_legend, null) + InitServiceFilterChip("RUNNING", R.string.running, Color(0xFF4CAF50), R.string.no_running_services) { it.isRunning && it.isEnabled }, + InitServiceFilterChip("ENABLED", R.string.enabled_legend, Color(0xFFFFCA28), R.string.no_enabled_services) { it.isEnabled && !it.isRunning && it.status != InitServiceUiStatus.UNKNOWN }, + InitServiceFilterChip("DISABLED", R.string.disabled_legend, Color(0xFFEF5350), R.string.no_disabled_services) { !it.isEnabled && !it.isRunning && it.status != InitServiceUiStatus.UNKNOWN }, + InitServiceFilterChip("ABNORMAL", R.string.abnormal_legend, Color(0xFFFF7043), R.string.no_abnormal_services) { it.isRunning && !it.isEnabled }, + InitServiceFilterChip("UNKNOWN", R.string.unknown_legend, Color(0xFF90A4AE), R.string.no_unknown_services) { it.status == InitServiceUiStatus.UNKNOWN }, + InitServiceFilterChip("ALL", R.string.all_legend, null, R.string.no_services_found) { true }, ) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - filters.forEach { (filter, labelRes, dotColor) -> - val count = serviceCounts[filter] ?: 0 - val isSelected = selectedFilter == filter - - FilterChip( - selected = isSelected, - onClick = { onFilterSelected(filter) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (dotColor != null) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = dotColor) {} - } - Text("${context.getString(labelRes)} ($count)", style = MaterialTheme.typography.labelLarge) - } - }, - shape = RoundedCornerShape(12.dp), - colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer), - border = FilterChipDefaults.filterChipBorder(selected = isSelected, enabled = true, - borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - selectedBorderColor = MaterialTheme.colorScheme.primary) - ) - } - } -} - -@Composable -private fun ProcdServiceCard( - service: ServiceInfo, - containerName: String, - onAction: (String, suspend () -> ContainerProcdManager.CommandResult) -> Unit -) { - val context = LocalContext.current - var showMenu by remember { mutableStateOf(false) } - - val statusColor = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> Color(0xFF4CAF50) - ServiceStatus.ENABLED_STOPPED -> Color(0xFFFFCA28) - ServiceStatus.DISABLED_STOPPED -> Color(0xFFEF5350) - ServiceStatus.ABNORMAL -> Color(0xFFFF7043) - ServiceStatus.UNKNOWN -> Color(0xFF90A4AE) - } - - Surface( - modifier = Modifier.fillMaxWidth().animateContentSize(AnimationUtils.mediumSpec()), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Column { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp).height(32.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = service.name, - style = MaterialTheme.typography.titleMedium.copy( - fontFamily = JetBrainsMono, - fontSize = if (service.name.length > 25) 13.sp else 16.sp - ), - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis - ) - - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> context.getString(R.string.running) - ServiceStatus.ENABLED_STOPPED -> context.getString(R.string.enabled_legend) - ServiceStatus.DISABLED_STOPPED -> context.getString(R.string.disabled_legend) - ServiceStatus.ABNORMAL -> context.getString(R.string.abnormal_legend) - ServiceStatus.UNKNOWN -> context.getString(R.string.unknown_legend) - }.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (service.description.isNotEmpty()) { - Text( - text = service.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { - val btnColor = if (service.isRunning) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - val accentColor = if (service.isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - - // Start / Stop - Surface( - onClick = { - if (service.isRunning) onAction(context.getString(R.string.stop)) { ContainerProcdManager.stopService(containerName, service.name) } - else onAction(context.getString(R.string.start)) { ContainerProcdManager.startService(containerName, service.name) } - }, - modifier = Modifier.weight(1f).height(48.dp), - shape = RoundedCornerShape(16.dp), - color = btnColor, - border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f)) - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(if (service.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, null, Modifier.size(18.dp), tint = accentColor) - Text(if (service.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = accentColor) - } - } - } - - // Enable / Disable - Surface( - onClick = { - if (service.isEnabled) onAction(context.getString(R.string.disable_service)) { ContainerProcdManager.disableService(containerName, service.name) } - else onAction(context.getString(R.string.enable_service)) { ContainerProcdManager.enableService(containerName, service.name) } - }, - modifier = Modifier.weight(1f).height(48.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(if (service.isEnabled) Icons.Default.Block else Icons.Default.CheckCircle, null, Modifier.size(18.dp)) - Text(if (service.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) - } - } - } - - // More (Restart / Reload) - Box { - Surface( - onClick = { showMenu = true }, - modifier = Modifier.size(48.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } - } - val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f - val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer - MaterialTheme( - colorScheme = MaterialTheme.colorScheme.copy( - surface = dropdownColor, - surfaceContainer = dropdownColor, - surfaceTint = Color.Transparent - ), - shapes = MaterialTheme.shapes.copy( - extraSmall = RoundedCornerShape(20.dp) - ) - ) { - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - modifier = Modifier - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), - shape = RoundedCornerShape(20.dp) - ) - ) { - DropdownMenuItem( - text = { Text(context.getString(R.string.restart_service)) }, - leadingIcon = { Icon(Icons.Default.Refresh, null) }, - onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerProcdManager.restartService(containerName, service.name) } } - ) - DropdownMenuItem( - text = { Text(context.getString(R.string.reload_service)) }, - leadingIcon = { Icon(Icons.Default.Refresh, null) }, - onClick = { showMenu = false; onAction(context.getString(R.string.reload_service)) { ContainerProcdManager.reloadService(containerName, service.name) } } - ) - } - } - } - } - } - } - } - } -} - -@Composable -private fun ProcdNotAvailable() { - val context = LocalContext.current - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(32.dp)) { - Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f))) { - Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Warning, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) } - } - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(context.getString(R.string.init_system_not_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(context.getString(R.string.init_system_not_available_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } - } - } -} - -@Composable -private fun ProcdEmptyServicesState(filter: ServiceFilter, modifier: Modifier = Modifier) { - val context = LocalContext.current - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - Icon(Icons.Default.SearchOff, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) - Text( - text = when (filter) { - ServiceFilter.RUNNING -> context.getString(R.string.no_running_services) - ServiceFilter.ENABLED -> context.getString(R.string.no_enabled_services) - ServiceFilter.DISABLED -> context.getString(R.string.no_disabled_services) - ServiceFilter.ABNORMAL -> context.getString(R.string.no_abnormal_services) - ServiceFilter.UNKNOWN -> context.getString(R.string.no_unknown_services) - ServiceFilter.ALL -> context.getString(R.string.no_services_found) - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - } + InitServiceScreen( + containerName = containerName, + titleRes = R.string.openwrt_services, + onNavigateBack = onNavigateBack, + isAvailable = { cn -> ContainerProcdManager.isProcdAvailable(cn) }, + fetchRows = { cn -> ContainerProcdManager.getAllServices(cn).map { it.toRow(cn) } }, + filters = filters, + defaultFilterId = "RUNNING", + ) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt index 40a0ccbc..4cb7c6f9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt @@ -1,56 +1,44 @@ package com.droidspaces.app.ui.screen -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.droidspaces.app.R -import com.droidspaces.app.ui.util.* -import com.droidspaces.app.util.AnimationUtils import com.droidspaces.app.util.ContainerSystemdManager -import com.droidspaces.app.util.ContainerSystemdManager.ServiceFilter -import com.droidspaces.app.util.ContainerSystemdManager.ServiceInfo -import com.droidspaces.app.util.ContainerSystemdManager.ServiceStatus -import kotlinx.coroutines.launch -// Premium Font Stack -private val JetBrainsMono = FontFamily( - Font(R.font.jetbrains_mono_regular, FontWeight.Normal), - Font(R.font.jetbrains_mono_bold, FontWeight.Bold) -) +private fun ContainerSystemdManager.CommandResult.toInit() = InitCommandResult(isSuccess, output, error) -// UI States -private sealed class SystemdScreenState { - data object Loading : SystemdScreenState() - data object SystemdNotAvailable : SystemdScreenState() - data class Ready(val services: List) : SystemdScreenState() -} - -private sealed class SystemdActionState { - data object Idle : SystemdActionState() - data class InProgress(val serviceName: String, val actionName: String) : SystemdActionState() +private fun ContainerSystemdManager.ServiceInfo.toRow(containerName: String): InitServiceRow { + val uiStatus = when (status) { + ContainerSystemdManager.ServiceStatus.ENABLED_RUNNING -> InitServiceUiStatus.ENABLED_RUNNING + ContainerSystemdManager.ServiceStatus.ENABLED_STOPPED -> InitServiceUiStatus.ENABLED_STOPPED + ContainerSystemdManager.ServiceStatus.DISABLED_STOPPED -> InitServiceUiStatus.DISABLED_STOPPED + ContainerSystemdManager.ServiceStatus.STATIC -> InitServiceUiStatus.STATIC + ContainerSystemdManager.ServiceStatus.ABNORMAL -> InitServiceUiStatus.ABNORMAL + ContainerSystemdManager.ServiceStatus.MASKED -> InitServiceUiStatus.MASKED + } + return InitServiceRow( + name = name, + description = description, + isRunning = isRunning, + isEnabled = isEnabled, + isMasked = isMasked, + isStatic = isStatic, + status = uiStatus, + startStop = { (if (isRunning) ContainerSystemdManager.stopService(containerName, name) else ContainerSystemdManager.startService(containerName, name)).toInit() }, + enableDisable = { (if (isEnabled) ContainerSystemdManager.disableService(containerName, name) else ContainerSystemdManager.enableService(containerName, name)).toInit() }, + unmask = if (isMasked) { { ContainerSystemdManager.unmaskService(containerName, name).toInit() } } else null, + menu = buildList { + if (isRunning) add(InitServiceMenuAction(R.string.restart_service, Icons.Default.Refresh) { ContainerSystemdManager.restartService(containerName, name).toInit() }) + add(InitServiceMenuAction(R.string.mask_service, Icons.Default.Lock) { ContainerSystemdManager.maskService(containerName, name).toInit() }) + } + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -60,388 +48,26 @@ fun SystemdScreen( onNavigateBack: () -> Unit ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { ContainerSystemdManager.initialize(context) } - var screenState by remember { mutableStateOf(SystemdScreenState.Loading) } - var actionState by remember { mutableStateOf(SystemdActionState.Idle) } - var selectedFilter by remember { mutableStateOf(ServiceFilter.RUNNING) } - var logsDialogContent by remember { mutableStateOf?>(null) } - var searchQuery by remember { mutableStateOf("") } - - var fetchJob by remember { mutableStateOf(null) } - var actionJob by remember { mutableStateOf(null) } - - fun fetchServices() { - fetchJob?.cancel() - screenState = SystemdScreenState.Loading - fetchJob = scope.launch { - try { - val available = ContainerSystemdManager.isSystemdAvailable(containerName) - if (!available) { - screenState = SystemdScreenState.SystemdNotAvailable - return@launch - } - val services = ContainerSystemdManager.getAllServices(containerName) - screenState = SystemdScreenState.Ready(services) - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - screenState = SystemdScreenState.SystemdNotAvailable - } - } - } - - fun executeAction(serviceName: String, actionName: String, action: suspend () -> ContainerSystemdManager.CommandResult) { - actionJob?.cancel() - fetchJob?.cancel() - actionState = SystemdActionState.InProgress(serviceName, actionName) - actionJob = scope.launch { - try { - val result = action() - actionState = SystemdActionState.Idle - if (result.isSuccess) { - screenState = SystemdScreenState.Loading - scope.showSuccess(snackbarHostState, context.getString(R.string.action_successful, actionName, serviceName)) - fetchServices() - } else { - val allLogs = result.output + result.error - if (allLogs.isNotEmpty()) logsDialogContent = allLogs - else scope.showError(snackbarHostState, context.getString(R.string.failed_to_action, actionName, serviceName)) - } - } catch (e: Exception) { - actionState = SystemdActionState.Idle - if (e is kotlinx.coroutines.CancellationException) throw e - scope.showError(snackbarHostState, context.getString(R.string.error_unknown, e.message ?: context.getString(R.string.unknown))) - } - } - } - - LaunchedEffect(containerName) { fetchServices() } - - val allServices = (screenState as? SystemdScreenState.Ready)?.services ?: emptyList() - val filteredServices = remember(allServices, selectedFilter, searchQuery) { - if (searchQuery.isBlank()) ContainerSystemdManager.filterServices(allServices, selectedFilter) - else allServices.filter { it.name.contains(searchQuery, ignoreCase = true) } - } - - val serviceCounts = remember(allServices) { - mapOf( - ServiceFilter.RUNNING to allServices.count { it.isRunning && it.isEnabled && !it.isMasked }, - ServiceFilter.ENABLED to allServices.count { it.isEnabled && !it.isRunning && !it.isMasked }, - ServiceFilter.DISABLED to allServices.count { !it.isEnabled && !it.isRunning && !it.isMasked && !it.isStatic }, - ServiceFilter.ABNORMAL to allServices.count { it.isRunning && !it.isEnabled && !it.isStatic && !it.isMasked }, - ServiceFilter.STATIC to allServices.count { it.isStatic }, - ServiceFilter.MASKED to allServices.count { it.isMasked }, - ServiceFilter.ALL to allServices.size - ) - } - - val clearFocus = rememberClearFocus() - - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(context.getString(R.string.systemd_services), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) }, - navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.back)) } }, - actions = { IconButton(onClick = { fetchServices() }, enabled = screenState !is SystemdScreenState.Loading && actionState is SystemdActionState.Idle) { Icon(Icons.Default.Refresh, context.getString(R.string.refresh)) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = Color.Transparent - ) { padding -> - ClearFocusOnClickOutside(modifier = Modifier.padding(padding).fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - when (screenState) { - is SystemdScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) - is SystemdScreenState.SystemdNotAvailable -> SystemdNotAvailable() - is SystemdScreenState.Ready -> { - Column(modifier = Modifier.fillMaxSize()) { - SearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) - FilterChipsRow(selectedFilter = selectedFilter, serviceCounts = serviceCounts, onFilterSelected = { selectedFilter = it; clearFocus() }) - if (filteredServices.isEmpty()) { - EmptyServicesState(filter = selectedFilter, modifier = Modifier.weight(1f)) - } else { - LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - items(filteredServices) { service -> - ServiceCard(service = service, containerName = containerName, onAction = { name, act -> executeAction(service.name, name, act) }) - } - } - } - } - } - } - } - } - } - } - - (actionState as? SystemdActionState.InProgress)?.let { state -> ProgressDialog(message = context.getString(R.string.actioning_service, state.actionName, state.serviceName)) } - logsDialogContent?.let { logs -> ErrorLogsDialog(logs = logs, onDismiss = { logsDialogContent = null }) } -} - -@Composable -private fun SearchBar(query: String, onQueryChange: (String) -> Unit) { - val context = LocalContext.current - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val borderColor by animateColorAsState( - targetValue = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), - animationSpec = AnimationUtils.fastSpec() - ) - - Surface( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, borderColor) - ) { - TextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - interactionSource = interactionSource, - placeholder = { Text(context.getString(R.string.search_services), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } } }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - keyboardOptions = FocusUtils.searchKeyboardOptions, - keyboardActions = FocusUtils.clearFocusKeyboardActions() - ) - } -} - -@Composable -private fun FilterChipsRow( - selectedFilter: ServiceFilter, - serviceCounts: Map, - onFilterSelected: (ServiceFilter) -> Unit -) { - val context = LocalContext.current + val maskedColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) val filters = listOf( - Triple(ServiceFilter.RUNNING, R.string.running, Color(0xFF4CAF50)), - Triple(ServiceFilter.ENABLED, R.string.enabled_legend, Color(0xFFFFCA28)), - Triple(ServiceFilter.DISABLED, R.string.disabled_legend, Color(0xFFEF5350)), - Triple(ServiceFilter.ABNORMAL, R.string.abnormal_legend, Color(0xFFFF7043)), - Triple(ServiceFilter.STATIC, R.string.static_legend, Color(0xFF607D8B)), - Triple(ServiceFilter.MASKED, R.string.masked_legend, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), - Triple(ServiceFilter.ALL, R.string.all_legend, null) + InitServiceFilterChip("RUNNING", R.string.running, Color(0xFF4CAF50), R.string.no_running_services) { it.isRunning && it.isEnabled && !it.isMasked }, + InitServiceFilterChip("ENABLED", R.string.enabled_legend, Color(0xFFFFCA28), R.string.no_enabled_services) { it.isEnabled && !it.isRunning && !it.isMasked }, + InitServiceFilterChip("DISABLED", R.string.disabled_legend, Color(0xFFEF5350), R.string.no_disabled_services) { !it.isEnabled && !it.isRunning && !it.isMasked && !it.isStatic }, + InitServiceFilterChip("ABNORMAL", R.string.abnormal_legend, Color(0xFFFF7043), R.string.no_abnormal_services) { it.isRunning && !it.isEnabled && !it.isStatic && !it.isMasked }, + InitServiceFilterChip("STATIC", R.string.static_legend, Color(0xFF607D8B), R.string.no_static_services) { it.isStatic }, + InitServiceFilterChip("MASKED", R.string.masked_legend, maskedColor, R.string.no_masked_services) { it.isMasked }, + InitServiceFilterChip("ALL", R.string.all_legend, null, R.string.no_services_found) { true }, ) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - filters.forEach { (filter, labelRes, dotColor) -> - val count = serviceCounts[filter] ?: 0 - val isSelected = selectedFilter == filter - - FilterChip( - selected = isSelected, - onClick = { onFilterSelected(filter) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (dotColor != null) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = dotColor) {} - } - Text("${context.getString(labelRes)} ($count)", style = MaterialTheme.typography.labelLarge) - } - }, - shape = RoundedCornerShape(12.dp), - colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer), - border = FilterChipDefaults.filterChipBorder(selected = isSelected, enabled = true, - borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - selectedBorderColor = MaterialTheme.colorScheme.primary) - ) - } - } -} - -@Composable -private fun ServiceCard( - service: ServiceInfo, - containerName: String, - onAction: (String, suspend () -> ContainerSystemdManager.CommandResult) -> Unit -) { - val context = LocalContext.current - var showMenu by remember { mutableStateOf(false) } - - val statusColor = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> Color(0xFF4CAF50) - ServiceStatus.ENABLED_STOPPED -> Color(0xFFFFCA28) - ServiceStatus.DISABLED_STOPPED -> Color(0xFFEF5350) - ServiceStatus.STATIC -> Color(0xFF607D8B) - ServiceStatus.ABNORMAL -> Color(0xFFFF7043) - ServiceStatus.MASKED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - } - - Surface( - modifier = Modifier.fillMaxWidth().animateContentSize(AnimationUtils.mediumSpec()), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Column { - // Premium Header Row (Symmetric) - Fixed Height 32dp + Padding - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp).height(32.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = service.name, - style = MaterialTheme.typography.titleMedium.copy( - fontFamily = JetBrainsMono, - fontSize = if (service.name.length > 25) 13.sp else 16.sp - ), - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis - ) - - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> context.getString(R.string.running) - ServiceStatus.ENABLED_STOPPED -> context.getString(R.string.enabled_legend) - ServiceStatus.STATIC -> context.getString(R.string.static_legend) - ServiceStatus.ABNORMAL -> context.getString(R.string.abnormal_legend) - ServiceStatus.DISABLED_STOPPED -> context.getString(R.string.disabled_legend) - ServiceStatus.MASKED -> context.getString(R.string.masked_legend) - }.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (service.description.isNotEmpty()) { - Text( - text = service.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { - if (service.isMasked) { - Surface(onClick = { onAction(context.getString(R.string.unmask)) { ContainerSystemdManager.unmaskService(containerName, service.name) } }, modifier = Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Default.LockOpen, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary); Text(context.getString(R.string.unmask), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) } - } - } - } else { - val btnColor = if (service.isRunning) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - val accentColor = if (service.isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - Surface(onClick = { if (service.isRunning) onAction(context.getString(R.string.stop)) { ContainerSystemdManager.stopService(containerName, service.name) } else onAction(context.getString(R.string.start)) { ContainerSystemdManager.startService(containerName, service.name) } }, modifier = Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(16.dp), color = btnColor, border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f))) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(if (service.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, null, Modifier.size(18.dp), tint = accentColor); Text(if (service.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = accentColor) } } - } - if (!service.isStatic) { - Surface(onClick = { if (service.isEnabled) onAction(context.getString(R.string.disable_service)) { ContainerSystemdManager.disableService(containerName, service.name) } else onAction(context.getString(R.string.enable_service)) { ContainerSystemdManager.enableService(containerName, service.name) } }, modifier = Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(if (service.isEnabled) Icons.Default.Block else Icons.Default.CheckCircle, null, Modifier.size(18.dp)); Text(if (service.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) } } - } - } - Box { - Surface(onClick = { showMenu = true }, modifier = Modifier.size(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } } - val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f - val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer - MaterialTheme( - colorScheme = MaterialTheme.colorScheme.copy( - surface = dropdownColor, - surfaceContainer = dropdownColor, - surfaceTint = Color.Transparent - ), - shapes = MaterialTheme.shapes.copy( - extraSmall = RoundedCornerShape(20.dp) - ) - ) { - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - modifier = Modifier - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), - shape = RoundedCornerShape(20.dp) - ) - ) { - if (service.isRunning) { DropdownMenuItem(text = { Text(context.getString(R.string.restart_service)) }, leadingIcon = { Icon(Icons.Default.Refresh, null) }, onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerSystemdManager.restartService(containerName, service.name) } }) } - DropdownMenuItem(text = { Text(context.getString(R.string.mask_service)) }, leadingIcon = { Icon(Icons.Default.Lock, null) }, onClick = { showMenu = false; onAction(context.getString(R.string.mask_service)) { ContainerSystemdManager.maskService(containerName, service.name) } }) - } - } - } - } - } - } - } - } - } -} - -@Composable -private fun SystemdNotAvailable() { - val context = LocalContext.current - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(32.dp)) { - Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f))) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Warning, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) } } - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(context.getString(R.string.init_system_not_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(context.getString(R.string.init_system_not_available_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } - } - } -} - -@Composable -private fun EmptyServicesState(filter: ServiceFilter, modifier: Modifier = Modifier) { - val context = LocalContext.current - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - Icon(Icons.Default.SearchOff, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) - Text(text = when (filter) { - ServiceFilter.RUNNING -> context.getString(R.string.no_running_services) - ServiceFilter.ENABLED -> context.getString(R.string.no_enabled_services) - ServiceFilter.DISABLED -> context.getString(R.string.no_disabled_services) - ServiceFilter.STATIC -> context.getString(R.string.no_static_services) - ServiceFilter.ABNORMAL -> context.getString(R.string.no_abnormal_services) - ServiceFilter.MASKED -> context.getString(R.string.no_masked_services) - ServiceFilter.ALL -> context.getString(R.string.no_services_found) - }, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) - } - } + InitServiceScreen( + containerName = containerName, + titleRes = R.string.systemd_services, + onNavigateBack = onNavigateBack, + isAvailable = { cn -> ContainerSystemdManager.isSystemdAvailable(cn) }, + fetchRows = { cn -> ContainerSystemdManager.getAllServices(cn).map { it.toRow(cn) } }, + filters = filters, + defaultFilterId = "RUNNING", + ) } From 139349ec220f1b02c63d78d0e57cfbadb91cffb4 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:26 +0530 Subject: [PATCH 168/211] refactor: delete dead StatusBarStyle.kt ui/component/StatusBarStyle.kt duplicated Theme.kt's private SystemBarStyle and was never referenced anywhere (grep-confirmed). Theme.kt already uses its own copy. Signed-off-by: ravindu644 --- .../app/ui/component/StatusBarStyle.kt | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/StatusBarStyle.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusBarStyle.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusBarStyle.kt deleted file mode 100644 index fe927f33..00000000 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusBarStyle.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.droidspaces.app.ui.component - -import androidx.activity.ComponentActivity -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext - -@Composable -fun StatusBarStyle( - darkMode: Boolean = isSystemInDarkTheme(), - statusBarScrim: Color = Color.Transparent, - navigationBarScrim: Color = Color.Transparent, -) { - val context = LocalContext.current - val activity = context as? ComponentActivity ?: return - - SideEffect { - activity.enableEdgeToEdge( - statusBarStyle = androidx.activity.SystemBarStyle.auto( - statusBarScrim.toArgb(), - statusBarScrim.toArgb(), - ) { darkMode }, - navigationBarStyle = when { - darkMode -> androidx.activity.SystemBarStyle.dark( - navigationBarScrim.toArgb(), - ) - else -> androidx.activity.SystemBarStyle.light( - navigationBarScrim.toArgb(), - navigationBarScrim.toArgb(), - ) - }, - ) - } -} - From 1ba1b12c4dfc17cd3a4e3ecc920b8fa3d5462cb4 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:26 +0530 Subject: [PATCH 169/211] refactor: extract shared PrimaryActionBottomBar across wizard screens The Surface -> divider -> rounded clickable action-button bottom bar was copy-pasted across 7 screens. Extract PrimaryActionBottomBar(label, icon, onClick, enabled, ...) with params for the per-screen variations (padding, divider alpha, label size, bar color, constant-color-when-disabled) and a secondaryAction slot for RootCheck's Skip button. Migrated Welcome, RootCheck, Installation, ContainerName, SparseImageConfig and InstallationSummary. AutoBootPriority keeps its bespoke 3-state animated-color bar (does not fit the enabled/disabled model). Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../ui/component/PrimaryActionBottomBar.kt | 107 ++++++++++++++++++ .../app/ui/screen/ContainerNameScreen.kt | 74 +++--------- .../app/ui/screen/InstallationScreen.kt | 52 ++------- .../ui/screen/InstallationSummaryScreen.kt | 58 ++-------- .../app/ui/screen/RootCheckScreen.kt | 104 ++++++----------- .../app/ui/screen/SparseImageConfigScreen.kt | 66 +++-------- .../app/ui/screen/WelcomeScreen.kt | 42 ++----- 7 files changed, 196 insertions(+), 307 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/PrimaryActionBottomBar.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrimaryActionBottomBar.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrimaryActionBottomBar.kt new file mode 100644 index 00000000..9a0d8cf7 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrimaryActionBottomBar.kt @@ -0,0 +1,107 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp + +/** + * Shared "primary call-to-action" bottom bar: a full-width [Surface] with a top + * divider and a rounded primary action button (icon + label). Replaces the same + * Surface -> Column -> HorizontalDivider -> clickable Surface -> Box -> Row block + * that was copy-pasted across the wizard/detail screens (DT-4). + * + * The varying bits are parameters so each screen keeps its exact look: + * [enabled] toggles the disabled colors, [secondaryAction] renders an extra row + * (e.g. RootCheck's Skip), and [containerColor]/[disabledContainerColor] let a + * screen keep the button a constant color regardless of [enabled]. + */ +@Composable +fun PrimaryActionBottomBar( + label: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + containerColor: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + barColor: Color = MaterialTheme.colorScheme.surfaceContainer, + dividerAlpha: Float = 0.25f, + horizontalPadding: Dp = 24.dp, + labelFontSize: TextUnit = TextUnit.Unspecified, + secondaryAction: (@Composable ColumnScope.() -> Unit)? = null, +) { + val btnShape = RoundedCornerShape(20.dp) + val bg = if (enabled) containerColor else disabledContainerColor + val fg = if (enabled) contentColor else disabledContentColor + Surface( + modifier = modifier.fillMaxWidth(), + color = barColor, + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = dividerAlpha), + thickness = 1.dp + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontalPadding) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(btnShape) + .clickable(enabled = enabled, onClick = onClick), + shape = btnShape, + color = bg, + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = fg) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + fontSize = labelFontSize, + color = fg + ) + } + } + } + secondaryAction?.invoke(this) + } + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt index 09ffe99e..d098c164 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt @@ -1,4 +1,6 @@ package com.droidspaces.app.ui.screen + +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.* @@ -99,64 +101,24 @@ fun ContainerNameScreen( ) }, bottomBar = { - val btnShape = RoundedCornerShape(20.dp) val isNextValid = containerName.isNotBlank() && nameError == null && hostnameError == null - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable( - enabled = isNextValid, - onClick = { - clearFocus() - val normalizedName = ValidationUtils.normalizeContainerName(containerName) - val nameResult = ValidationUtils.validateContainerName(normalizedName, context) - val hostnameResult = ValidationUtils.validateHostname(hostname.ifEmpty { ValidationUtils.sanitizeHostname(normalizedName) }, context) - if (!nameResult.isError && !hostnameResult.isError) { - onNext(normalizedName, hostname.ifEmpty { ValidationUtils.sanitizeHostname(normalizedName) }) - } else { - nameError = nameResult.errorMessage - hostnameError = hostnameResult.errorMessage - } - }, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } - ), - shape = btnShape, - color = if (isNextValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (isNextValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - Text( - context.getString(R.string.next_configuration), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isNextValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } + PrimaryActionBottomBar( + label = context.getString(R.string.next_configuration), + icon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = { + clearFocus() + val normalizedName = ValidationUtils.normalizeContainerName(containerName) + val nameResult = ValidationUtils.validateContainerName(normalizedName, context) + val hostnameResult = ValidationUtils.validateHostname(hostname.ifEmpty { ValidationUtils.sanitizeHostname(normalizedName) }, context) + if (!nameResult.isError && !hostnameResult.isError) { + onNext(normalizedName, hostname.ifEmpty { ValidationUtils.sanitizeHostname(normalizedName) }) + } else { + nameError = nameResult.errorMessage + hostnameError = hostnameResult.errorMessage } - } - } + }, + enabled = isNextValid + ) } ) { innerPadding -> Box( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt index 8aa66ac8..ee796e3c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt @@ -1,4 +1,6 @@ package com.droidspaces.app.ui.screen + +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color import androidx.activity.compose.BackHandler @@ -170,51 +172,11 @@ fun InstallationScreen( // Show the Continue button once the work is finished, whether it // succeeded or failed - it is the only accepted way off this screen. if (isSuccess || errorMessage != null) { - val btnShape = RoundedCornerShape(20.dp) - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable( - onClick = onInstallationComplete, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } - ), - shape = btnShape, - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = if (isSuccess) Icons.Default.Check else Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = context.getString(R.string.continue_button), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } - } + PrimaryActionBottomBar( + label = context.getString(R.string.continue_button), + icon = if (isSuccess) Icons.Default.Check else Icons.AutoMirrored.Filled.ArrowForward, + onClick = onInstallationComplete + ) } } ) { innerPadding -> diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index c5acf7c0..91a8ec6c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -1,4 +1,6 @@ package com.droidspaces.app.ui.screen + +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color import androidx.compose.foundation.BorderStroke @@ -50,56 +52,12 @@ fun InstallationSummaryScreen( ) }, bottomBar = { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.98f), - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable( - onClick = onInstall, - indication = rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } - ), - shape = btnShape, - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box( - modifier = Modifier.padding(vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Icons.Default.InstallMobile, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - Text( - stringResource(R.string.install_container), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } - } + PrimaryActionBottomBar( + label = stringResource(R.string.install_container), + icon = Icons.Default.InstallMobile, + onClick = onInstall, + barColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.98f) + ) } ) { innerPadding -> Column( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RootCheckScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RootCheckScreen.kt index 5348a3b6..b1d359be 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RootCheckScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RootCheckScreen.kt @@ -1,5 +1,7 @@ package com.droidspaces.app.ui.screen +import com.droidspaces.app.ui.component.PrimaryActionBottomBar + import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import kotlinx.coroutines.delay @@ -72,90 +74,50 @@ fun RootCheckScreen( Scaffold( containerColor = Color.Transparent, bottomBar = { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), thickness = 1.dp) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - .navigationBarsPadding() - .animateContentSize(animationSpec = AnimationUtils.fastSpec()), - verticalArrangement = Arrangement.spacedBy(12.dp) + PrimaryActionBottomBar( + label = context.getString( + if (currentRootStatus == RootStatus.Granted) R.string.continue_button + else if (isChecking) R.string.checking_root + else R.string.check_root_access + ), + icon = if (currentRootStatus == RootStatus.Granted) Icons.Default.CheckCircle else Icons.Default.Shield, + onClick = { + if (currentRootStatus == RootStatus.Granted) onNavigateToInstallation() + else checkRoot() + }, + enabled = !isChecking && currentRootStatus != RootStatus.Checking, + disabledContainerColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.onPrimary, + horizontalPadding = 20.dp, + labelFontSize = 16.sp, + secondaryAction = { + androidx.compose.animation.AnimatedVisibility( + visible = currentRootStatus == RootStatus.Denied && hasCheckedRoot, + enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), + exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut() ) { - // Main Action Button (Check Root / Continue) Surface( modifier = Modifier .fillMaxWidth() .clip(btnShape) - .clickable( - enabled = !isChecking && currentRootStatus != RootStatus.Checking, - onClick = { - if (currentRootStatus == RootStatus.Granted) onNavigateToInstallation() - else checkRoot() - } - ), + .clickable(onClick = onSkip), shape = btnShape, - color = MaterialTheme.colorScheme.primary, + color = Color.Transparent, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = if (currentRootStatus == RootStatus.Granted) Icons.Default.CheckCircle else Icons.Default.Shield, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = context.getString( - if (currentRootStatus == RootStatus.Granted) R.string.continue_button - else if (isChecking) R.string.checking_root - else R.string.check_root_access - ), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - - // Skip button - only if Denied and has checked - androidx.compose.animation.AnimatedVisibility( - visible = currentRootStatus == RootStatus.Denied && hasCheckedRoot, - enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut() - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(btnShape) - .clickable(onClick = onSkip), - shape = btnShape, - color = Color.Transparent, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Text( - text = context.getString(R.string.skip), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = context.getString(R.string.skip), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } } - } + ) } ) { innerPadding -> Column( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt index a07b3c36..ff7a2d4c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt @@ -1,4 +1,6 @@ package com.droidspaces.app.ui.screen + +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.consumeWindowInsets @@ -62,59 +64,19 @@ fun SparseImageConfigScreen( ) }, bottomBar = { - val btnShape = RoundedCornerShape(20.dp) - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable( - enabled = isNextEnabled, - onClick = { - if (useSparseImage) { - val s = sizeGB.toIntOrNull() - if (s != null && s in 4..512) onNext(true, s) - } else { - onNext(false, 8) - } - }, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } - ), - shape = btnShape, - color = if (isNextEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (isNextEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - Text( - context.getString(R.string.next_configuration), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isNextEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } + PrimaryActionBottomBar( + label = context.getString(R.string.next_configuration), + icon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = { + if (useSparseImage) { + val s = sizeGB.toIntOrNull() + if (s != null && s in 4..512) onNext(true, s) + } else { + onNext(false, 8) } - } - } + }, + enabled = isNextEnabled + ) } ) { innerPadding -> Column( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt index 9796f24b..39ac02e9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.droidspaces.app.R +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import com.droidspaces.app.util.AnimationUtils import kotlinx.coroutines.delay @@ -63,39 +64,14 @@ fun WelcomeScreen(onNavigateToRootCheck: () -> Unit) { Scaffold( containerColor = Color.Transparent, bottomBar = { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), thickness = 1.dp) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable(onClick = onNavigateToRootCheck), - shape = btnShape, - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.RocketLaunch, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onPrimary) - Text( - text = context.getString(R.string.get_started), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } - } + PrimaryActionBottomBar( + label = context.getString(R.string.get_started), + icon = Icons.Default.RocketLaunch, + onClick = onNavigateToRootCheck, + dividerAlpha = 0.4f, + horizontalPadding = 20.dp, + labelFontSize = 16.sp + ) } ) { innerPadding -> Column( From ec777f811518f64753ad42b71d49823fb53d0636 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:26 +0530 Subject: [PATCH 170/211] refactor: extract shared DialogFooterRow for dialog Cancel/Confirm rows The identical two-Surface Cancel/Confirm footer row was copy-pasted across the app's dialogs. Extract DialogFooterRow(dismissLabel, confirmLabel, onDismiss, onConfirm, confirmEnabled, cancelBorderAlpha, textFontWeight) and migrate the six primary-confirm dialogs: AddPortForwardDialog, GatewayConfigureDialog, AddUpstreamDialog, EnvironmentVariablesDialog, FilePickerDialog and the repo manager. PrivilegedModeDialog/HardwareAccessDialog keep bespoke error-colored confirms (better served by a DangerousActionConfirmScaffold). Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/component/DialogFooterRow.kt | 67 +++++++++++++++++++ .../component/EnvironmentVariablesDialog.kt | 37 ++-------- .../app/ui/component/FilePickerDialog.kt | 33 ++------- .../ui/component/GatewaySettingsSection.kt | 41 ++---------- .../app/ui/component/PortForwardingList.kt | 43 +++--------- .../app/ui/component/RootfsRepoSheet.kt | 38 ++++------- .../app/ui/component/UpstreamInterfaceList.kt | 39 +++-------- 7 files changed, 116 insertions(+), 182 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt new file mode 100644 index 00000000..bb5753ed --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt @@ -0,0 +1,67 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Shared "Cancel / Confirm" dialog footer: two equal-weight rounded [Surface] + * buttons. Replaces the identical two-Surface footer that was copy-pasted across + * the app's dialogs (DT-5). The confirm button dims and disables via + * [confirmEnabled]; [cancelBorderAlpha] and [textFontWeight] keep the small + * per-dialog cosmetic differences. + */ +@Composable +fun DialogFooterRow( + dismissLabel: String, + confirmLabel: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + confirmEnabled: Boolean = true, + cancelBorderAlpha: Float = 0.4f, + textFontWeight: FontWeight = FontWeight.SemiBold, +) { + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = cancelBorderAlpha)), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text(dismissLabel, style = MaterialTheme.typography.labelLarge, fontWeight = textFontWeight) + } + } + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(enabled = confirmEnabled, onClick = onConfirm), + shape = RoundedCornerShape(14.dp), + color = if (confirmEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text( + confirmLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = textFontWeight, + color = if (confirmEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt index f39c234f..448a3a8f 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt @@ -214,36 +214,13 @@ fun EnvironmentVariablesDialog( Spacer(modifier = Modifier.height(16.dp)) // Action buttons - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - onClick = { onConfirm(serializeEnvVars(vars)) } - ), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - confirmLabel ?: context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = confirmLabel ?: context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = { onConfirm(serializeEnvVars(vars)) }, + cancelBorderAlpha = 0.35f + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt index e0099ea3..dc725704 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt @@ -297,32 +297,13 @@ fun FilePickerDialog( Spacer(modifier = Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { - clearFocus() - onConfirm(currentPath) - }), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.select_folder), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimary) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.select_folder), + onDismiss = onDismiss, + onConfirm = { clearFocus(); onConfirm(currentPath) }, + textFontWeight = FontWeight.Bold + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt index 0b42eeac..2c842484 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt @@ -204,40 +204,13 @@ private fun GatewayConfigureDialog( error = errs.bridge ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = advancedValid, - onClick = { onConfirm(net, iface, bridge) } - ), - shape = RoundedCornerShape(14.dp), - color = if (advancedValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (advancedValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = { onConfirm(net, iface, bridge) }, + confirmEnabled = advancedValid + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt index b5b123b9..2e7e307f 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt @@ -249,41 +249,14 @@ private fun AddPortForwardDialog( ) } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = isFormValid, - onClick = { - if (isFormValid) { - onConfirm(PortForward(hostPort.trim(), if (containerPort.isBlank()) null else containerPort.trim(), proto)) - } - } - ), - shape = RoundedCornerShape(14.dp), - color = if (isFormValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.add), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isFormValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.add), + onDismiss = onDismiss, + onConfirm = { onConfirm(PortForward(hostPort.trim(), if (containerPort.isBlank()) null else containerPort.trim(), proto)) }, + confirmEnabled = isFormValid, + cancelBorderAlpha = 0.35f + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt index e95e3582..ec6b6f22 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt @@ -847,32 +847,18 @@ private fun RepoManagerDialog( Spacer(Modifier.height(16.dp)) // Single footer row: Close / Save - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Box(Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable { - val currentUrls = repos.map { it.second }.toSet() - val toRemove = originalUrls.filter { it !in currentUrls } - val toAdd = repos.filter { it.second !in originalUrls } - onSave(toAdd, toRemove) - }, - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.primary - ) { - Box(Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.ok), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimary) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = { + val currentUrls = repos.map { it.second }.toSet() + val toRemove = originalUrls.filter { it !in currentUrls } + val toAdd = repos.filter { it.second !in originalUrls } + onSave(toAdd, toRemove) + }, + cancelBorderAlpha = 0.35f + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt index 60e8323f..3ea51f11 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt @@ -225,37 +225,14 @@ private fun AddUpstreamDialog( ) ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = customIface.isNotBlank() && selectedInterfaces.size < 8, - onClick = { onAdd(customIface.trim()) } - ), - shape = RoundedCornerShape(14.dp), - color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.add), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.add), + onDismiss = onDismiss, + onConfirm = { onAdd(customIface.trim()) }, + confirmEnabled = customIface.isNotBlank() && selectedInterfaces.size < 8, + cancelBorderAlpha = 0.35f + ) } } } From 21714f348fd381bea96b8d8afa5c0505bce1a20b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:27 +0530 Subject: [PATCH 171/211] refactor: merge ToggleCard/SettingsRowCard into shared SettingsCard ToggleCard and SettingsRowCard built the identical Surface -> Row -> (icon + title/subtitle Column) shell. Extract it into SettingsCard(title, onClick, icon, painter, subtitleContent slot, trailing slot). ToggleCard becomes a wrapper passing a Switch as trailing; SettingsRowCard a wrapper passing its description/subtitle lines. Public signatures unchanged, so all callers are untouched. Fixes the pre-existing description-alpha drift by construction. Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/component/SettingsCard.kt | 92 ++++++++++++++++++ .../app/ui/component/SettingsRowCard.kt | 93 ++++++------------- .../app/ui/component/ToggleCard.kt | 82 +++++----------- 3 files changed, 139 insertions(+), 128 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsCard.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsCard.kt new file mode 100644 index 00000000..0eddcb9d --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsCard.kt @@ -0,0 +1,92 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Shared settings-card shell: a rounded `surfaceContainerHigh` [Surface] with a + * leading icon, a title, an optional [subtitleContent] block and an optional + * [trailing] slot. [ToggleCard] and [SettingsRowCard] are thin wrappers over + * this (DT-9) — the two previously duplicated this whole layout. + */ +@Composable +fun SettingsCard( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: ImageVector? = null, + painter: Painter? = null, + subtitleContent: (@Composable ColumnScope.() -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + val alpha = if (enabled) 1f else 0.5f + Surface( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.weight(1f) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) + ) + } else if (painter != null) { + Icon( + painter = painter, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + subtitleContent?.invoke(this) + } + } + trailing?.invoke() + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt index 432ebc68..f6f8ba1a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt @@ -1,19 +1,16 @@ package com.droidspaces.app.ui.component -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp /** - * Reusable settings row card component for non-toggle options. - * Provides consistent styling with ToggleCard but for clickable actions. + * Non-toggle settings row: a clickable [SettingsCard] with an optional + * [description] line and an accent [subtitle] line. */ @Composable fun SettingsRowCard( @@ -27,67 +24,29 @@ fun SettingsRowCard( description: String? = null ) { val alpha = if (enabled) 1f else 0.5f - Surface( - modifier = modifier.fillMaxWidth(), + SettingsCard( + title = title, onClick = onClick, + modifier = modifier, enabled = enabled, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.weight(1f) - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - ) - } else if (painter != null) { - Icon( - painter = painter, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - if (description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f * alpha) - ) - } - if (subtitle.isNotBlank()) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f * alpha), - fontWeight = FontWeight.Bold - ) - } - } + icon = icon, + painter = painter, + subtitleContent = { + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f * alpha) + ) + } + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f * alpha), + fontWeight = FontWeight.Bold + ) } } - } + ) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt index d18a11e6..0aee8ff0 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt @@ -1,20 +1,17 @@ package com.droidspaces.app.ui.component -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.vector.ImageVector /** - * Reusable toggle card component that eliminates duplication in ContainerConfigScreen. - * Provides consistent styling and behavior for all toggle options. + * Toggle option row: a [SettingsCard] with a trailing [Switch]. Tapping the card + * toggles the switch. */ @Composable fun ToggleCard( @@ -28,57 +25,21 @@ fun ToggleCard( painter: Painter? = null ) { val alpha = if (enabled) 1f else 0.5f - Surface( - modifier = modifier.fillMaxWidth(), + SettingsCard( + title = title, onClick = { if (enabled) onCheckedChange(!checked) }, + modifier = modifier, enabled = enabled, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.weight(1f) - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - ) - } else if (painter != null) { - Icon( - painter = painter, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f * alpha) - ) - } - } + icon = icon, + painter = painter, + subtitleContent = { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f * alpha) + ) + }, + trailing = { Switch( checked = checked, onCheckedChange = null, @@ -92,6 +53,5 @@ fun ToggleCard( ) ) } - } + ) } - From e72f8469ce55896f2b032cd1a6873e0f8feac1e8 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:27 +0530 Subject: [PATCH 172/211] refactor: extract DsTextFieldDefaults for duplicated OutlinedTextField colors The same OutlinedTextFieldDefaults.colors(...) block was copy-pasted across 13 call sites in two visual variants. Extract both into DsTextFieldDefaults (colors() = surfaceContainerLow/surfaceVariant; surfaceColors() = translucent surface) and route every matching site through it. Both variants are preserved verbatim, so nothing changes on screen; the unique DsDropdown (0.35 border) and the error-colored field are intentionally left as-is. Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/component/ContainerConfigForm.kt | 7 +--- .../app/ui/component/DsTextFieldDefaults.kt | 34 +++++++++++++++++++ .../component/EnvironmentVariablesDialog.kt | 7 +--- .../app/ui/component/FilePickerDialog.kt | 7 +--- .../ui/component/GatewaySettingsSection.kt | 7 +--- .../app/ui/component/HardwareAccessDialog.kt | 7 +--- .../app/ui/component/PortForwardingList.kt | 14 ++------ .../app/ui/component/PrivilegedModeDialog.kt | 7 +--- .../app/ui/component/RootfsRepoSheet.kt | 7 +--- .../app/ui/component/UpstreamInterfaceList.kt | 7 +--- .../app/ui/screen/ContainersScreen.kt | 9 ++--- .../app/ui/screen/EditContainerScreen.kt | 9 ++--- .../app/ui/screen/SparseImageConfigScreen.kt | 9 ++--- 13 files changed, 53 insertions(+), 78 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/DsTextFieldDefaults.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index e4d18a21..94ae20ef 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -124,12 +124,7 @@ fun ContainerConfigForm( var showHwAccessDialog by remember { mutableStateOf(false) } val modernFieldShape = RoundedCornerShape(16.dp) - val modernFieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + val modernFieldColors = DsTextFieldDefaults.colors() if (showFilePicker) { FilePickerDialog( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsTextFieldDefaults.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsTextFieldDefaults.kt new file mode 100644 index 00000000..beff820d --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsTextFieldDefaults.kt @@ -0,0 +1,34 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.runtime.Composable + +/** + * Shared OutlinedTextField color sets. These exact color blocks were previously + * copy-pasted across a dozen call sites. Two visual variants exist in the app — + * both are preserved verbatim so nothing changes on screen: + * - [colors] container tint = surfaceContainerLow / surfaceVariant (forms, screens, dropdowns) + * - [surfaceColors] container tint = translucent surface (dialogs) + */ +object DsTextFieldDefaults { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun colors(): TextFieldColors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun surfaceColors(): TextFieldColors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + ) +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt index 448a3a8f..abe5e58f 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt @@ -64,12 +64,7 @@ fun EnvironmentVariablesDialog( } val fieldShape = RoundedCornerShape(14.dp) - val fieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + val fieldColors = DsTextFieldDefaults.surfaceColors() Dialog( onDismissRequest = onDismiss, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt index dc725704..683aec95 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt @@ -216,12 +216,7 @@ fun FilePickerDialog( fontFamily = JetBrainsMono ), shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + colors = DsTextFieldDefaults.surfaceColors() ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt index 2c842484..83faccc9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt @@ -240,12 +240,7 @@ private fun ExplainedField( singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + colors = DsTextFieldDefaults.colors() ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt index 0b060738..55f5312f 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt @@ -91,12 +91,7 @@ fun HardwareAccessDialog( singleLine = true, isError = confirmText.isNotEmpty() && !isConfirmed, shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + colors = DsTextFieldDefaults.surfaceColors() ) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt index 2e7e307f..d043589f 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt @@ -214,12 +214,7 @@ private fun AddPortForwardDialog( isError = hostError != null || widthError != null || overlapError != null, supportingText = { Text(hostError ?: widthError ?: overlapError ?: "") }, shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + colors = DsTextFieldDefaults.surfaceColors() ) OutlinedTextField( @@ -232,12 +227,7 @@ private fun AddPortForwardDialog( isError = containerError != null || widthError != null || overlapError != null, supportingText = { Text(containerError ?: widthError ?: overlapError ?: context.getString(R.string.optional_symmetric_hint)) }, shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + colors = DsTextFieldDefaults.surfaceColors() ) DsDropdown( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt index 92bc321b..17116853 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt @@ -186,12 +186,7 @@ fun PrivilegedModeDialog( singleLine = true, isError = confirmText.isNotEmpty() && !isConfirmed, shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + colors = DsTextFieldDefaults.surfaceColors() ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt index ec6b6f22..3921ee08 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt @@ -668,12 +668,7 @@ private fun RepoManagerDialog( var urlError by remember { mutableStateOf("") } val fieldShape = RoundedCornerShape(14.dp) - val fieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + val fieldColors = DsTextFieldDefaults.surfaceColors() fun tryAdd() { val n = newName.trim(); val u = newUrl.trim() diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt index 3ea51f11..143ce866 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt @@ -217,12 +217,7 @@ private fun AddUpstreamDialog( singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + colors = DsTextFieldDefaults.colors() ) DialogFooterRow( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index 4dfdc45d..022edb24 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -1,5 +1,7 @@ package com.droidspaces.app.ui.screen +import com.droidspaces.app.ui.component.DsTextFieldDefaults + import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -909,12 +911,7 @@ private fun SparseSizeDialog( modifier = Modifier.fillMaxWidth(), singleLine = true, shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), + colors = DsTextFieldDefaults.colors(), isError = !isValid && sizeText.isNotEmpty(), supportingText = { if (!isValid && sizeText.isNotEmpty()) Text(context.getString(R.string.enter_size_between_4_512_gb)) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index 858b780e..13774042 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -1,5 +1,7 @@ package com.droidspaces.app.ui.screen +import com.droidspaces.app.ui.component.DsTextFieldDefaults + import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke @@ -281,12 +283,7 @@ fun EditContainerScreen( } val hostnameFieldShape = RoundedCornerShape(16.dp) - val hostnameFieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + val hostnameFieldColors = DsTextFieldDefaults.colors() OutlinedTextField( value = hostname, onValueChange = { hostname = it }, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt index ff7a2d4c..59075e31 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt @@ -1,5 +1,7 @@ package com.droidspaces.app.ui.screen +import com.droidspaces.app.ui.component.DsTextFieldDefaults + import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -42,12 +44,7 @@ fun SparseImageConfigScreen( var sizeError by remember { mutableStateOf(null) } val fieldShape = RoundedCornerShape(16.dp) - val fieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + val fieldColors = DsTextFieldDefaults.colors() val isNextEnabled = !useSparseImage || (sizeGB.toIntOrNull()?.let { it in 4..512 } == true) From f4c695abe3b2d5df7e86e63c8e91c5009662b84d Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:27 +0530 Subject: [PATCH 173/211] refactor: collapse GatewaySettingsSection params into GatewayConfig GatewaySettingsSection took four separate (value, onChange) pairs for the gateway container/net/iface/bridge. Group them into a GatewayConfig data class plus one onConfigChange, mirroring how errors are already grouped as GatewayErrors. Its only caller (ContainerConfigForm) is updated in the same commit and preserves the original focus behavior (clear focus only on gateway-container selection). Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/component/ContainerConfigForm.kt | 15 ++++---- .../ui/component/GatewaySettingsSection.kt | 34 +++++++++---------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index 94ae20ef..b9c6989e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -277,14 +277,13 @@ fun ContainerConfigForm( GatewaySettingsSection( visible = state.netMode == "gateway", - gatewayContainer = state.gatewayContainer, - onGatewayContainerChange = { clearFocus(); onStateChange(state.copy(gatewayContainer = it)) }, - gatewayNet = state.gatewayNet, - onGatewayNetChange = { onStateChange(state.copy(gatewayNet = it)) }, - gatewayIface = state.gatewayIface, - onGatewayIfaceChange = { onStateChange(state.copy(gatewayIface = it)) }, - gatewayBridge = state.gatewayBridge, - onGatewayBridgeChange = { onStateChange(state.copy(gatewayBridge = it)) }, + config = GatewayConfig(state.gatewayContainer, state.gatewayNet, state.gatewayIface, state.gatewayBridge), + onConfigChange = { c -> + // Preserve original behavior: clear focus only on gateway-container + // selection (a dropdown pick), not while typing net/iface/bridge. + if (c.container != state.gatewayContainer) clearFocus() + onStateChange(state.copy(gatewayContainer = c.container, gatewayNet = c.net, gatewayIface = c.iface, gatewayBridge = c.bridge)) + }, selfName = selfName, installedContainers = installedContainers, errors = gatewayErrors diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt index 83faccc9..ea2b98c1 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt @@ -30,17 +30,19 @@ import com.droidspaces.app.util.ValidationUtils * opens a dialog for the optional interface / LAN-name / bridge overrides. All * validation errors come from [errors]; the caller blocks Save/Next on `errors.isValid`. */ +/** Grouped gateway-mode overrides (replaces 4 separate value/onChange param pairs). */ +data class GatewayConfig( + val container: String = "", + val net: String = "", + val iface: String = "", + val bridge: String = "", +) + @Composable fun GatewaySettingsSection( visible: Boolean, - gatewayContainer: String, - onGatewayContainerChange: (String) -> Unit, - gatewayNet: String, - onGatewayNetChange: (String) -> Unit, - gatewayIface: String, - onGatewayIfaceChange: (String) -> Unit, - gatewayBridge: String, - onGatewayBridgeChange: (String) -> Unit, + config: GatewayConfig, + onConfigChange: (GatewayConfig) -> Unit, selfName: String, installedContainers: List, errors: GatewayErrors @@ -75,10 +77,10 @@ fun GatewaySettingsSection( val noCandidates = candidates.isEmpty() DsDropdown( label = context.getString(R.string.gateway_container), - selected = gatewayContainer, + selected = config.container, options = candidates, displayName = { it }, - onSelect = onGatewayContainerChange, + onSelect = { onConfigChange(config.copy(container = it)) }, leadingIcon = Icons.Default.Router, isError = errors.container != null, supportingText = if (noCandidates) @@ -111,15 +113,13 @@ fun GatewaySettingsSection( if (showDialog) { GatewayConfigureDialog( selfName = selfName, - gatewayContainer = gatewayContainer, + gatewayContainer = config.container, installed = installedContainers, - initialNet = gatewayNet, - initialIface = gatewayIface, - initialBridge = gatewayBridge, + initialNet = config.net, + initialIface = config.iface, + initialBridge = config.bridge, onConfirm = { net, iface, bridge -> - onGatewayNetChange(net) - onGatewayIfaceChange(iface) - onGatewayBridgeChange(bridge) + onConfigChange(config.copy(net = net, iface = iface, bridge = bridge)) showDialog = false }, onDismiss = { showDialog = false } From 971bbc45fb43f1f07b4c7d0bea5af4abc204830a Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:27 +0530 Subject: [PATCH 174/211] refactor: extract shared StatusPill component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colored-dot + uppercase-label status pill was copy-pasted verbatim in ContainerCard, DroidspacesStatusCard and RootfsRepoSheet. Extract it into a StatusPill(label, color) composable; callers pass the already-formatted label (some uppercase it, some don't). Visuals unchanged. Note: the DsCard shell wrapper from the same finding is intentionally NOT extracted — the three card shells differ materially (combinedClickable+long-press vs Surface(onClick), and animateContentSize present/absent), so unifying them would change click semantics. Left as-is to avoid a behavior change. Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/component/ContainerCard.kt | 21 +------- .../app/ui/component/DroidspacesStatusCard.kt | 21 +------- .../app/ui/component/RootfsRepoSheet.kt | 21 +------- .../app/ui/component/StatusPill.kt | 50 +++++++++++++++++++ 4 files changed, 53 insertions(+), 60 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/StatusPill.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt index a349363c..ae365b1a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt @@ -126,26 +126,7 @@ fun ContainerCard( ContainerStatus.STOPPED -> context.getString(R.string.status_stopped) to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) } - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = statusText.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } + StatusPill(label = statusText.uppercase(), color = statusColor) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DroidspacesStatusCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DroidspacesStatusCard.kt index 442ac8ca..d388d57c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/DroidspacesStatusCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DroidspacesStatusCard.kt @@ -115,26 +115,7 @@ fun DroidspacesStatusCard( // Right side: mode pill if (isWorking && backendMode != null) { - Surface( - color = accentColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f)) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = accentColor) {} - Text( - text = backendMode!!.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = accentColor - ) - } - } + StatusPill(label = backendMode!!.uppercase(), color = accentColor) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt index 3921ee08..3b9f4d39 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt @@ -367,26 +367,7 @@ private fun RootfsAssetCard( } if (displayLabel.isNotEmpty()) { - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = displayLabel, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } + StatusPill(label = displayLabel, color = statusColor) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusPill.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusPill.kt new file mode 100644 index 00000000..7ba06420 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusPill.kt @@ -0,0 +1,50 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Small "status pill": a colored dot + bold label in a tinted rounded chip. + * Previously copy-pasted verbatim in ContainerCard, DroidspacesStatusCard and + * RootfsRepoSheet. The caller supplies the already-formatted [label] (some sites + * uppercase it, some don't) and the accent [color]. + */ +@Composable +fun StatusPill(label: String, color: Color, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + color = color.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, color.copy(alpha = 0.2f)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = color) {} + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Black, + letterSpacing = 0.5.sp, + color = color + ) + } + } +} From 3ce17df3b03443ffbbec355b3619bbf207b60fef Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:27 +0530 Subject: [PATCH 175/211] refactor: share warning card, confirm gate and footer across dangerous dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrivilegedModeDialog and HardwareAccessDialog duplicated the red disclaimer card, the 'type the phrase to confirm' gate, and the Cancel/OK footer. Extract DangerousWarningCard + ConfirmPhraseField, and route both footers through the existing DialogFooterRow (extended with confirmColor/confirmContentColor, defaulting to primary so the other six callers are unchanged). This also closes the DT-5 footer migration deferred for these two error-colored dialogs. PrivilegedModeDialog's behavior is preserved exactly, including the allOff path (clearing privileged mode is safe: enabled without the confirm phrase, primary color) and the tag-building onConfirm. A full DangerousActionConfirmScaffold was deliberately not created — Privileged's conditional gate + allOff logic would make it an 11-param over-abstraction; the shared pieces capture the dedup safely. Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../ui/component/DangerousActionConfirm.kt | 83 ++++++++++++ .../app/ui/component/DialogFooterRow.kt | 7 +- .../app/ui/component/HardwareAccessDialog.kt | 102 +++------------ .../app/ui/component/PrivilegedModeDialog.kt | 121 +++++------------- 4 files changed, 137 insertions(+), 176 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/component/DangerousActionConfirm.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DangerousActionConfirm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DangerousActionConfirm.kt new file mode 100644 index 00000000..55fe52da --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DangerousActionConfirm.kt @@ -0,0 +1,83 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.droidspaces.app.R + +/** + * The red "you are about to do something dangerous" disclaimer card shared by + * PrivilegedModeDialog and HardwareAccessDialog. + */ +@Composable +fun DangerousWarningCard(title: String, text: String, modifier: Modifier = Modifier) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), + modifier = modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +/** + * The "type the confirmation phrase to proceed" gate shared by the dangerous + * dialogs: an instruction line plus a validated text field. [isError] highlights + * a non-empty, not-yet-matching entry. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfirmPhraseField( + value: String, + onValueChange: (String) -> Unit, + isError: Boolean, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = context.getString(R.string.privileged_confirm_instruction), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(context.getString(R.string.i_understand_caps)) }, + singleLine = true, + isError = isError, + shape = RoundedCornerShape(14.dp), + colors = DsTextFieldDefaults.surfaceColors() + ) + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt index bb5753ed..901564a9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -35,6 +36,8 @@ fun DialogFooterRow( confirmEnabled: Boolean = true, cancelBorderAlpha: Float = 0.4f, textFontWeight: FontWeight = FontWeight.SemiBold, + confirmColor: Color = MaterialTheme.colorScheme.primary, + confirmContentColor: Color = MaterialTheme.colorScheme.onPrimary, ) { Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Surface( @@ -51,7 +54,7 @@ fun DialogFooterRow( Surface( modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(enabled = confirmEnabled, onClick = onConfirm), shape = RoundedCornerShape(14.dp), - color = if (confirmEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + color = if (confirmEnabled) confirmColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { @@ -59,7 +62,7 @@ fun DialogFooterRow( confirmLabel, style = MaterialTheme.typography.labelLarge, fontWeight = textFontWeight, - color = if (confirmEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + color = if (confirmEnabled) confirmContentColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt index 55f5312f..74d0ce9a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt @@ -53,92 +53,26 @@ fun HardwareAccessDialog( fontWeight = FontWeight.Bold ) - // Warning Card - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = context.getString(R.string.privileged_warning_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = context.getString(R.string.hw_access_disclaimer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } + DangerousWarningCard( + title = context.getString(R.string.privileged_warning_title), + text = context.getString(R.string.hw_access_disclaimer) + ) - // Confirmation Gate - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = context.getString(R.string.privileged_confirm_instruction), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold - ) - OutlinedTextField( - value = confirmText, - onValueChange = { confirmText = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(context.getString(R.string.i_understand_caps)) }, - singleLine = true, - isError = confirmText.isNotEmpty() && !isConfirmed, - shape = RoundedCornerShape(14.dp), - colors = DsTextFieldDefaults.surfaceColors() - ) - } + ConfirmPhraseField( + value = confirmText, + onValueChange = { confirmText = it }, + isError = confirmText.isNotEmpty() && !isConfirmed + ) - // Buttons - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(14.dp)) - .clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.cancel), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - } - } - Surface( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(14.dp)) - .clickable( - enabled = isConfirmed, - onClick = onConfirm - ), - shape = RoundedCornerShape(14.dp), - color = if (isConfirmed) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isConfirmed) MaterialTheme.colorScheme.onError - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = onConfirm, + confirmEnabled = isConfirmed, + confirmColor = MaterialTheme.colorScheme.error, + confirmContentColor = MaterialTheme.colorScheme.onError + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt index 17116853..d40cc94b 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt @@ -95,28 +95,10 @@ fun PrivilegedModeDialog( fontWeight = FontWeight.Bold ) - // Disclaimer Card - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = context.getString(R.string.privileged_warning_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = context.getString(R.string.privileged_disclaimer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } + DangerousWarningCard( + title = context.getString(R.string.privileged_warning_title), + text = context.getString(R.string.privileged_disclaimer) + ) // Granular Toggles using modern look Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -170,79 +152,38 @@ fun PrivilegedModeDialog( ) } - // Confirmation Gate + // Confirmation Gate (not needed when clearing all flags) if (!allOff) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = context.getString(R.string.privileged_confirm_instruction), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold - ) - OutlinedTextField( - value = confirmText, - onValueChange = { confirmText = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(context.getString(R.string.i_understand_caps)) }, - singleLine = true, - isError = confirmText.isNotEmpty() && !isConfirmed, - shape = RoundedCornerShape(14.dp), - colors = DsTextFieldDefaults.surfaceColors() - ) - } + ConfirmPhraseField( + value = confirmText, + onValueChange = { confirmText = it }, + isError = confirmText.isNotEmpty() && !isConfirmed + ) } - // Buttons - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = isConfirmed || allOff, - onClick = { - val tags = mutableListOf() - if (full) { - tags.add("full") - } else { - if (nomask) tags.add("nomask") - if (nocaps) tags.add("nocaps") - if (noseccomp) tags.add("noseccomp") - if (shared) tags.add("shared") - if (unfiltered) tags.add("unfiltered-dev") - } - onConfirm(tags.joinToString(",")) - } - ), - shape = RoundedCornerShape(14.dp), - color = if (isConfirmed || allOff) { - if (allOff) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = { + val tags = mutableListOf() + if (full) { + tags.add("full") } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - }, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isConfirmed || allOff) { - if (allOff) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onError - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - } - ) + if (nomask) tags.add("nomask") + if (nocaps) tags.add("nocaps") + if (noseccomp) tags.add("noseccomp") + if (shared) tags.add("shared") + if (unfiltered) tags.add("unfiltered-dev") } - } - } + onConfirm(tags.joinToString(",")) + }, + // allOff means "clear privileged mode" — a safe action, so it is + // enabled without the confirm phrase and uses the primary color. + confirmEnabled = isConfirmed || allOff, + confirmColor = if (allOff) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + confirmContentColor = if (allOff) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onError + ) } } } From 2bf0fa6cc010e1eddff1a76af36a6f08720de816 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:27 +0530 Subject: [PATCH 176/211] refactor: route ContainerViewModel scan through a ContainerRuntime gateway ContainerViewModel.runScan()/silentScan() called com.topjohnwu.superuser.Shell.cmd(...) directly, mixing raw root-shell execution into the ViewModel. Introduce a util/ContainerRuntime gateway that owns the backend 'scan' shell call (built from constants only) and is the single place command construction/quoting will live; the ViewModel now calls ContainerRuntime.scan(). No behavior change. Signed-off-by: ravindu644 --- .../app/ui/viewmodel/ContainerViewModel.kt | 8 +++---- .../droidspaces/app/util/ContainerRuntime.kt | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/util/ContainerRuntime.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerViewModel.kt index 6040dc48..649612ee 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerViewModel.kt @@ -12,7 +12,7 @@ import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerOSInfoManager import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.PreferencesManager -import com.droidspaces.app.util.Constants +import com.droidspaces.app.util.ContainerRuntime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -171,8 +171,7 @@ class ContainerViewModel(application: Application) : AndroidViewModel(applicatio try { withContext(Dispatchers.IO) { Log.i(TAG, "Executing manual scan...") - val command = "${Constants.getDroidspacesCommand()} scan" - com.topjohnwu.superuser.Shell.cmd(command).exec() + ContainerRuntime.scan() // Fetch new list after scan val result = ContainerManager.listContainers() @@ -195,8 +194,7 @@ class ContainerViewModel(application: Application) : AndroidViewModel(applicatio withContext(Dispatchers.IO) { try { Log.i(TAG, "Executing silent background scan...") - val command = "${Constants.getDroidspacesCommand()} scan" - com.topjohnwu.superuser.Shell.cmd(command).exec() + ContainerRuntime.scan() // Fetch new list and update state val result = ContainerManager.listContainers() diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerRuntime.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerRuntime.kt new file mode 100644 index 00000000..fd45991b --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerRuntime.kt @@ -0,0 +1,24 @@ +package com.droidspaces.app.util + +import com.topjohnwu.superuser.Shell + +/** + * Thin gateway over the root shell for container-runtime operations. + * + * The single place ViewModels reach the backend binary, so command construction + * and quoting live here instead of being inlined in the UI/ViewModel layer. Any + * dynamic or user-supplied argument passed through this object MUST be quoted + * via [ContainerCommandBuilder.quote]. + */ +object ContainerRuntime { + + /** + * Run the backend `scan` to reconcile on-disk containers. Blocking; call + * from a background dispatcher. The command is built from constants only + * (no user input), so no argument quoting is required here. + */ + fun scan(): Shell.Result { + val command = "${Constants.getDroidspacesCommand()} scan" + return Shell.cmd(command).exec() + } +} From 05b01a6052443c5879f9894008a659dabb286e76 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:28 +0530 Subject: [PATCH 177/211] refactor: extract daemon-mode root shell into DaemonModeRepository PreferencesManager ran root Shell.cmd() directly (echo/cat of the root-protected daemon-mode file), entangling a key-value store with privileged filesystem I/O. Move both operations into a util/DaemonModeRepository; the isDaemonModeEnabled setter and syncDaemonModeFromDisk now delegate to it, so their public API and behavior are unchanged and no caller needs updating. The written value and path are constants, so quoting is unaffected. Signed-off-by: ravindu644 --- .../app/util/DaemonModeRepository.kt | 34 +++++++++++++++++++ .../app/util/PreferencesManager.kt | 29 ++++------------ 2 files changed, 40 insertions(+), 23 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/util/DaemonModeRepository.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/util/DaemonModeRepository.kt b/Android/app/src/main/java/com/droidspaces/app/util/DaemonModeRepository.kt new file mode 100644 index 00000000..6f7f37bd --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/DaemonModeRepository.kt @@ -0,0 +1,34 @@ +package com.droidspaces.app.util + +import com.topjohnwu.superuser.Shell + +/** + * Owns the root-shell I/O for the backend daemon-mode flag, which lives in a + * root-protected file on disk ([Constants.DAEMON_MODE_FILE]). + * + * Extracted out of [PreferencesManager] so the preference store stays a plain + * key-value store and does not execute root shell commands itself. The value + * written ("1"/"0") and the path are constants, so no argument quoting applies. + */ +object DaemonModeRepository { + + /** Persist the daemon-mode flag to the root-protected file (non-blocking). */ + fun writeToDisk(enabled: Boolean) { + val value = if (enabled) "1" else "0" + val path = Constants.DAEMON_MODE_FILE + Shell.cmd("echo '$value' > '$path'").submit() + } + + /** + * Read the daemon-mode flag from disk. Returns null when the file does not + * exist or cannot be read (blocking; call from a background dispatcher). + */ + fun readFromDisk(): Boolean? { + val path = Constants.DAEMON_MODE_FILE + val result = Shell.cmd("cat '$path' 2>/dev/null").exec() + if (result.isSuccess && result.out.isNotEmpty()) { + return result.out[0].trim() == "1" + } + return null + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt index 1cb600fc..d677698d 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt @@ -136,7 +136,7 @@ class PreferencesManager private constructor(context: Context) { get() = prefs.getBoolean(KEY_DAEMON_MODE_ENABLED, false) set(value) { prefs.edit().putBoolean(KEY_DAEMON_MODE_ENABLED, value).apply() - syncDaemonMode(value) + DaemonModeRepository.writeToDisk(value) } var isSymlinkEnabled: Boolean @@ -145,33 +145,16 @@ class PreferencesManager private constructor(context: Context) { prefs.edit().putBoolean(KEY_SYMLINK_ENABLED, value).apply() } - /** - * Sync daemon mode preference to the root-protected file on disk. - * writes 1 if enabled, 0 if disabled. - */ - private fun syncDaemonMode(enabled: Boolean) { - val value = if (enabled) "1" else "0" - val path = Constants.DAEMON_MODE_FILE - // Use non-blocking shell command to write the file - com.topjohnwu.superuser.Shell.cmd("echo '$value' > '$path'").submit() - } - /** * Sync daemon mode preference from the root-protected file on disk. * Updates SharedPreferences if the file exists and differs. */ fun syncDaemonModeFromDisk() { - val path = Constants.DAEMON_MODE_FILE - // Use blocking shell command to read the file state accurately - val result = com.topjohnwu.superuser.Shell.cmd("cat '$path' 2>/dev/null").exec() - if (result.isSuccess && result.out.isNotEmpty()) { - val diskValue = result.out[0].trim() - val enabled = diskValue == "1" - if (isDaemonModeEnabled != enabled) { - // Update SharedPreferences ONLY (avoiding recursive syncDaemonMode call) - // This will trigger the OnSharedPreferenceChangeListener in the UI - prefs.edit().putBoolean(KEY_DAEMON_MODE_ENABLED, enabled).apply() - } + val enabled = DaemonModeRepository.readFromDisk() ?: return + if (isDaemonModeEnabled != enabled) { + // Update SharedPreferences ONLY (avoiding a recursive setter/disk write). + // This triggers the OnSharedPreferenceChangeListener in the UI. + prefs.edit().putBoolean(KEY_DAEMON_MODE_ENABLED, enabled).apply() } } From 430c1d8d64326b73020ccf05de1d352743c2871e Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:28 +0530 Subject: [PATCH 178/211] refactor: bound usage-monitor coroutines, prune download jobs, privatize app scope ContainerUsageViewModel.startMonitoring: the 2s while(true) loop launched one un-awaited child per container each tick (piling up unbounded coroutines when a collector was slow) and mutated the Compose snapshot map off the main thread while the parent removed keys. Now each tick collects via async{}.awaitAll() (bounded, awaited before the delay) and applies the map mutations on Dispatchers.Main. RootfsRepoViewModel: downloadJobs/downloadIds were only pruned by cancelDownload, so completed downloads accumulated Job references forever, and there was no onCleared. Prune each entry when its poll completes normally (identity-guarded against a concurrent restart) and clear the maps in onCleared. DroidspacesApplication.applicationScope is now private (only this class uses it), removing an app-wide unscoped launcher. Root Shell setup left unchanged. Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../droidspaces/app/DroidspacesApplication.kt | 5 +-- .../ui/viewmodel/ContainerUsageViewModel.kt | 35 ++++++++++++------- .../app/ui/viewmodel/RootfsRepoViewModel.kt | 26 +++++++++++--- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/DroidspacesApplication.kt b/Android/app/src/main/java/com/droidspaces/app/DroidspacesApplication.kt index 4cc21b17..6a6efd7e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/DroidspacesApplication.kt +++ b/Android/app/src/main/java/com/droidspaces/app/DroidspacesApplication.kt @@ -10,8 +10,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class DroidspacesApplication : Application() { - // Use SupervisorJob to prevent child coroutine failures from cancelling the scope - val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + // Use SupervisorJob to prevent child coroutine failures from cancelling the scope. + // Private: only this class schedules onto it (avoids an app-wide unscoped launcher). + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onCreate() { super.onCreate() diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerUsageViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerUsageViewModel.kt index be72846f..f743a506 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerUsageViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerUsageViewModel.kt @@ -9,10 +9,14 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.droidspaces.app.util.ContainerUsageCollector +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * ViewModel for tracking real-time container usage statistics. @@ -49,20 +53,27 @@ class ContainerUsageViewModel(application: Application) : AndroidViewModel(appli // Start new monitoring loop updateJob = viewModelScope.launch(Dispatchers.IO) { while (true) { - // Collect usage for all monitored containers in parallel - containerNames.forEach { containerName -> - launch { - try { - val usage = ContainerUsageCollector.collectUsage(containerName) - _usageStats[containerName] = usage - } catch (e: Exception) { - Log.e(TAG, "Failed to collect usage for $containerName", e) + // Collect usage for all monitored containers concurrently, but AWAIT the + // whole tick before sleeping — so slow collectors can't let successive + // ticks pile up an unbounded number of in-flight child coroutines. + val results = coroutineScope { + containerNames.map { containerName -> + async { + try { + containerName to ContainerUsageCollector.collectUsage(containerName) + } catch (e: Exception) { + Log.e(TAG, "Failed to collect usage for $containerName", e) + null + } } - } + }.awaitAll() } - // Remove stats for containers that are no longer monitored - _usageStats.keys.removeAll { it !in containerNames } + // Compose snapshot state must be mutated on the main thread. + withContext(Dispatchers.Main) { + results.filterNotNull().forEach { (name, usage) -> _usageStats[name] = usage } + _usageStats.keys.removeAll { it !in containerNames } + } // Wait before next update delay(UPDATE_INTERVAL_MS) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/RootfsRepoViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/RootfsRepoViewModel.kt index a948a5a4..05842875 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/RootfsRepoViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/RootfsRepoViewModel.kt @@ -130,20 +130,30 @@ class RootfsRepoViewModel(application: Application) : AndroidViewModel(applicati fun startDownload(asset: RootfsAsset) { if (downloadStates[asset.downloadUrl] is AssetDownloadState.Downloading) return val ctx = getApplication() - downloadJobs[asset.downloadUrl]?.cancel() + val url = asset.downloadUrl + downloadJobs[url]?.cancel() val downloadId = RootfsDownloadManager.enqueue(ctx, asset) - downloadIds[asset.downloadUrl] = downloadId - downloadJobs[asset.downloadUrl] = viewModelScope.launch { + downloadIds[url] = downloadId + lateinit var job: Job + job = viewModelScope.launch { RootfsDownloadManager.pollFlow(ctx, asset, downloadId).collect { status -> downloadStates = downloadStates.toMutableMap().apply { - put(asset.downloadUrl, when (status) { + put(url, when (status) { is DownloadStatus.Progress -> AssetDownloadState.Downloading(status.percent) is DownloadStatus.Completed -> AssetDownloadState.Done(status.fileUri) is DownloadStatus.Failed -> AssetDownloadState.Failed(status.reason) }) } } + // Poll completed normally (terminal status reached, not cancelled): drop this + // job's bookkeeping so the maps don't grow unbounded across repeated downloads. + // Guard on identity so a concurrent restart's entry is never removed. + if (downloadJobs[url] === job) { + downloadJobs.remove(url) + downloadIds.remove(url) + } } + downloadJobs[url] = job } fun cancelDownload(asset: RootfsAsset) { @@ -172,4 +182,12 @@ class RootfsRepoViewModel(application: Application) : AndroidViewModel(applicati fun getCustomRepos(): List> = PreferencesManager.getInstance(getApplication()).getCustomRepos() + + override fun onCleared() { + super.onCleared() + // viewModelScope cancels the poll coroutines; drop the bookkeeping too. + downloadJobs.values.forEach { it.cancel() } + downloadJobs.clear() + downloadIds.clear() + } } From 2e5ab925773f03aa29553ec01a779c74bb683bf2 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:28 +0530 Subject: [PATCH 179/211] refactor: key/remember recomputed lists to cut needless recomposition work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunningContainerCard: the sessionCount derivedStateOf was remembered with NO key, so its closure captured the first container.name forever if the slot were reused for a different container — key it on container.name. ContainerCard: the 14-branch options list was rebuilt on every recomposition even though it depends only on stable container fields — wrap in remember(container){buildList{}}. ContainerDetailsScreen: the list of @Composable identity tokens fed to SyncedGrid (which does two SubcomposeLayout passes) was rebuilt every recomposition, incl. the 2s OS-info poll — remember it keyed on (osInfo, isRunning). Verified: ./gradlew compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/component/ContainerCard.kt | 37 ++++++++++--------- .../app/ui/component/RunningContainerCard.kt | 2 +- .../app/ui/screen/ContainerDetailsScreen.kt | 8 ++-- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt index ae365b1a..471c6142 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt @@ -153,23 +153,26 @@ fun ContainerCard( } } - // Options Row - val options = mutableListOf() - if (container.disableIPv6) options.add(context.getString(R.string.ipv6_option)) - if (container.enableAndroidStorage) options.add(context.getString(R.string.storage_option)) - if (container.enableHwAccess) options.add(context.getString(R.string.hw_option)) - if (container.enableGpuMode || container.enableHwAccess) options.add(context.getString(R.string.gpu_option)) - if (container.enableTermuxX11) options.add(context.getString(R.string.x11_option)) - if (container.enableVirgl) options.add(context.getString(R.string.virgl_option)) - if (container.enablePulseaudio) options.add(context.getString(R.string.pulseaudio_option)) - if (container.selinuxPermissive) options.add(context.getString(R.string.selinux_permissive_option)) - if (container.allowUserns) options.add(context.getString(R.string.userns_option)) - if (container.volatileMode) options.add(context.getString(R.string.volatile_option)) - if (container.forceCgroupv1) options.add(context.getString(R.string.cgroup_v1_option)) - if (container.blockNestedNs) options.add(context.getString(R.string.deadlock_shield_option)) - if (container.privileged.isNotEmpty()) options.add(context.getString(R.string.privileged_option)) - if (container.customInit.isNotEmpty()) options.add(context.getString(R.string.custom_init_option)) - if (container.runAtBoot) options.add(context.getString(R.string.run_at_boot)) + // Options Row — depends only on stable container fields, so build once per container. + val options = remember(container) { + buildList { + if (container.disableIPv6) add(context.getString(R.string.ipv6_option)) + if (container.enableAndroidStorage) add(context.getString(R.string.storage_option)) + if (container.enableHwAccess) add(context.getString(R.string.hw_option)) + if (container.enableGpuMode || container.enableHwAccess) add(context.getString(R.string.gpu_option)) + if (container.enableTermuxX11) add(context.getString(R.string.x11_option)) + if (container.enableVirgl) add(context.getString(R.string.virgl_option)) + if (container.enablePulseaudio) add(context.getString(R.string.pulseaudio_option)) + if (container.selinuxPermissive) add(context.getString(R.string.selinux_permissive_option)) + if (container.allowUserns) add(context.getString(R.string.userns_option)) + if (container.volatileMode) add(context.getString(R.string.volatile_option)) + if (container.forceCgroupv1) add(context.getString(R.string.cgroup_v1_option)) + if (container.blockNestedNs) add(context.getString(R.string.deadlock_shield_option)) + if (container.privileged.isNotEmpty()) add(context.getString(R.string.privileged_option)) + if (container.customInit.isNotEmpty()) add(context.getString(R.string.custom_init_option)) + if (container.runAtBoot) add(context.getString(R.string.run_at_boot)) + } + } if (options.isNotEmpty()) { Text(context.getString(R.string.options_label, options.joinToString(", ")), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/RunningContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/RunningContainerCard.kt index ea12423c..94655e3c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/RunningContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/RunningContainerCard.kt @@ -73,7 +73,7 @@ fun RunningContainerCard( modifier = Modifier.weight(1f) ) - val sessionCount by remember { + val sessionCount by remember(container.name) { derivedStateOf { TerminalSessionService.globalSessionList.values.count { it.containerName == container.name diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt index 62f5f378..378046be 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt @@ -209,8 +209,10 @@ fun ContainerDetailsScreen( } osInfo?.let { info -> - // Dynamic Height Synced Grid - measure all 6 and find max - val tokens = mutableListOf<@Composable () -> Unit>().apply { + // Dynamic Height Synced Grid - measure all 6 and find max. + // Built once per (osInfo, running) instead of every recomposition + // (this block re-fires on the 2s OS-info poll). + val tokens = remember(info, container.isRunning) { mutableListOf<@Composable () -> Unit>().apply { add { IdentityToken(context.getString(R.string.distribution), info.prettyName ?: info.name ?: "Linux", IconUtils.getDistroIcon(info.prettyName ?: info.name), MaterialTheme.colorScheme.primary) } add { IdentityToken(context.getString(R.string.hostname), info.hostname ?: "localhost", rememberVectorPainter(image = Icons.Default.Computer), MaterialTheme.colorScheme.secondary) } add { IdentityToken(context.getString(R.string.uptime), info.uptime ?: "0s", rememberVectorPainter(image = Icons.Default.Timer), MaterialTheme.colorScheme.tertiary) } @@ -219,7 +221,7 @@ fun ContainerDetailsScreen( add { IdentityToken(context.getString(R.string.cpu_usage_label), info.cpuUsage?.let { context.getString(R.string.cpu_percent_label, it) } ?: "---", rememberVectorPainter(image = Icons.Default.Speed), MaterialTheme.colorScheme.primary) } add { IdentityToken(context.getString(R.string.ram_usage_label), info.ramUsageMb?.let { context.getString(R.string.ram_percent_label, it, info.ramPercent ?: 0.0) } ?: "---", rememberVectorPainter(image = Icons.Default.Memory), MaterialTheme.colorScheme.secondary) } } - } + } } SyncedGrid(items = tokens) } ?: Box( From a6d193269a15f6a70731668bffccdebf81e3a5bc Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:28 +0530 Subject: [PATCH 180/211] refactor: fix net_mode default drift between model and parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContainerInfo.netMode defaulted to "nat" but parseConfig defaulted a missing net_mode key to "host" — so a config that omits the key was interpreted as a different network mode than the object model uses. Define the default once as Constants.DEFAULT_NET_MODE ("nat") and use it in both places. Configs written by the app always include net_mode, so only externally-authored/legacy configs missing the key are affected; they now default to "nat" (matching create-flow). Signed-off-by: ravindu644 --- .../app/src/main/java/com/droidspaces/app/util/Constants.kt | 3 +++ .../main/java/com/droidspaces/app/util/ContainerManager.kt | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/Constants.kt b/Android/app/src/main/java/com/droidspaces/app/util/Constants.kt index 00de26c4..902aea0a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/Constants.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/Constants.kt @@ -22,6 +22,9 @@ object Constants { const val DAEMON_MODE_FILE = "/data/local/Droidspaces/.daemon_mode" const val DAEMON_PID_FILE = "/data/local/Droidspaces/droidspacesd.pid" const val CONTAINER_CONFIG_FILE = "container.config" + // Default network mode — the single source of truth for both the ContainerInfo + // model default and the config parser default (they used to disagree). + const val DEFAULT_NET_MODE = "nat" // Preferences keys const val PREFS_NAME = "droidspaces_prefs" diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index 54fab33b..27353160 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -29,7 +29,7 @@ data class ContainerInfo( val name: String, val hostname: String, val rootfsPath: String, - val netMode: String = "nat", + val netMode: String = Constants.DEFAULT_NET_MODE, val disableIPv6: Boolean = false, val enableAndroidStorage: Boolean = false, val enableHwAccess: Boolean = false, @@ -312,7 +312,7 @@ object ContainerManager { } else { getRootfsPath(containerName) }, - netMode = configMap["net_mode"] ?: "host", + netMode = configMap["net_mode"] ?: Constants.DEFAULT_NET_MODE, disableIPv6 = configMap["disable_ipv6"] == "1", enableAndroidStorage = configMap["enable_android_storage"] == "1", enableHwAccess = configMap["enable_hw_access"] == "1", From 276b2721a892ab73bd777462bda1dfab60b92c73 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:28 +0530 Subject: [PATCH 181/211] refactor: dedupe countEnvVars into ValidationUtils countEnvVars (parse an env-file body, count non-comment key=value lines) was defined as a local function inside two composables (ContainerConfigForm and InstallationSummaryScreen). Move it to ValidationUtils.countEnvVars(content: String?) and call it from both, so it is defined once and unit-testable outside Compose. Signed-off-by: ravindu644 --- .../droidspaces/app/ui/component/ContainerConfigForm.kt | 9 ++------- .../app/ui/screen/InstallationSummaryScreen.kt | 9 +-------- .../java/com/droidspaces/app/util/ValidationUtils.kt | 8 ++++++++ 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index b9c6989e..07f0c6ad 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -82,12 +82,7 @@ import com.droidspaces.app.util.Constants import com.droidspaces.app.util.ContainerConfigState import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.GatewayErrors - -/** Count non-comment `key=value` lines. Shared by the config screens. */ -private fun countEnvVars(content: String): Int = - content.lines() - .map { it.trim() } - .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } +import com.droidspaces.app.util.ValidationUtils /** * The single, shared container-configuration form used by both the Create wizard @@ -602,7 +597,7 @@ fun ContainerConfigForm( modifier = Modifier.padding(top = 16.dp) ) - val envCount = countEnvVars(state.envFileContent) + val envCount = ValidationUtils.countEnvVars(state.envFileContent) val envSubtitle = if (envCount > 0) { context.getString(R.string.environment_variables_configured, envCount) } else { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index 91a8ec6c..136274df 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -136,14 +136,7 @@ fun InstallationSummaryScreen( if (config.blockNestedNs) SummaryItem(stringResource(R.string.manual_deadlock_shield), stringResource(R.string.enabled_legend), Icons.Default.GppBad) if (config.privileged.isNotEmpty()) SummaryItem(stringResource(R.string.privileged_mode), config.privileged, Icons.Default.GppMaybe) - fun countEnvVars(content: String?): Int { - if (content.isNullOrBlank()) return 0 - return content.lines() - .map { it.trim() } - .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } - } - - val envCount = countEnvVars(config.envFileContent) + val envCount = com.droidspaces.app.util.ValidationUtils.countEnvVars(config.envFileContent) if (envCount > 0) { SummaryItem(stringResource(R.string.environment_variables), stringResource(R.string.environment_variables_configured, envCount), Icons.Default.Code) } diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt index e1fe893c..c8b0aa03 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt @@ -71,6 +71,14 @@ object ValidationUtils { .trim('-') } + /** Count non-comment `key=value` lines in an env-file body. */ + fun countEnvVars(content: String?): Int { + if (content.isNullOrBlank()) return 0 + return content.lines() + .map { it.trim() } + .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } + } + // ---- Gateway networking mode -------------------------------------------- // Linux IFNAMSIZ is 16 incl. NUL, so interface/bridge names get 15 usable chars. From 0c85f754a16393859b359d2251222f0359fd32c7 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:28 +0530 Subject: [PATCH 182/211] refactor: make SummaryItem icon type-safe via overloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SummaryItem took icon: Any and did a runtime when(icon){is ImageVector; is Painter}. Replace with a @Composable icon-slot core plus two typed overloads (ImageVector, Painter) so icon type is checked at compile time. Call sites are unchanged — overload resolution picks the right one from the argument type. Signed-off-by: ravindu644 --- .../ui/screen/InstallationSummaryScreen.kt | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index 136274df..69cf073b 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -179,11 +179,33 @@ fun InstallationSummaryScreen( } } +@Composable +private fun SummaryItem(label: String, value: String, icon: androidx.compose.ui.graphics.vector.ImageVector) = + SummaryItem(label, value) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ) + } + +@Composable +private fun SummaryItem(label: String, value: String, icon: androidx.compose.ui.graphics.painter.Painter) = + SummaryItem(label, value) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ) + } + @Composable private fun SummaryItem( label: String, value: String, - icon: Any + icon: @Composable () -> Unit ) { Row( modifier = Modifier @@ -192,24 +214,7 @@ private fun SummaryItem( horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically ) { - when (icon) { - is androidx.compose.ui.graphics.vector.ImageVector -> { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - ) - } - is androidx.compose.ui.graphics.painter.Painter -> { - Icon( - painter = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - ) - } - } + icon() Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) From e11371ae95b44c12a87bbf4d4df1b18c8844258c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:29 +0530 Subject: [PATCH 183/211] refactor: remove dead @Deprecated buildUptimeCommand ContainerCommandBuilder.buildUptimeCommand was @Deprecated in favor of buildUsageCommand and has no remaining callers. Delete it. Signed-off-by: ravindu644 --- .../com/droidspaces/app/util/ContainerCommandBuilder.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerCommandBuilder.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerCommandBuilder.kt index 8268d15a..c0904f33 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerCommandBuilder.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerCommandBuilder.kt @@ -58,14 +58,6 @@ object ContainerCommandBuilder { return "$DROIDSPACES_BINARY_PATH --config=${quote(getConfigPath(container))} restart" } - /** - * Build uptime command for a container. - */ - @Deprecated("Use buildUsageCommand instead for synchronized metrics", ReplaceWith("buildUsageCommand(containerName)")) - fun buildUptimeCommand(containerName: String): String { - return "$DROIDSPACES_BINARY_PATH --name=${quote(containerName)} uptime" - } - /** * Build unified usage command for a container (UPTIME, RAM, CPU). */ From 19e3302d40fdcf29b979d78d617850bfa9d96e59 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:29 +0530 Subject: [PATCH 184/211] refactor: dedupe ABI->arch mapping into DeviceArch The identical Build.SUPPORTED_ABIS[0] -> arch when-ladder existed three times (BinaryInstaller.getArchitectureSuffix/getArchitectureName, RootfsRepository.deviceArch). Extract one DeviceArch util (suffix() + displayName()); the existing functions now delegate to it, so callers are unchanged. Signed-off-by: ravindu644 --- .../droidspaces/app/util/BinaryInstaller.kt | 22 ++---------- .../com/droidspaces/app/util/DeviceArch.kt | 35 +++++++++++++++++++ .../droidspaces/app/util/RootfsRepository.kt | 11 +----- 3 files changed, 38 insertions(+), 30 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/util/DeviceArch.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt b/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt index 674848a5..26e39a29 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt @@ -26,16 +26,7 @@ object BinaryInstaller { /** * Map Android architecture to binary name suffix */ - private fun getArchitectureSuffix(): String { - val arch = Build.SUPPORTED_ABIS[0] // Primary ABI - return when { - arch.contains("arm64") || arch.contains("aarch64") -> "aarch64" - arch.contains("armeabi") || arch.contains("arm") -> "armhf" - arch.contains("x86_64") -> "x86_64" - arch.contains("x86") -> "x86" - else -> "aarch64" // Default to aarch64 - } - } + private fun getArchitectureSuffix(): String = DeviceArch.suffix() /** * Get droidspaces binary name for architecture @@ -55,16 +46,7 @@ object BinaryInstaller { /** * Get human-readable architecture name */ - fun getArchitectureName(): String { - val arch = Build.SUPPORTED_ABIS[0] - return when { - arch.contains("arm64") || arch.contains("aarch64") -> "ARM64 (aarch64)" - arch.contains("armeabi") || arch.contains("arm") -> "ARM (armhf)" - arch.contains("x86_64") -> "x86_64" - arch.contains("x86") -> "x86" - else -> arch - } - } + fun getArchitectureName(): String = DeviceArch.displayName() /** * Install droidspaces binary with progress updates diff --git a/Android/app/src/main/java/com/droidspaces/app/util/DeviceArch.kt b/Android/app/src/main/java/com/droidspaces/app/util/DeviceArch.kt new file mode 100644 index 00000000..7b3b4101 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/DeviceArch.kt @@ -0,0 +1,35 @@ +package com.droidspaces.app.util + +import android.os.Build + +/** + * Single source of truth for mapping the device's primary ABI to the arch + * strings Droidspaces uses (the bundled-binary suffix, which also matches the + * arch field in rootfs.json), plus a human-readable name. Previously this + * mapping was duplicated across BinaryInstaller and RootfsRepository. + */ +object DeviceArch { + /** Binary-suffix / rootfs arch: aarch64, armhf, x86_64, x86 (defaults to aarch64). */ + fun suffix(): String { + val abi = Build.SUPPORTED_ABIS[0] + return when { + abi.contains("arm64") || abi.contains("aarch64") -> "aarch64" + abi.contains("armeabi") || abi.contains("arm") -> "armhf" + abi.contains("x86_64") -> "x86_64" + abi.contains("x86") -> "x86" + else -> "aarch64" + } + } + + /** Human-readable architecture name for display. */ + fun displayName(): String { + val abi = Build.SUPPORTED_ABIS[0] + return when { + abi.contains("arm64") || abi.contains("aarch64") -> "ARM64 (aarch64)" + abi.contains("armeabi") || abi.contains("arm") -> "ARM (armhf)" + abi.contains("x86_64") -> "x86_64" + abi.contains("x86") -> "x86" + else -> abi + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/RootfsRepository.kt b/Android/app/src/main/java/com/droidspaces/app/util/RootfsRepository.kt index b35cca2b..891898d8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/RootfsRepository.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/RootfsRepository.kt @@ -47,16 +47,7 @@ sealed class RepoResult { } /** Maps the device's primary ABI to the arch string used in rootfs.json. */ -fun deviceArch(): String { - val abi = Build.SUPPORTED_ABIS[0] - return when { - abi.contains("arm64") || abi.contains("aarch64") -> "aarch64" - abi.contains("armeabi") || abi.contains("arm") -> "armhf" - abi.contains("x86_64") -> "x86_64" - abi.contains("x86") -> "x86" - else -> "aarch64" - } -} +fun deviceArch(): String = DeviceArch.suffix() object RootfsRepository { From 2cc3b3c23d2089236d8d7ac0f164506a961e19f6 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:29 +0530 Subject: [PATCH 185/211] fix(security): shell-quote file-picker paths in root ls/test commands FilePickerDialog ran root Shell.cmd("[ -d \"$targetDir\" ]") and Shell.cmd("ls -F \"$path\"") with the path only double-quoted, so a directory/path containing ", $(...) or backticks executed as root when browsed. Route both through ContainerCommandBuilder.quote() (POSIX single-quote escaping). Signed-off-by: ravindu644 --- .../com/droidspaces/app/ui/component/FilePickerDialog.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt index 683aec95..fec89fcf 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import com.droidspaces.app.util.ContainerCommandBuilder import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip @@ -127,7 +128,7 @@ fun FilePickerDialog( } if (targetDir != currentPath && targetDir.isNotEmpty()) { val exists = withContext(Dispatchers.IO) { - val result = Shell.cmd("[ -d \"$targetDir\" ] && echo yes").exec() + val result = Shell.cmd("[ -d ${ContainerCommandBuilder.quote(targetDir)} ] && echo yes").exec() result.isSuccess && result.out.firstOrNull() == "yes" } if (exists) { @@ -349,7 +350,7 @@ private fun FileItemRow( } private suspend fun fetchItems(path: String, showFiles: Boolean): List = withContext(Dispatchers.IO) { - val result = Shell.cmd("ls -F \"$path\" 2>/dev/null").exec() + val result = Shell.cmd("ls -F ${ContainerCommandBuilder.quote(path)} 2>/dev/null").exec() if (!result.isSuccess) return@withContext emptyList() result.out.mapNotNull { line -> From 3055be05df4becf244e4d0c823da1b65e054439f Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:29 +0530 Subject: [PATCH 186/211] fix(security): shell-quote imgPath in disk-usage stat command getUsage() interpolated ContainerInfo.rootfsPath (read verbatim from the config file, never validated) double-quoted into a root busybox stat command; $(...) / backticks would run as root. Quote via ContainerCommandBuilder.quote(). Signed-off-by: ravindu644 --- .../java/com/droidspaces/app/util/ContainerDiskUsageManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt index efb5c06b..1fa01522 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt @@ -38,7 +38,7 @@ object ContainerDiskUsageManager { * @return used/total in bytes, or null if the image could not be stat'd. */ suspend fun getUsage(imgPath: String): DiskUsage? = withContext(Dispatchers.IO) { - val cmd = "${Constants.BUSYBOX_BINARY_PATH} stat -c '%s %b' \"$imgPath\" 2>/dev/null" + val cmd = "${Constants.BUSYBOX_BINARY_PATH} stat -c '%s %b' ${ContainerCommandBuilder.quote(imgPath)} 2>/dev/null" val result = Shell.cmd(cmd).exec() if (!result.isSuccess) return@withContext null From 299d654847c83fef6c176ef6e2cd7341b58105b0 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:29 +0530 Subject: [PATCH 187/211] fix(security): quote bare rm -rf path in installer cleanup cleanup() ran Shell.cmd("rm -rf $path 2>&1") completely unquoted as root; paths derive from the container name via the weak sanitizeContainerName. Quote via ContainerCommandBuilder.quote(). (sanitizeContainerName hardening deferred.) Signed-off-by: ravindu644 --- .../main/java/com/droidspaces/app/util/ContainerInstaller.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt index 830cece8..c7b54ee7 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt @@ -375,7 +375,7 @@ object ContainerInstaller { private suspend fun cleanup(paths: List, logger: ContainerLogger) { paths.reversed().forEach { path -> try { - val result = Shell.cmd("rm -rf $path 2>&1").exec() + val result = Shell.cmd("rm -rf ${ContainerCommandBuilder.quote(path)} 2>&1").exec() if (result.isSuccess) { logger.d("Cleaned up: $path") } else { From 241bfaf9e7fbae226e08ee02dc87faf2d6548b39 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:29 +0530 Subject: [PATCH 188/211] fix(security): allow-list container name and user before building su -c payload The terminal built su -c 'droidspaces -n "$name" enter $user' escaping only double quotes; $user (sourced from the container's /etc/passwd, unvalidated) was spliced raw. Validate containerName against the creation charset (letters/digits/space/_.- so normal names incl. spaces still work) and containerUser against a metachar-free allow-list (fallback root); an unsafe name opens a bare root shell instead of an injectable payload. Signed-off-by: ravindu644 --- .../ui/terminal/DroidspacesTerminalSession.kt | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/terminal/DroidspacesTerminalSession.kt b/Android/app/src/main/java/com/droidspaces/app/ui/terminal/DroidspacesTerminalSession.kt index b59412fe..96d39980 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/terminal/DroidspacesTerminalSession.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/terminal/DroidspacesTerminalSession.kt @@ -32,15 +32,25 @@ import com.termux.terminal.TerminalSessionClient */ object DroidspacesTerminalSession { + // Container names allow letters, digits, spaces, '_', '.', '-' (mirrors + // ValidationUtils.validateContainerName) — none are shell metacharacters, so + // a validated name is safe inside the "..." payload. A username discovered + // from the container's /etc/passwd is NOT validated elsewhere, so it is + // checked here too. See FINDINGS_APP_VULN V7. + private val SAFE_NAME = Regex("^[a-zA-Z0-9_\\s.-]+$") + private val SAFE_USER = Regex("^[A-Za-z0-9_.@:+-]+$") + fun create( sessionClient: TerminalSessionClient, containerName: String?, containerUser: String? = null, ): TerminalSession { - return if (containerName != null) { - // Escape any literal " in the container name - it sits inside "..." in the su payload. - val escapedName = containerName.replace("\"", "\\\"") - val user = containerUser ?: "root" + val safeName = containerName?.takeIf { SAFE_NAME.matches(it) } + return if (safeName != null) { + // Reject any username carrying shell metacharacters; fall back to root. + val user = containerUser?.takeIf { SAFE_USER.matches(it) } ?: "root" + // safeName cannot contain " / ' / $ / ` — keep the escape as belt-and-suspenders. + val escapedName = safeName.replace("\"", "\\\"") // Equivalent to: sh -c "su -c 'droidspaces -n "Name" enter user'" val shArg = "su -c 'droidspaces -n \"$escapedName\" enter $user'" @@ -56,7 +66,8 @@ object DroidspacesTerminalSession { /* client = */ sessionClient, ) } else { - // Bare interactive root shell. + // No container, or a name carrying shell metacharacters: open a bare + // interactive root shell rather than build an injectable payload. TerminalSession( /* shell = */ "/system/bin/sh", /* workingDir = */ "/sdcard", From 5c4ba1cc5a190a3967d5c14e57bbac5c6884e83c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:30 +0530 Subject: [PATCH 189/211] fix(security): disable allowBackup on the root-privileged app allowBackup="true" with no dataExtractionRules let app-private data (PreferencesManager cache incl. custom rootfs-repo URLs, cached container defs) be exfiltrated/injected via ADB or cloud backup/restore. Set allowBackup="false". Signed-off-by: ravindu644 --- Android/app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index eabe305f..6e44120d 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ Date: Sun, 26 Jul 2026 18:49:30 +0530 Subject: [PATCH 190/211] fix(security): enforce HTTPS for rootfs repos and block cleartext traffic The rootfs supply chain (manifest fetch + tarball download, incl. user-added custom repos) had no scheme enforcement; only the implicit platform default blocked http. Add res/xml/network_security_config.xml (cleartextTrafficPermitted=false) referenced from the manifest to block cleartext app-wide, and reject non-https URLs in RootfsRepository.httpGet and PreferencesManager.addCustomRepo. Signed-off-by: ravindu644 --- Android/app/src/main/AndroidManifest.xml | 1 + .../com/droidspaces/app/util/PreferencesManager.kt | 2 ++ .../java/com/droidspaces/app/util/RootfsRepository.kt | 2 ++ .../app/src/main/res/xml/network_security_config.xml | 10 ++++++++++ 4 files changed, 15 insertions(+) create mode 100644 Android/app/src/main/res/xml/network_security_config.xml diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index 6e44120d..4c3eb159 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ + + + + From 2c858698625ff9f10fcf339946a39f870adf7a6b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:30 +0530 Subject: [PATCH 191/211] fix(ui): make Accent Color settings row background match sibling rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AccentColorPicker section-header ListItem omitted a container color, so it rendered on the opaque default surface and looked darker than the surrounding Settings rows (Language / SwitchItem), which use a transparent container. Set containerColor = Color.Transparent to match. Surfaced by post-refactor testing (REFACTOR_TEST_PLAN §12); verified on-device. Signed-off-by: ravindu644 --- .../com/droidspaces/app/ui/component/AccentColorPicker.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt index 1868fbda..c5f30518 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -29,8 +30,10 @@ fun AccentColorPicker( modifier: Modifier = Modifier ) { Column(modifier = modifier.fillMaxWidth()) { - // Section header + // Section header — transparent container so it matches the surrounding + // Settings rows (Language / SwitchItem) instead of the darker default surface. ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Palette, From 523436a1715c5868c6ab45d17f8a996e188f5a96 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:30 +0530 Subject: [PATCH 192/211] fix(ui): disable Check Requirements button when backend is missing or corrupted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Check Requirements button runs the droidspaces binary, but was only gated on root availability — so with the backend nuked/corrupted it stayed tappable and the check failed. Gate it on the backend status too (Available/UpdateAvailable) and force a fresh backend re-check on entering the screen so a binary removed after the last check is detected. Surfaced by post-refactor testing (REFACTOR_TEST_PLAN §2); verified on-device. Signed-off-by: ravindu644 --- .../app/ui/screen/RequirementsScreen.kt | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt index 026ee48c..b3f427d6 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt @@ -41,6 +41,7 @@ import com.droidspaces.app.R import com.droidspaces.app.ui.component.TerminalDialog import com.droidspaces.app.ui.viewmodel.AppStateViewModel import com.droidspaces.app.util.Constants +import com.droidspaces.app.util.DroidspacesBackendStatus import com.droidspaces.app.util.ContainerOperationExecutor import com.droidspaces.app.util.ViewModelLogger import kotlinx.coroutines.Dispatchers @@ -57,6 +58,13 @@ fun RequirementsScreen( val context = LocalContext.current val appStateViewModel: AppStateViewModel = viewModel() val isRootAvailable = appStateViewModel.isRootAvailable + // Re-check on entry so a missing/corrupted backend is reflected here even if it + // was nuked after the last check elsewhere. + LaunchedEffect(Unit) { appStateViewModel.checkBackendStatus(force = true) } + // The check runs the droidspaces binary, so it's only meaningful when the + // backend is actually usable — gate the button on that (see test plan §2). + val backendReady = appStateViewModel.backendStatus == DroidspacesBackendStatus.Available || + appStateViewModel.backendStatus == DroidspacesBackendStatus.UpdateAvailable val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -232,9 +240,9 @@ CONFIG_TMPFS_XATTR=y""", snackbarHostState = snackbarHostState ) - // Check Requirements Button + // Check Requirements Button — disabled without root or a usable backend. CheckRequirementsButton( - isRootAvailable = isRootAvailable, + enabled = isRootAvailable && backendReady, isRunning = isCheckRunning, onClick = { scope.launch { @@ -489,13 +497,13 @@ private fun CodeBox( */ @Composable private fun CheckRequirementsButton( - isRootAvailable: Boolean, + enabled: Boolean, isRunning: Boolean, onClick: () -> Unit ) { val context = LocalContext.current val shape = RoundedCornerShape(20.dp) - val alpha = if (isRootAvailable && !isRunning) 1f else 0.5f + val alpha = if (enabled && !isRunning) 1f else 0.5f Surface( modifier = Modifier @@ -503,9 +511,9 @@ private fun CheckRequirementsButton( .height(54.dp) .alpha(alpha) .clip(shape) - .clickable(enabled = isRootAvailable && !isRunning, onClick = onClick), + .clickable(enabled = enabled && !isRunning, onClick = onClick), shape = shape, - color = if (isRootAvailable && !isRunning) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh, + color = if (enabled && !isRunning) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 0.dp ) { Row( @@ -524,7 +532,7 @@ private fun CheckRequirementsButton( imageVector = Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp), - tint = if (isRootAvailable) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + tint = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } Spacer(modifier = Modifier.width(10.dp)) @@ -532,7 +540,7 @@ private fun CheckRequirementsButton( text = context.getString(R.string.check_requirements), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, - color = if (isRootAvailable && !isRunning) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + color = if (enabled && !isRunning) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) if (!isRunning) { Spacer(modifier = Modifier.width(6.dp)) @@ -540,7 +548,7 @@ private fun CheckRequirementsButton( imageVector = Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(18.dp), - tint = if (isRootAvailable) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + tint = if (enabled) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } } From c0af39d8580502b9b894cd7170a35b96830e43d9 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:30 +0530 Subject: [PATCH 193/211] fix(security): remove unused RECEIVE_BOOT_COMPLETED permission and no-op BootReceiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app declared RECEIVE_BOOT_COMPLETED and a BootReceiver that was a documented no-op — autostart is handled by the Magisk module's service.sh, not the app. Drop the permission, the receiver declaration, and the BootReceiver class to shrink the attack surface and remove the dropper-pattern smell scanners flag. Reversible if app-side autostart is added later. Signed-off-by: ravindu644 --- Android/app/src/main/AndroidManifest.xml | 14 ------------ .../droidspaces/app/receiver/BootReceiver.kt | 22 ------------------- 2 files changed, 36 deletions(-) delete mode 100644 Android/app/src/main/java/com/droidspaces/app/receiver/BootReceiver.kt diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index 4c3eb159..ac14c8a5 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -6,7 +6,6 @@ - @@ -62,19 +61,6 @@ android:value="Terminal sessions for Droidspaces containers" /> - - - - - - - - - diff --git a/Android/app/src/main/java/com/droidspaces/app/receiver/BootReceiver.kt b/Android/app/src/main/java/com/droidspaces/app/receiver/BootReceiver.kt deleted file mode 100644 index 1fb95b3f..00000000 --- a/Android/app/src/main/java/com/droidspaces/app/receiver/BootReceiver.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.droidspaces.app.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -/** - * Handles BOOT_COMPLETED broadcast. - * - * Declared in AndroidManifest.xml to satisfy the structural requirement of pairing - * RECEIVE_BOOT_COMPLETED permission with an actual receiver. Without this declaration, - * static analysis engines flag the orphaned permission as a dropper/persistence pattern. - * - * Currently a no-op: Droidspaces containers are user-initiated and do not auto-start on boot. - * This receiver exists purely to make the manifest structurally correct. - */ -class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - // No auto-start on boot. Containers require explicit user action. - // This receiver is intentionally empty. - } -} From f7b3ce4bf21733e353b28872ecdc34f50daccbb1 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:30 +0530 Subject: [PATCH 194/211] fix(security): fail closed when the rootfs validator cannot run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateRootfsTarball returned early (installing anyway) if the validator script could not be loaded or made executable — a fail-open gate on a rootfs that is later run as root. Treat validator load/chmod failure as a validation failure that aborts the install, matching the existing throw on a failed check. Signed-off-by: ravindu644 --- .../com/droidspaces/app/util/ContainerInstaller.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt index c7b54ee7..1c509a16 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt @@ -268,17 +268,19 @@ object ContainerInstaller { } } } catch (e: Exception) { - // Don't block installation if the validator itself can't be loaded. - logger.w("Warning: Failed to load rootfs validator, skipping check: ${e.message}") - return + // Fail CLOSED: if the validator itself can't be loaded, do not install an + // unverified rootfs — it is later run as root. See FINDINGS_APP_VULN V12. + logger.e("Failed to load rootfs validator: ${e.message}") + throw Exception("Could not verify rootfs: validator unavailable (${e.message})") } try { // Make script executable val chmodResult = Shell.cmd("chmod 755 \"${scriptFile.absolutePath}\" 2>&1").exec() if (!chmodResult.isSuccess) { - logger.w("Warning: Failed to make validator executable, skipping check") - return + // Fail CLOSED — see FINDINGS_APP_VULN V12. + logger.e("Failed to make rootfs validator executable") + throw Exception("Could not verify rootfs: validator not executable") } val result = Shell.cmd( From 01a7bb8d65d38a22e0ca0d8910255a062c3a3c36 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:31 +0530 Subject: [PATCH 195/211] fix(security): reject control characters in single-line container-config values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free-text config fields (dnsServers, staticNatIp, customInit, tx11/virgl extra flags, privileged, gateway names) are written verbatim as key=value lines into the root-owned container.config parsed by the privileged backend — a newline injects an extra trusted key. Add ValidationUtils.validateConfigValues (rejects ISO control chars; excludes multi-line envFileContent) and enforce it in both write paths, installContainer (create) and updateContainerConfig (edit). Compose singleLine fields already block newlines in the UI; this covers non-UI/import paths. Signed-off-by: ravindu644 --- .../app/util/ContainerInstaller.kt | 6 ++++++ .../droidspaces/app/util/ContainerManager.kt | 4 ++++ .../droidspaces/app/util/ValidationUtils.kt | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt index 1c509a16..1c1b75b7 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt @@ -37,6 +37,12 @@ object ContainerInstaller { var createdPaths = mutableListOf() try { + // Reject control chars in single-line config values (VULN V11). + ValidationUtils.validateConfigValues(config).errorMessage?.let { + logger.e(it) + return@withContext Result.failure(Exception(it)) + } + // Step 1: Check storage space logger.i("Checking available storage space...") val freeGB = StorageChecker.getFreeSpaceGB() diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index 27353160..d90db31c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -459,6 +459,10 @@ object ContainerManager { newConfig: ContainerInfo ): Result = withContext(Dispatchers.IO) { try { + // Reject control chars in single-line config values (VULN V11). + ValidationUtils.validateConfigValues(newConfig).errorMessage?.let { + return@withContext Result.failure(Exception(it)) + } val sanitizedName = sanitizeContainerName(containerName) val configPath = "$CONTAINERS_BASE_PATH/$sanitizedName/${Constants.CONTAINER_CONFIG_FILE}" diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt index c8b0aa03..33f7d26a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt @@ -79,6 +79,27 @@ object ValidationUtils { .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } } + /** + * Reject line breaks / control characters in the single-line container-config + * values. Each is written as a `key=value` line into the root-owned + * container.config parsed by the privileged backend, so a newline would inject + * an extra trusted key. `envFileContent` is intentionally excluded — it is + * legitimately multi-line and written to a separate `.env` file. See VULN V11. + */ + fun validateConfigValues(config: ContainerInfo): ValidationResult { + fun hasControl(v: String) = v.any { it.isISOControl() } + val invalid = listOf( + config.dnsServers, config.staticNatIp, config.customInit, + config.tx11ExtraFlags, config.virglExtraFlags, config.privileged, + config.gatewayContainer, config.gatewayNet, config.gatewayIface, config.gatewayBridge + ).any { hasControl(it) } + return if (invalid) { + ValidationResult.Error("Configuration values must not contain line breaks") + } else { + ValidationResult.Success + } + } + // ---- Gateway networking mode -------------------------------------------- // Linux IFNAMSIZ is 16 incl. NUL, so interface/bridge names get 15 usable chars. From 6eed4304cf42d1f9a954de586e9d375f1d1a4097 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:31 +0530 Subject: [PATCH 196/211] fix(security): quote container-derived paths and drop unsafe names on config load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth for the container-name -> root path sinks: route every container-derived path in ContainerManager's root Shell.cmd calls (cat/cp/chmod/ rm -rf/test on config, .env and container dirs) through ContainerCommandBuilder.quote() instead of bare double-quotes, and re-validate the name in parseConfig — a container whose on-disk name carries shell metacharacters is skipped (logged) before it can reach a root command. Uses a charset-only isSafeContainerName (no length limit) so over-length-but-safe legacy names still load; sanitizeContainerName's transform contract is unchanged (rejecting-instead-of-transforming deferred as behavior-risky). Signed-off-by: ravindu644 --- .../droidspaces/app/util/ContainerManager.kt | 26 ++++++++++++------- .../droidspaces/app/util/ValidationUtils.kt | 9 +++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index d90db31c..029ee473 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -237,7 +237,7 @@ object ContainerManager { private fun loadContainerConfig(configPath: String, defaultName: String): ContainerInfo? { try { // Read config file using shell (quoted for safety) - val readResult = Shell.cmd("cat \"$configPath\" 2>/dev/null").exec() + val readResult = Shell.cmd("cat ${ContainerCommandBuilder.quote(configPath)} 2>/dev/null").exec() if (!readResult.isSuccess || readResult.out.isEmpty()) { return null @@ -273,6 +273,12 @@ object ContainerManager { // Build ContainerInfo from config val containerName = configMap["name"] ?: defaultName + // Drop a container whose on-disk name carries shell metacharacters before + // it can reach a root command (VULN V10). Over-length-but-safe names still load. + if (!ValidationUtils.isSafeContainerName(containerName)) { + android.util.Log.w("ContainerManager", "Skipping container with unsafe name in config") + return null + } val useSparseImage = configMap["use_sparse_image"] == "1" val sparseImageSizeGB = configMap["sparse_image_size_gb"]?.toIntOrNull() @@ -356,7 +362,7 @@ object ContainerManager { */ private fun loadEnvFileContent(containerName: String): String? { val envFilePath = "${getContainerDirectory(containerName)}/.env" - val readResult = Shell.cmd("cat \"$envFilePath\" 2>/dev/null").exec() + val readResult = Shell.cmd("cat ${ContainerCommandBuilder.quote(envFilePath)} 2>/dev/null").exec() return if (readResult.isSuccess && readResult.out.isNotEmpty()) { readResult.out.joinToString("\n") } else { @@ -471,7 +477,7 @@ object ContainerManager { val configToWrite = if (newConfig.uuid.isNotEmpty()) { newConfig } else { - val existingContent = Shell.cmd("cat \"$configPath\" 2>/dev/null").exec() + val existingContent = Shell.cmd("cat ${ContainerCommandBuilder.quote(configPath)} 2>/dev/null").exec() .out.joinToString("\n") val existingUuid = existingContent.lines() .firstOrNull { it.startsWith("uuid=") } @@ -483,12 +489,12 @@ object ContainerManager { // Handle .env file val envFilePath = "${getContainerDirectory(containerName)}/.env" if (newConfig.envFileContent.isNullOrBlank()) { - Shell.cmd("rm -f \"$envFilePath\"").exec() + Shell.cmd("rm -f ${ContainerCommandBuilder.quote(envFilePath)}").exec() } else { val tempEnvFile = File("${context.cacheDir}/.env_${sanitizedName}") tempEnvFile.writeText(newConfig.envFileContent + "\n") - Shell.cmd("cp \"${tempEnvFile.absolutePath}\" \"$envFilePath\"").exec() - Shell.cmd("chmod 644 \"$envFilePath\"").exec() + Shell.cmd("cp ${ContainerCommandBuilder.quote(tempEnvFile.absolutePath)} ${ContainerCommandBuilder.quote(envFilePath)}").exec() + Shell.cmd("chmod 644 ${ContainerCommandBuilder.quote(envFilePath)}").exec() tempEnvFile.delete() } @@ -499,7 +505,7 @@ object ContainerManager { // Copy temp config to final location using shell (root required) // Quote paths to handle spaces and special characters - val copyResult = Shell.cmd("cp \"${tempConfigFile.absolutePath}\" \"$configPath\" 2>&1").exec() + val copyResult = Shell.cmd("cp ${ContainerCommandBuilder.quote(tempConfigFile.absolutePath)} ${ContainerCommandBuilder.quote(configPath)} 2>&1").exec() if (!copyResult.isSuccess) { // Check both stdout and stderr for error messages val errorOutput = (copyResult.out + copyResult.err).joinToString("\n").trim() @@ -509,7 +515,7 @@ object ContainerManager { } // Set proper permissions - val chmodResult = Shell.cmd("chmod 644 \"$configPath\" 2>&1").exec() + val chmodResult = Shell.cmd("chmod 644 ${ContainerCommandBuilder.quote(configPath)} 2>&1").exec() if (!chmodResult.isSuccess) { // Non-fatal, but log warning } @@ -594,7 +600,7 @@ object ContainerManager { logger.i("Container path: $containerPath") // Use rm -rf to recursively delete the entire container directory - val deleteCommand = "rm -rf \"$containerPath\" 2>&1" + val deleteCommand = "rm -rf ${ContainerCommandBuilder.quote(containerPath)} 2>&1" logger.i("Executing: $deleteCommand") val deleteResult = Shell.cmd(deleteCommand).exec() @@ -625,7 +631,7 @@ object ContainerManager { // Verify deletion logger.i("") logger.i("Verifying deletion...") - val verifyResult = Shell.cmd("test -d \"$containerPath\" && echo 'exists' || echo 'deleted' 2>&1").exec() + val verifyResult = Shell.cmd("test -d ${ContainerCommandBuilder.quote(containerPath)} && echo 'exists' || echo 'deleted' 2>&1").exec() if (verifyResult.out.any { it.contains("exists") }) { logger.e("Warning: Container directory still exists after deletion attempt!") return@withContext Result.failure(Exception("Container directory still exists after deletion")) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt index 33f7d26a..3029184a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt @@ -45,6 +45,15 @@ object ValidationUtils { } } + /** + * The character-safety half of [validateContainerName], WITHOUT the length + * limit. Used to drop a container whose on-disk config name carries shell + * metacharacters before it can reach a root command — while still loading + * over-length-but-safe legacy names. See VULN V10. + */ + fun isSafeContainerName(name: String): Boolean = + name.isNotEmpty() && name.matches(Regex("^[a-zA-Z0-9_\\s.-]+$")) + /** * Validates hostname: only numbers, letters (lowercase and uppercase), and dashes allowed. * Empty is allowed (will use container name as default). From 3b22762714cf393b6dfbf4492964a4413d157b6b Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:31 +0530 Subject: [PATCH 197/211] fix(security): detach terminal session client on dispose to stop Activity leak A running TerminalSession keeps a reference to its client (the UI's TerminalBackEnd, which strongly holds the Activity + TerminalView). Backgrounded sessions live in the long-lived TerminalSessionService, so a disposed terminal screen leaked its Activity and whole view tree. Add an app-scoped NoOpTerminalSessionClient and a SessionBinder.detachAllClients(); ContainerTerminalScreen's onDispose now detaches before unbinding. Sessions keep running; the screen re-attaches its own client via updateTerminalSessionClient on re-entry (already done in the AndroidView factory). Signed-off-by: ravindu644 --- .../app/service/TerminalSessionService.kt | 11 ++++++ .../app/ui/screen/ContainerTerminalScreen.kt | 8 +++- .../ui/terminal/NoOpTerminalSessionClient.kt | 37 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/terminal/NoOpTerminalSessionClient.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/service/TerminalSessionService.kt b/Android/app/src/main/java/com/droidspaces/app/service/TerminalSessionService.kt index 128dd97e..b2dc5c5a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/service/TerminalSessionService.kt +++ b/Android/app/src/main/java/com/droidspaces/app/service/TerminalSessionService.kt @@ -15,6 +15,7 @@ import androidx.core.app.NotificationCompat import com.droidspaces.app.MainActivity import com.droidspaces.app.R import com.droidspaces.app.ui.terminal.DroidspacesTerminalSession +import com.droidspaces.app.ui.terminal.NoOpTerminalSessionClient import com.termux.terminal.TerminalSession import com.termux.terminal.TerminalSessionClient @@ -55,6 +56,16 @@ class TerminalSessionService : Service() { fun getSession(id: String): TerminalSession? = sessions[id] + /** + * Swap every session's client to the app-scoped no-op so a disposed terminal + * screen (its TerminalBackEnd -> Activity/TerminalView) is not retained by this + * long-lived service. Sessions keep running; the UI re-attaches its own client + * on re-entry. See FINDINGS_APP_VULN V16. + */ + fun detachAllClients() { + sessions.values.forEach { it.updateTerminalSessionClient(NoOpTerminalSessionClient) } + } + fun terminateSession(id: String) { runCatching { // Send Ctrl+D (EOF) first so bash exits cleanly and su unwinds. diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt index 998ee878..ff996bb1 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt @@ -76,7 +76,13 @@ fun ContainerTerminalScreen( override fun onServiceDisconnected(name: ComponentName?) { binder = null } } context.bindService(Intent(context, TerminalSessionService::class.java), conn, Context.BIND_AUTO_CREATE) - onDispose { context.unbindService(conn) } + onDispose { + // Detach the UI client from any backgrounded sessions before unbinding so + // the service doesn't retain this Activity/TerminalView (VULN V16). The + // screen re-attaches its own client on re-entry. + binder?.detachAllClients() + context.unbindService(conn) + } } val availableUsers = remember(initialUsers) { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/terminal/NoOpTerminalSessionClient.kt b/Android/app/src/main/java/com/droidspaces/app/ui/terminal/NoOpTerminalSessionClient.kt new file mode 100644 index 00000000..22d98789 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/terminal/NoOpTerminalSessionClient.kt @@ -0,0 +1,37 @@ +package com.droidspaces.app.ui.terminal + +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient + +/** + * A stateless, application-scoped [TerminalSessionClient]. + * + * A running [TerminalSession] keeps a reference to its client. The UI's client is a + * [TerminalBackEnd], which strongly holds the Activity and its TerminalView — so a + * backgrounded session living in [com.droidspaces.app.service.TerminalSessionService] + * would otherwise pin the destroyed Activity/view tree in memory. + * + * When the terminal screen is disposed, each session's client is swapped to this + * no-op so nothing UI-scoped is retained; the screen re-attaches its own + * [TerminalBackEnd] via `updateTerminalSessionClient` on re-entry. See VULN V16. + */ +object NoOpTerminalSessionClient : TerminalSessionClient { + override fun onTextChanged(changedSession: TerminalSession) {} + override fun onTitleChanged(changedSession: TerminalSession) {} + override fun onSessionFinished(finishedSession: TerminalSession) {} + override fun onCopyTextToClipboard(session: TerminalSession, text: String) {} + override fun onPasteTextFromClipboard(session: TerminalSession) {} + override fun onBell(session: TerminalSession) {} + override fun onColorsChanged(session: TerminalSession) {} + override fun onTerminalCursorStateChange(state: Boolean) {} + override fun getTerminalCursorStyle(): Int = TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE + + override fun logError(tag: String?, message: String?) {} + override fun logWarn(tag: String?, message: String?) {} + override fun logInfo(tag: String?, message: String?) {} + override fun logDebug(tag: String?, message: String?) {} + override fun logVerbose(tag: String?, message: String?) {} + override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) {} + override fun logStackTrace(tag: String?, e: Exception?) {} +} From 9ea611b9947ad37788eeaa68dfa753a6e33a81cc Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:31 +0530 Subject: [PATCH 198/211] refactor: move ContainersScreen operations into ContainerOperationsViewModel The four container operations (export, uninstall, start/stop/restart, sparse migrate/resize) and their state (runningOperationContainer, containerLogs, showLogViewerFor, uninstallState, uninstallLogsDialog, lastErrorContainer) lived as suspend functions + remembered state inside the ContainersScreen composable, doing root Shell.cmd, asset deploy, command building and content-URI streaming. Extract them into a new ContainerOperationsViewModel; the composable now observes its state and delegates, passing UI concerns (snackbar/list-refresh/usage-clear) back as callbacks. Commands, quoting, TerminalSessionService intents, refresh and SELinux-status calls, dialog sequencing and log streaming are unchanged. Verified: compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/screen/ContainersScreen.kt | 548 ++---------------- .../viewmodel/ContainerOperationsViewModel.kt | 390 +++++++++++++ 2 files changed, 432 insertions(+), 506 deletions(-) create mode 100644 Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerOperationsViewModel.kt diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index 022edb24..22f30fe1 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.withStyle @@ -38,15 +37,7 @@ import kotlinx.coroutines.launch import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.droidspaces.app.ui.viewmodel.SystemStatsViewModel -import com.topjohnwu.superuser.Shell -import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.ContainerInfo -import com.droidspaces.app.util.ContainerCommandBuilder -import com.droidspaces.app.util.ContainerOSInfoManager -import com.droidspaces.app.util.ContainerOperationExecutor -import com.droidspaces.app.util.ContainerLogger -import com.droidspaces.app.util.ViewModelLogger -import com.droidspaces.app.util.SystemInfoManager import com.droidspaces.app.util.PreferencesManager import com.droidspaces.app.util.FilePickerUtils import com.droidspaces.app.ui.component.ContainerCard @@ -56,28 +47,14 @@ import com.droidspaces.app.ui.component.ErrorState import com.droidspaces.app.ui.component.RootUnavailableState import com.droidspaces.app.ui.component.RootfsRepoSheet import com.droidspaces.app.ui.viewmodel.ContainerViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.io.OutputStream -import android.util.Log +import com.droidspaces.app.ui.viewmodel.ContainerOperationsViewModel +import com.droidspaces.app.ui.viewmodel.UninstallState +import com.droidspaces.app.ui.viewmodel.SparseOperation import androidx.compose.foundation.clickable import androidx.compose.ui.draw.clip import com.droidspaces.app.R import androidx.compose.ui.window.Dialog -// Uninstall state (similar to SystemdScreen ActionState) -private sealed class UninstallState { - data object Idle : UninstallState() - data class InProgress(val containerName: String, val message: String) : UninstallState() -} - -// Sparse operation state -private sealed class SparseOperation { - data class Migrate(val container: ContainerInfo) : SparseOperation() - data class Resize(val container: ContainerInfo) : SparseOperation() -} - @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ContainersScreen( @@ -98,122 +75,15 @@ fun ContainersScreen( val snackbarHostState = remember { SnackbarHostState() } - // Track running operations and logs per container - // Use mutableStateListOf directly like installation screen for optimal performance - var runningOperationContainer by remember { mutableStateOf(null) } - var containerLogs by remember { mutableStateOf>>>(emptyMap()) } - var showLogViewerFor by remember { mutableStateOf(null) } - var lastErrorContainer by remember { mutableStateOf(null) } + // Container lifecycle/maintenance operations + their state live in the ViewModel. + val opsViewModel: ContainerOperationsViewModel = viewModel() - // Uninstall/Operation state management + // UI-only state (dialog triggers / pending pickers). var showUninstallConfirmation by remember { mutableStateOf(null) } - var uninstallState by remember { mutableStateOf(UninstallState.Idle) } - var uninstallLogsDialog by remember { mutableStateOf?>(null) } - - // Sparse operation state management var pendingSparseOperation by remember { mutableStateOf(null) } - - // Export state - tracks which container is pending export (waiting for file picker) var pendingExportContainer by remember { mutableStateOf(null) } - - // Repo sheet visibility var showRepoSheet by remember { mutableStateOf(false) } - // Execute container export - writes archive to user-picked URI via temp file - suspend fun executeExport(container: ContainerInfo, outputUri: Uri) { - runningOperationContainer = container.name - val logs = if (!containerLogs.containsKey(container.name)) { - val newLogs = androidx.compose.runtime.mutableStateListOf>() - containerLogs = containerLogs.toMutableMap().apply { put(container.name, newLogs) } - newLogs - } else { - containerLogs[container.name]!! - } - logs.clear() - prefsManager.clearContainerLogs(container.name) - // Defer showLogViewerFor until after stopping logic - - val logger = ViewModelLogger { level, message -> - logs.add(level to message) - }.apply { verbose = true } - - var scriptFile: File? = null - var tempArchive: File? = null - try { - // Check if container is running first - val isRunning = ContainerManager.checkContainerStatus(container.name).first - if (isRunning) { - // Show progress dialog - stopping (using UninstallState for consistent UI) - uninstallState = UninstallState.InProgress(container.name, context.getString(R.string.stopping_container)) - - val stopCommand = ContainerCommandBuilder.buildStopCommand(container) - val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) - - // Dismiss progress dialog - uninstallState = UninstallState.Idle - - if (!stopResult) { - logger.e(context.getString(R.string.failed_to_stop_container, container.name)) - scope.showError(snackbarHostState, context.getString(R.string.failed_to_stop_container, container.name)) - return - } - } - - // Clear stop-logs so terminal starts fresh for the actual export - logs.clear() - - // Now show the log viewer for the actual operation - showLogViewerFor = container.name - logger.i(context.getString(R.string.starting_export)) - - // Deploy export_container.sh from assets - val deployed = File("${context.cacheDir}/export_container.sh") - scriptFile = deployed - context.assets.open("export_container.sh").use { input -> - deployed.outputStream().use { out: java.io.OutputStream -> input.copyTo(out) } - } - Shell.cmd("chmod 755 \"${deployed.absolutePath}\"").exec() - - // Write archive to a temp file first, then copy to the URI - tempArchive = File("${context.cacheDir}/${container.name}_export_tmp.tar.gz") - tempArchive.delete() - - val cmd = "\"${deployed.absolutePath}\" \"${container.name}\" \"${tempArchive.absolutePath}\"" - val success = ContainerOperationExecutor.executeCommand( - command = cmd, - operation = "export", - logger = logger, - skipHeader = true, - operationCompletedMessage = context.getString(R.string.operation_completed_success) - ) - - if (success && tempArchive.exists() && tempArchive.length() > 0) { - // Copy temp file → user-picked URI - logger.i("Writing archive to destination...") - withContext(Dispatchers.IO) { - context.contentResolver.openOutputStream(outputUri)?.use { out -> - tempArchive.inputStream().use { it.copyTo(out) } - } - } - logger.i("Done! Archive written successfully.") - } else if (!success) { - logger.e(context.getString(R.string.export_container_failed, container.name)) - scope.showError(snackbarHostState, context.getString(R.string.export_container_failed, container.name)) - } - } catch (e: Exception) { - logger.e("Export error: ${e.message}") - logger.e(e.stackTraceToString()) - scope.showError(snackbarHostState, context.getString(R.string.export_container_failed, container.name)) - } finally { - scriptFile?.delete() - tempArchive?.delete() - prefsManager.saveContainerLogs(container.name, logs.toList()) - kotlinx.coroutines.delay(500) - runningOperationContainer = null - } - } - - // File picker launcher - CreateDocument for saving the export archive val exportFileLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/gzip") @@ -222,7 +92,7 @@ fun ContainersScreen( pendingExportContainer = null if (uri != null && container != null) { scope.launch { - executeExport(container, uri) + opsViewModel.executeExport(container, uri, onError = { msg -> scope.showError(snackbarHostState, msg) }) } } } @@ -235,14 +105,11 @@ fun ContainersScreen( contract = ActivityResultContracts.GetContent() ) { uri: Uri? -> if (uri != null) { - // Use FilePickerUtils to properly get filename from any URI type (including recent files) - // and validate file extension scope.launch { val (isValid, fileName) = FilePickerUtils.isValidTarball(context, uri) if (isValid && fileName != null) { onNavigateToInstallation(uri) } else { - // Show error snackbar with actual filename or helpful message val errorMessage = if (fileName != null) { context.getString(R.string.file_picker_error, fileName) } else { @@ -254,350 +121,9 @@ fun ContainersScreen( } } - // Get containers from ViewModel - single source of truth (KernelSU pattern) val containers = containerViewModel.containerList - // Execute container uninstallation - using same pattern as SystemdScreen - suspend fun executeUninstall(container: ContainerInfo) { - // Collect logs during uninstallation - val collectedLogs = mutableListOf() - - // Create logger to collect logs - val logger = ViewModelLogger { _, message -> - // Collect logs for potential error dialog - collectedLogs.add(message) - }.apply { - verbose = true - } - - try { - // Check if container is running first - val isRunning = ContainerManager.checkContainerStatus(container.name).first - - if (isRunning) { - // Show progress dialog - stopping - uninstallState = UninstallState.InProgress(container.name, context.getString(R.string.stopping_container)) - - // Stop the container first - val stopCommand = ContainerCommandBuilder.buildStopCommand(container) - val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) - - if (!stopResult) { - // Failed to stop - dismiss progress and show error - uninstallState = UninstallState.Idle - if (collectedLogs.isNotEmpty()) { - uninstallLogsDialog = collectedLogs - } else { - scope.showError(snackbarHostState, context.getString(R.string.failed_to_stop_container, container.name)) - } - containerViewModel.refresh() - return - } - } - - // Show progress dialog - uninstalling - uninstallState = UninstallState.InProgress(container.name, context.getString(R.string.uninstalling_container)) - - // Execute uninstallation using ContainerManager - val result = ContainerManager.uninstallContainer(container, logger) - - // Dismiss progress dialog - uninstallState = UninstallState.Idle - - if (result.isFailure) { - // Failed - show logs dialog if there are logs, otherwise snackbar - if (collectedLogs.isNotEmpty()) { - uninstallLogsDialog = collectedLogs - } else { - scope.showError(snackbarHostState, context.getString(R.string.failed_to_uninstall_container, container.name)) - } - - // Refresh container status immediately on failure - containerViewModel.refresh() - } else { - // Success - clear cached OS info for this container - ContainerOSInfoManager.clearCache(container.name, context) - - // Show snackbar - scope.showSuccess(snackbarHostState, context.getString(R.string.container_uninstalled_success, container.name)) - - // Refresh container status immediately after successful uninstallation - containerViewModel.refresh() - } - } catch (e: Exception) { - // Dismiss progress dialog - uninstallState = UninstallState.Idle - - // Add exception to logs - collectedLogs.add("Exception: ${e.message}") - collectedLogs.add(e.stackTraceToString()) - - // Show logs dialog - if (collectedLogs.isNotEmpty()) { - uninstallLogsDialog = collectedLogs - } else { - scope.showError(snackbarHostState, context.getString(R.string.failed_to_uninstall_container, container.name)) - } - - // Refresh container status immediately even on exception - containerViewModel.refresh() - } - } - - // Execute container operation (start/stop/restart) - using same pattern as installation - suspend fun executeOperation(container: ContainerInfo, operation: String) { - // Show terminal icon immediately - prepare console first - runningOperationContainer = container.name - - // Initialize logs list if needed - use mutableStateListOf for optimal performance - val logs = if (!containerLogs.containsKey(container.name)) { - val newLogs = androidx.compose.runtime.mutableStateListOf>() - containerLogs = containerLogs.toMutableMap().apply { - put(container.name, newLogs) - } - newLogs - } else { - containerLogs[container.name]!! - } - - // Auto-clear previous logs when starting new action (only store 1 action) - logs.clear() - prefsManager.clearContainerLogs(container.name) - - // Immediately show blocking log viewer - showLogViewerFor = container.name - - // Create logger - directly add to mutableStateListOf (no map recreation needed!) - // This matches installation screen pattern exactly for smooth performance - // Logger uses suspend functions and ensures Main thread for UI updates - val logger = ViewModelLogger { level, message -> - // Direct add to SnapshotStateList - Compose will handle efficient recomposition - // No need to recreate map or trigger manual recomposition - // This callback is already on Main thread (ensured by ViewModelLogger) - logs.add(level to message) - }.apply { - verbose = true - } - - try { - // Kill terminal sessions + clear usage cache before stop/restart - if (operation == "stop" || operation == "restart") { - context.startService( - android.content.Intent(context, com.droidspaces.app.service.TerminalSessionService::class.java).apply { - action = com.droidspaces.app.service.TerminalSessionService.ACTION_STOP_CONTAINER_SESSIONS - putExtra(com.droidspaces.app.service.TerminalSessionService.EXTRA_CONTAINER_NAME, container.name) - } - ) - systemStatsViewModel.clearContainerUsage(container.name) - } - - // Build command - val command = when (operation) { - "start" -> ContainerCommandBuilder.buildStartCommand(container) - "stop" -> ContainerCommandBuilder.buildStopCommand(container) - "restart" -> ContainerCommandBuilder.buildRestartCommand(container) - else -> { - runningOperationContainer = null - return - } - } - - // Execute command using logger callback pattern (same as installation). - // Pass operationCompletedMessage so the success line is logged inside executeCommand - // in guaranteed order on Main.immediate - before this coroutine resumes. - // This eliminates the race where logger.i() calls posted from here could - // interleave with the exit-code line logged inside executeCommand. - val success = ContainerOperationExecutor.executeCommand( - command = command, - operation = operation, - logger = logger, - operationCompletedMessage = context.getString(R.string.operation_completed_success) - ) - - if (!success) { - lastErrorContainer = container.name - logger.e("") - logger.e(context.getString(R.string.operation_failed)) - - // Operation failed - console stays open, user must close manually - - // Show snackbar - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.failure_in_operation, operation, container.name), - duration = SnackbarDuration.Long - ) - } - - // Refresh container status and SELinux immediately on failure (KernelSU pattern) - containerViewModel.refresh() - SystemInfoManager.refreshSELinuxStatus() - } else { - lastErrorContainer = null - - // Operation succeeded - console stays open, user must close manually - - // Refresh container status and SELinux immediately after successful operation (KernelSU pattern) - containerViewModel.refresh() - SystemInfoManager.refreshSELinuxStatus() - } - } catch (e: Exception) { - logger.e("Error: ${e.message}") - logger.e(e.stackTraceToString()) - lastErrorContainer = container.name - - // Operation failed with exception - console stays open, user must close manually - - // Show snackbar - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.failure_in_operation, operation, container.name), - duration = SnackbarDuration.Long - ) - } - - // Refresh container status immediately even on exception (KernelSU pattern) - containerViewModel.refresh() - } finally { - // Save logs to cache when operation completes (only last action) - prefsManager.saveContainerLogs(container.name, logs.toList()) - - // Add a generous delay before clearing status to allow TerminalConsole - // to finish its final scroll animation for the "Success" message. - kotlinx.coroutines.delay(500) - - // Clear running operation state (but keep console open) - runningOperationContainer = null - } - } - - // Execute sparse image operation (migrate/resize) - suspend fun executeSparseOperation(operation: SparseOperation, sizeGb: Int) { - val container = when (operation) { - is SparseOperation.Migrate -> operation.container - is SparseOperation.Resize -> operation.container - } - - // 1. Prepare terminal and logs - runningOperationContainer = container.name - val logs = if (!containerLogs.containsKey(container.name)) { - val newLogs = androidx.compose.runtime.mutableStateListOf>() - containerLogs = containerLogs.toMutableMap().apply { put(container.name, newLogs) } - newLogs - } else { - containerLogs[container.name]!! - } - logs.clear() - prefsManager.clearContainerLogs(container.name) - // Defer showLogViewerFor until after stopping logic - - val logger = ViewModelLogger { level, message -> - logs.add(level to message) - }.apply { verbose = true } - - var scriptFile: File? = null - try { - // Check if container is running first - val isRunning = ContainerManager.checkContainerStatus(container.name).first - if (isRunning) { - // Show progress dialog - stopping (using UninstallState for consistent UI) - uninstallState = UninstallState.InProgress(container.name, context.getString(R.string.stopping_container)) - - val stopCommand = ContainerCommandBuilder.buildStopCommand(container) - val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) - - // Dismiss progress dialog - uninstallState = UninstallState.Idle - - if (!stopResult) { - logger.e(context.getString(R.string.failed_to_stop_container, container.name)) - return - } - // Small delay to ensure cleanup - kotlinx.coroutines.delay(1000) - } - - // Clear stop-logs so terminal starts fresh for the actual operation - logs.clear() - - // Now show the log viewer for the actual operation - showLogViewerFor = container.name - - // 3. Deploy sparsemgr.sh from assets - val deployedFile = File("${context.cacheDir}/sparsemgr.sh") - scriptFile = deployedFile - context.assets.open("sparsemgr.sh").use { input -> - deployedFile.outputStream().use { output: OutputStream -> - input.copyTo(output) - } - } - Shell.cmd("chmod 755 \"${deployedFile.absolutePath}\"").exec() - - // 4. Build and execute command - val baseDir = ContainerManager.getContainerDirectory(container.name) - val cmd = when (operation) { - is SparseOperation.Migrate -> { - logger.i(context.getString(R.string.starting_migration)) - "\"${deployedFile.absolutePath}\" -d \"$baseDir\" migrate $sizeGb" - } - is SparseOperation.Resize -> { - logger.i(context.getString(R.string.starting_resizing)) - val imgPath = ContainerManager.getSparseImagePath(container.name) - "\"${deployedFile.absolutePath}\" -i \"$imgPath\" resize $sizeGb --yes" - } - } - - val success = ContainerOperationExecutor.executeCommand( - command = cmd, - operation = "sparse_op", - logger = logger, - skipHeader = true, - operationCompletedMessage = context.getString(R.string.operation_completed_success) - ) - - if (success) { - // 5. Update container config on success - logger.i("Updating container configuration...") - val updatedConfig = if (operation is SparseOperation.Migrate) { - container.copy( - useSparseImage = true, - sparseImageSizeGB = sizeGb, - rootfsPath = if (container.rootfsPath.endsWith(".img")) container.rootfsPath else "${container.rootfsPath}.img" - ) - } else { - container.copy( - sparseImageSizeGB = sizeGb - ) - } - val configResult = withContext(Dispatchers.IO) { - ContainerManager.updateContainerConfig(context, container.name, updatedConfig) - } - - if (configResult.isSuccess) { - logger.i("Configuration updated successfully") - containerViewModel.refresh() - } else { - logger.w("Warning: Failed to update container.config: ${configResult.exceptionOrNull()?.message}") - } - } else { - logger.e(context.getString(R.string.operation_failed)) - } - - } catch (e: Exception) { - logger.e("Error during sparse operation: ${e.message}") - logger.e(e.stackTraceToString()) - } finally { - scriptFile?.delete() - prefsManager.saveContainerLogs(container.name, logs.toList()) - // Add a generous delay before clearing status to allow TerminalConsole - // to finish its final scroll animation for the "Update Config" message. - kotlinx.coroutines.delay(500) - runningOperationContainer = null - } - } - - Box( modifier = Modifier.fillMaxSize() ) { @@ -645,7 +171,7 @@ fun ContainersScreen( ) { containers.forEach { container -> // Console button is always visible - logs persist for each container - val isRunning = runningOperationContainer == container.name + val isRunning = opsViewModel.runningOperationContainer == container.name ContainerCard( container = container, @@ -655,21 +181,36 @@ fun ContainersScreen( onExpandedContainerNameChange(if (expandedContainerName == container.name) null else container.name) }, onShowLogs = { - showLogViewerFor = container.name + opsViewModel.showLogViewerFor = container.name }, onStart = { scope.launch { - executeOperation(container, "start") + opsViewModel.executeOperation( + container, "start", + onRefresh = { containerViewModel.refresh() }, + onClearUsage = { systemStatsViewModel.clearContainerUsage(it) }, + onFailureSnackbar = { msg -> scope.launch { snackbarHostState.showSnackbar(msg, duration = SnackbarDuration.Long) } } + ) } }, onStop = { scope.launch { - executeOperation(container, "stop") + opsViewModel.executeOperation( + container, "stop", + onRefresh = { containerViewModel.refresh() }, + onClearUsage = { systemStatsViewModel.clearContainerUsage(it) }, + onFailureSnackbar = { msg -> scope.launch { snackbarHostState.showSnackbar(msg, duration = SnackbarDuration.Long) } } + ) } }, onRestart = { scope.launch { - executeOperation(container, "restart") + opsViewModel.executeOperation( + container, "restart", + onRefresh = { containerViewModel.refresh() }, + onClearUsage = { systemStatsViewModel.clearContainerUsage(it) }, + onFailureSnackbar = { msg -> scope.launch { snackbarHostState.showSnackbar(msg, duration = SnackbarDuration.Long) } } + ) } }, onEdit = { @@ -775,36 +316,26 @@ fun ContainersScreen( ) // Log viewer dialog - console stays open, user must close manually - showLogViewerFor?.let { containerName -> + opsViewModel.showLogViewerFor?.let { containerName -> // Load logs from memory first, fallback to cache if empty - val memoryLogs = containerLogs[containerName]?.toList() ?: emptyList() + val memoryLogs = opsViewModel.containerLogs[containerName]?.toList() ?: emptyList() val cachedLogs = if (memoryLogs.isEmpty()) { prefsManager.loadContainerLogs(containerName) } else { emptyList() } val logs = memoryLogs.ifEmpty { cachedLogs } - val isBlocking = runningOperationContainer == containerName // Blocking when operation is running + val isBlocking = opsViewModel.runningOperationContainer == containerName // Blocking when operation is running TerminalDialog( title = context.getString(R.string.logs_title, containerName), logs = logs, onDismiss = { - showLogViewerFor = null + opsViewModel.showLogViewerFor = null // Refresh container status when console is closed (KernelSU pattern) containerViewModel.refresh() }, onClear = { - // Force-clear memory list (initialize if null) to ensure UI reacts immediately - val buffer = containerLogs[containerName] ?: androidx.compose.runtime.mutableStateListOf>().also { - containerLogs = containerLogs.toMutableMap().apply { put(containerName, it) } - } - buffer.clear() - - // Clear persistent cache synchronously - prefsManager.clearContainerLogs(containerName) - - // Trigger map recomposition for safety - containerLogs = containerLogs.toMutableMap() + opsViewModel.clearLogsBuffer(containerName) }, isBlocking = isBlocking // Block dismissal when operation is running ) @@ -817,7 +348,12 @@ fun ContainersScreen( onConfirm = { showUninstallConfirmation = null scope.launch { - executeUninstall(container) + opsViewModel.executeUninstall( + container, + onError = { msg -> scope.showError(snackbarHostState, msg) }, + onSuccess = { msg -> scope.showSuccess(snackbarHostState, msg) }, + onRefresh = { containerViewModel.refresh() } + ) } }, onDismiss = { @@ -827,17 +363,17 @@ fun ContainersScreen( } // Uninstall progress dialog - (uninstallState as? UninstallState.InProgress)?.let { state -> + (opsViewModel.uninstallState as? UninstallState.InProgress)?.let { state -> ProgressDialog( message = state.message ) } // Uninstall logs dialog (only on failure) - uninstallLogsDialog?.let { logs -> + opsViewModel.uninstallLogsDialog?.let { logs -> ErrorLogsDialog( logs = logs, - onDismiss = { uninstallLogsDialog = null } + onDismiss = { opsViewModel.dismissUninstallLogs() } ) } @@ -862,7 +398,7 @@ fun ContainersScreen( onConfirm = { size -> pendingSparseOperation = null scope.launch { - executeSparseOperation(op, size) + opsViewModel.executeSparseOperation(op, size, onRefresh = { containerViewModel.refresh() }) } }, onDismiss = { pendingSparseOperation = null } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerOperationsViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerOperationsViewModel.kt new file mode 100644 index 00000000..97e322ab --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerOperationsViewModel.kt @@ -0,0 +1,390 @@ +package com.droidspaces.app.ui.viewmodel + +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.AndroidViewModel +import com.droidspaces.app.R +import com.droidspaces.app.service.TerminalSessionService +import com.droidspaces.app.util.ContainerCommandBuilder +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.ContainerManager +import com.droidspaces.app.util.ContainerOSInfoManager +import com.droidspaces.app.util.ContainerOperationExecutor +import com.droidspaces.app.util.PreferencesManager +import com.droidspaces.app.util.SystemInfoManager +import com.droidspaces.app.util.ViewModelLogger +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.File +import java.io.OutputStream + +/** Progress state for the stop/uninstall flow (drives the ProgressDialog). */ +sealed class UninstallState { + data object Idle : UninstallState() + data class InProgress(val containerName: String, val message: String) : UninstallState() +} + +/** A pending sparse-image operation awaiting a size from the user. */ +sealed class SparseOperation { + data class Migrate(val container: ContainerInfo) : SparseOperation() + data class Resize(val container: ContainerInfo) : SparseOperation() +} + +/** + * Owns the container lifecycle/maintenance operations (start/stop/restart, + * export, uninstall, sparse migrate/resize) that used to live as suspend + * functions inside `ContainersScreen`. Root shell, asset deploy, command + * building and content-URI streaming now live here; the composable observes the + * state below and passes UI concerns (snackbar, list refresh, usage-cache clear) + * back as callbacks so behavior is identical. See FINDINGS_APP_DUCT_TAPES DT-6. + */ +class ContainerOperationsViewModel(app: Application) : AndroidViewModel(app) { + + private val appContext get() = getApplication() + private val prefsManager get() = PreferencesManager.getInstance(appContext) + + /** Container whose operation is currently running (shows the terminal icon / blocks the log dialog). */ + var runningOperationContainer by mutableStateOf(null) + private set + + /** Per-container streaming log buffers. */ + var containerLogs by mutableStateOf>>>(emptyMap()) + private set + + /** Container whose log viewer dialog is open (set from UI and from operations). */ + var showLogViewerFor by mutableStateOf(null) + + /** Last container whose operation failed (kept for parity with prior behavior). */ + var lastErrorContainer by mutableStateOf(null) + private set + + /** Stop/uninstall progress. */ + var uninstallState by mutableStateOf(UninstallState.Idle) + private set + + /** Non-null when an uninstall failed and its logs should be shown (dismissed from UI). */ + var uninstallLogsDialog by mutableStateOf?>(null) + + fun dismissUninstallLogs() { uninstallLogsDialog = null } + + /** Get-or-create the streaming log buffer for [name] (matches the previous inline pattern). */ + private fun logsFor(name: String): SnapshotStateList> { + containerLogs[name]?.let { return it } + val newLogs = mutableStateListOf>() + containerLogs = containerLogs.toMutableMap().apply { put(name, newLogs) } + return newLogs + } + + /** Clear the in-memory + cached logs for [name] (log dialog "clear" action). */ + fun clearLogsBuffer(name: String) { + val buffer = containerLogs[name] ?: mutableStateListOf>().also { + containerLogs = containerLogs.toMutableMap().apply { put(name, it) } + } + buffer.clear() + prefsManager.clearContainerLogs(name) + containerLogs = containerLogs.toMutableMap() + } + + private fun string(resId: Int, vararg args: Any): String = + if (args.isEmpty()) appContext.getString(resId) else appContext.getString(resId, *args) + + // ── Export ──────────────────────────────────────────────────────────────── + suspend fun executeExport( + container: ContainerInfo, + outputUri: Uri, + onError: (String) -> Unit, + ) { + runningOperationContainer = container.name + val logs = logsFor(container.name) + logs.clear() + prefsManager.clearContainerLogs(container.name) + + val logger = ViewModelLogger { level, message -> logs.add(level to message) }.apply { verbose = true } + + var scriptFile: File? = null + var tempArchive: File? = null + try { + val isRunning = ContainerManager.checkContainerStatus(container.name).first + if (isRunning) { + uninstallState = UninstallState.InProgress(container.name, string(R.string.stopping_container)) + val stopCommand = ContainerCommandBuilder.buildStopCommand(container) + val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) + uninstallState = UninstallState.Idle + if (!stopResult) { + logger.e(string(R.string.failed_to_stop_container, container.name)) + onError(string(R.string.failed_to_stop_container, container.name)) + return + } + } + + logs.clear() + showLogViewerFor = container.name + logger.i(string(R.string.starting_export)) + + val deployed = File("${appContext.cacheDir}/export_container.sh") + scriptFile = deployed + appContext.assets.open("export_container.sh").use { input -> + deployed.outputStream().use { out: OutputStream -> input.copyTo(out) } + } + Shell.cmd("chmod 755 \"${deployed.absolutePath}\"").exec() + + tempArchive = File("${appContext.cacheDir}/${container.name}_export_tmp.tar.gz") + tempArchive.delete() + + val cmd = "\"${deployed.absolutePath}\" \"${container.name}\" \"${tempArchive.absolutePath}\"" + val success = ContainerOperationExecutor.executeCommand( + command = cmd, + operation = "export", + logger = logger, + skipHeader = true, + operationCompletedMessage = string(R.string.operation_completed_success) + ) + + if (success && tempArchive.exists() && tempArchive.length() > 0) { + logger.i("Writing archive to destination...") + withContext(Dispatchers.IO) { + appContext.contentResolver.openOutputStream(outputUri)?.use { out -> + tempArchive.inputStream().use { it.copyTo(out) } + } + } + logger.i("Done! Archive written successfully.") + } else if (!success) { + logger.e(string(R.string.export_container_failed, container.name)) + onError(string(R.string.export_container_failed, container.name)) + } + } catch (e: Exception) { + logger.e("Export error: ${e.message}") + logger.e(e.stackTraceToString()) + onError(string(R.string.export_container_failed, container.name)) + } finally { + scriptFile?.delete() + tempArchive?.delete() + prefsManager.saveContainerLogs(container.name, logs.toList()) + delay(500) + runningOperationContainer = null + } + } + + // ── Uninstall ─────────────────────────────────────────────────────────────── + suspend fun executeUninstall( + container: ContainerInfo, + onError: (String) -> Unit, + onSuccess: (String) -> Unit, + onRefresh: () -> Unit, + ) { + val collectedLogs = mutableListOf() + val logger = ViewModelLogger { _, message -> collectedLogs.add(message) }.apply { verbose = true } + + try { + val isRunning = ContainerManager.checkContainerStatus(container.name).first + if (isRunning) { + uninstallState = UninstallState.InProgress(container.name, string(R.string.stopping_container)) + val stopCommand = ContainerCommandBuilder.buildStopCommand(container) + val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) + if (!stopResult) { + uninstallState = UninstallState.Idle + if (collectedLogs.isNotEmpty()) uninstallLogsDialog = collectedLogs + else onError(string(R.string.failed_to_stop_container, container.name)) + onRefresh() + return + } + } + + uninstallState = UninstallState.InProgress(container.name, string(R.string.uninstalling_container)) + val result = ContainerManager.uninstallContainer(container, logger) + uninstallState = UninstallState.Idle + + if (result.isFailure) { + if (collectedLogs.isNotEmpty()) uninstallLogsDialog = collectedLogs + else onError(string(R.string.failed_to_uninstall_container, container.name)) + onRefresh() + } else { + ContainerOSInfoManager.clearCache(container.name, appContext) + onSuccess(string(R.string.container_uninstalled_success, container.name)) + onRefresh() + } + } catch (e: Exception) { + uninstallState = UninstallState.Idle + collectedLogs.add("Exception: ${e.message}") + collectedLogs.add(e.stackTraceToString()) + if (collectedLogs.isNotEmpty()) uninstallLogsDialog = collectedLogs + else onError(string(R.string.failed_to_uninstall_container, container.name)) + onRefresh() + } + } + + // ── Start / Stop / Restart ──────────────────────────────────────────────── + suspend fun executeOperation( + container: ContainerInfo, + operation: String, + onRefresh: () -> Unit, + onClearUsage: (String) -> Unit, + onFailureSnackbar: (String) -> Unit, + ) { + runningOperationContainer = container.name + val logs = logsFor(container.name) + logs.clear() + prefsManager.clearContainerLogs(container.name) + showLogViewerFor = container.name + + val logger = ViewModelLogger { level, message -> logs.add(level to message) }.apply { verbose = true } + + try { + if (operation == "stop" || operation == "restart") { + appContext.startService( + Intent(appContext, TerminalSessionService::class.java).apply { + action = TerminalSessionService.ACTION_STOP_CONTAINER_SESSIONS + putExtra(TerminalSessionService.EXTRA_CONTAINER_NAME, container.name) + } + ) + onClearUsage(container.name) + } + + val command = when (operation) { + "start" -> ContainerCommandBuilder.buildStartCommand(container) + "stop" -> ContainerCommandBuilder.buildStopCommand(container) + "restart" -> ContainerCommandBuilder.buildRestartCommand(container) + else -> { + runningOperationContainer = null + return + } + } + + val success = ContainerOperationExecutor.executeCommand( + command = command, + operation = operation, + logger = logger, + operationCompletedMessage = string(R.string.operation_completed_success) + ) + + if (!success) { + lastErrorContainer = container.name + logger.e("") + logger.e(string(R.string.operation_failed)) + onFailureSnackbar(string(R.string.failure_in_operation, operation, container.name)) + onRefresh() + SystemInfoManager.refreshSELinuxStatus() + } else { + lastErrorContainer = null + onRefresh() + SystemInfoManager.refreshSELinuxStatus() + } + } catch (e: Exception) { + logger.e("Error: ${e.message}") + logger.e(e.stackTraceToString()) + lastErrorContainer = container.name + onFailureSnackbar(string(R.string.failure_in_operation, operation, container.name)) + onRefresh() + } finally { + prefsManager.saveContainerLogs(container.name, logs.toList()) + delay(500) + runningOperationContainer = null + } + } + + // ── Sparse migrate / resize ───────────────────────────────────────────────── + suspend fun executeSparseOperation( + operation: SparseOperation, + sizeGb: Int, + onRefresh: () -> Unit, + ) { + val container = when (operation) { + is SparseOperation.Migrate -> operation.container + is SparseOperation.Resize -> operation.container + } + + runningOperationContainer = container.name + val logs = logsFor(container.name) + logs.clear() + prefsManager.clearContainerLogs(container.name) + + val logger = ViewModelLogger { level, message -> logs.add(level to message) }.apply { verbose = true } + + var scriptFile: File? = null + try { + val isRunning = ContainerManager.checkContainerStatus(container.name).first + if (isRunning) { + uninstallState = UninstallState.InProgress(container.name, string(R.string.stopping_container)) + val stopCommand = ContainerCommandBuilder.buildStopCommand(container) + val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) + uninstallState = UninstallState.Idle + if (!stopResult) { + logger.e(string(R.string.failed_to_stop_container, container.name)) + return + } + delay(1000) + } + + logs.clear() + showLogViewerFor = container.name + + val deployedFile = File("${appContext.cacheDir}/sparsemgr.sh") + scriptFile = deployedFile + appContext.assets.open("sparsemgr.sh").use { input -> + deployedFile.outputStream().use { output: OutputStream -> input.copyTo(output) } + } + Shell.cmd("chmod 755 \"${deployedFile.absolutePath}\"").exec() + + val baseDir = ContainerManager.getContainerDirectory(container.name) + val cmd = when (operation) { + is SparseOperation.Migrate -> { + logger.i(string(R.string.starting_migration)) + "\"${deployedFile.absolutePath}\" -d \"$baseDir\" migrate $sizeGb" + } + is SparseOperation.Resize -> { + logger.i(string(R.string.starting_resizing)) + val imgPath = ContainerManager.getSparseImagePath(container.name) + "\"${deployedFile.absolutePath}\" -i \"$imgPath\" resize $sizeGb --yes" + } + } + + val success = ContainerOperationExecutor.executeCommand( + command = cmd, + operation = "sparse_op", + logger = logger, + skipHeader = true, + operationCompletedMessage = string(R.string.operation_completed_success) + ) + + if (success) { + logger.i("Updating container configuration...") + val updatedConfig = if (operation is SparseOperation.Migrate) { + container.copy( + useSparseImage = true, + sparseImageSizeGB = sizeGb, + rootfsPath = if (container.rootfsPath.endsWith(".img")) container.rootfsPath else "${container.rootfsPath}.img" + ) + } else { + container.copy(sparseImageSizeGB = sizeGb) + } + val configResult = withContext(Dispatchers.IO) { + ContainerManager.updateContainerConfig(appContext, container.name, updatedConfig) + } + if (configResult.isSuccess) { + logger.i("Configuration updated successfully") + onRefresh() + } else { + logger.w("Warning: Failed to update container.config: ${configResult.exceptionOrNull()?.message}") + } + } else { + logger.e(string(R.string.operation_failed)) + } + } catch (e: Exception) { + logger.e("Error during sparse operation: ${e.message}") + logger.e(e.stackTraceToString()) + } finally { + scriptFile?.delete() + prefsManager.saveContainerLogs(container.name, logs.toList()) + delay(500) + runningOperationContainer = null + } + } +} From 711e554768158eee420a9336e3ad732c5b48f0ce Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:31 +0530 Subject: [PATCH 199/211] refactor: move InstallationScreen orchestration into AppStateViewModel.performInstallation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InstallationScreen's LaunchedEffect ran the whole backend install/update orchestration inline — root Shell.cmd (rm -rf module, test -f), BinaryInstaller/ ModuleInstaller/SymlinkInstaller, daemon-mode preference — mutating seven remembered state vars. Move it into AppStateViewModel.performInstallation() with the state hoisted onto the ViewModel; the composable now just calls it from LaunchedEffect and renders the observed state. Steps, ordering, daemon/symlink handling and post-install refresh are unchanged. Verified: compileDebugKotlin. Signed-off-by: ravindu644 --- .../app/ui/screen/InstallationScreen.kt | 127 ++---------------- .../app/ui/viewmodel/AppStateViewModel.kt | 118 ++++++++++++++++ 2 files changed, 128 insertions(+), 117 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt index ee796e3c..d18f9970 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt @@ -23,18 +23,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.droidspaces.app.util.BinaryInstaller import com.droidspaces.app.util.InstallationStep -import com.droidspaces.app.util.ModuleInstaller import com.droidspaces.app.util.ModuleInstallationStep -import com.droidspaces.app.util.DroidspacesChecker -import com.droidspaces.app.util.DroidspacesBackendStatus -import com.droidspaces.app.util.PreferencesManager -import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import com.droidspaces.app.R -import com.droidspaces.app.util.Constants import com.droidspaces.app.ui.viewmodel.AppStateViewModel @@ -45,13 +36,14 @@ fun InstallationScreen( ) { val context = LocalContext.current - var currentStep by remember { mutableStateOf(null) } - var currentModuleStep by remember { mutableStateOf(null) } - var isInstalling by remember { mutableStateOf(false) } - var isSuccess by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - var isInstallingModule by remember { mutableStateOf(false) } - var rebootRecommended by remember { mutableStateOf(false) } + // Install orchestration + state live in AppStateViewModel (DT-6). Read as locals + // so the UI below is unchanged; these are Compose state reads and recompose. + val currentStep = appStateViewModel.installCurrentStep + val currentModuleStep = appStateViewModel.installCurrentModuleStep + val isSuccess = appStateViewModel.isInstallSuccess + val errorMessage = appStateViewModel.installErrorMessage + val isInstallingModule = appStateViewModel.isInstallingModule + val rebootRecommended = appStateViewModel.installRebootRecommended // Completely block the back gesture in every state. This screen must be // left only via the Continue button, whose handler decides the next @@ -62,108 +54,9 @@ fun InstallationScreen( // Intentionally no-op while installing, on success and on error. } - // Check backend status and determine what to install + // Run the install orchestration (idempotent inside the ViewModel). LaunchedEffect(Unit) { - if (!isInstalling && !isSuccess) { - val backendStatus = withContext(Dispatchers.IO) { - DroidspacesChecker.checkBackendStatus() - } - - isInstalling = true - - val whichBackendMode = withContext(Dispatchers.IO) { - com.droidspaces.app.util.SystemInfoManager.getBackendMode(context) - } - val wasDaemon = whichBackendMode == "DAEMON" - - val isAtomicUpdate = backendStatus is DroidspacesBackendStatus.UpdateAvailable - - isInstallingModule = false - - // Capture symlink state before any module directory removal - val wasSymlinkEnabled = withContext(Dispatchers.IO) { - com.droidspaces.app.util.SymlinkInstaller.isSymlinkEnabled() - } - - // Check if SELinux policy exists BEFORE we start nuking things - val sepolicyExists = withContext(Dispatchers.IO) { - Shell.cmd("test -f ${Constants.MAGISK_MODULE_PATH}/sepolicy.rule").exec().isSuccess - } - if (!sepolicyExists) { - rebootRecommended = true - } - - if (!isAtomicUpdate) { - // Clean Slate: Remove the old module, but NEVER the bin directory - // (the daemon's g_self_path fix means the old binary stays valid - // until the daemon is restarted, and the new binary is already - // atomically in place at the canonical path). - currentStep = InstallationStep.CreatingDirectories("Nuking existing module...") - Shell.cmd("rm -rf '/data/adb/modules/droidspaces' 2>&1").exec() - } - - - // Step 2: Install binaries (atomic mv to canonical path - safe even while daemon is running) - val binaryResult = BinaryInstaller.install(context) { step -> - currentStep = step - } - binaryResult.fold( - onSuccess = { - // Signal the running daemon (if any) that the binary was swapped - if (wasDaemon) { - BinaryInstaller.signalDaemon() - } - // Step 3: Install module - isInstallingModule = true - val moduleResult = ModuleInstaller.install(context) { step -> - currentModuleStep = step - } - moduleResult.fold( - onSuccess = { - // On a truly fresh install, .daemon_mode won't exist yet. - // Force-enable daemon mode so new users get sane defaults. - // On reinstalls/updates the file already exists (value 0 or 1), - // meaning the user has an established preference — leave it alone. - val daemonFileExists = withContext(Dispatchers.IO) { - Shell.cmd("test -f '${Constants.DAEMON_MODE_FILE}'").exec().isSuccess - } - if (!daemonFileExists) { - PreferencesManager.getInstance(context).isDaemonModeEnabled = true - } - // Restore symlink if it was enabled before the update - if (wasSymlinkEnabled) { - withContext(Dispatchers.IO) { - com.droidspaces.app.util.SymlinkInstaller.enable() - } - } - isSuccess = true - isInstalling = false - isInstallingModule = false - // Proactive refresh to update UI state before user navigates back - appStateViewModel.resetForPostInstallation() - appStateViewModel.forceRefresh() - }, - onFailure = { error -> - errorMessage = error.message ?: context.getString(R.string.module_installation_failed) - isInstalling = false - isInstallingModule = false - // Refresh even on failure to update error status - appStateViewModel.resetForPostInstallation() - appStateViewModel.forceRefresh() - } - ) - }, - onFailure = { error -> - errorMessage = error.message ?: context.getString(R.string.binary_installation_failed) - isInstalling = false - // Refresh even on failure - appStateViewModel.resetForPostInstallation() - appStateViewModel.forceRefresh() - } - ) - - - } + appStateViewModel.performInstallation() } Scaffold( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt index 2f0fdead..ec0fd440 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt @@ -6,12 +6,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.droidspaces.app.R +import com.droidspaces.app.util.BinaryInstaller +import com.droidspaces.app.util.Constants import com.droidspaces.app.util.DroidspacesBackendStatus import com.droidspaces.app.util.DroidspacesChecker +import com.droidspaces.app.util.InstallationStep +import com.droidspaces.app.util.ModuleInstaller +import com.droidspaces.app.util.ModuleInstallationStep import com.droidspaces.app.util.PreferencesManager import com.droidspaces.app.util.RootChecker import com.droidspaces.app.util.RootStatus +import com.droidspaces.app.util.SymlinkInstaller import com.droidspaces.app.util.SystemInfoManager +import com.topjohnwu.superuser.Shell import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -69,6 +77,22 @@ class AppStateViewModel(application: Application) : AndroidViewModel(application // Current refresh job for cancellation private var refreshJob: Job? = null + // ── Installation flow state (drives InstallationScreen) ───────────────────── + var installCurrentStep by mutableStateOf(null) + private set + var installCurrentModuleStep by mutableStateOf(null) + private set + var isInstalling by mutableStateOf(false) + private set + var isInstallSuccess by mutableStateOf(false) + private set + var installErrorMessage by mutableStateOf(null) + private set + var isInstallingModule by mutableStateOf(false) + private set + var installRebootRecommended by mutableStateOf(false) + private set + fun checkBackendStatus(force: Boolean = false) { if (hasCompletedInitialCheck && !force) return @@ -143,6 +167,100 @@ class AppStateViewModel(application: Application) : AndroidViewModel(application SystemInfoManager.resetCache() } + /** + * Run the backend install/update orchestration (moved out of InstallationScreen's + * LaunchedEffect — see FINDINGS_APP_DUCT_TAPES DT-6). Idempotent: no-op if an + * install is already running or has succeeded. Drives the install* state above. + */ + suspend fun performInstallation() { + if (isInstalling || isInstallSuccess) return + val context = getApplication() + + val backendStatus = withContext(Dispatchers.IO) { + DroidspacesChecker.checkBackendStatus() + } + + isInstalling = true + + val whichBackendMode = withContext(Dispatchers.IO) { + SystemInfoManager.getBackendMode(context) + } + val wasDaemon = whichBackendMode == "DAEMON" + + val isAtomicUpdate = backendStatus is DroidspacesBackendStatus.UpdateAvailable + + isInstallingModule = false + + // Capture symlink state before any module directory removal + val wasSymlinkEnabled = withContext(Dispatchers.IO) { + SymlinkInstaller.isSymlinkEnabled() + } + + // Check if SELinux policy exists BEFORE we start nuking things + val sepolicyExists = withContext(Dispatchers.IO) { + Shell.cmd("test -f ${Constants.MAGISK_MODULE_PATH}/sepolicy.rule").exec().isSuccess + } + if (!sepolicyExists) { + installRebootRecommended = true + } + + if (!isAtomicUpdate) { + // Clean Slate: Remove the old module, but NEVER the bin directory. + installCurrentStep = InstallationStep.CreatingDirectories("Nuking existing module...") + Shell.cmd("rm -rf '/data/adb/modules/droidspaces' 2>&1").exec() + } + + // Step 2: Install binaries (atomic mv to canonical path) + val binaryResult = BinaryInstaller.install(context) { step -> + installCurrentStep = step + } + binaryResult.fold( + onSuccess = { + if (wasDaemon) { + BinaryInstaller.signalDaemon() + } + // Step 3: Install module + isInstallingModule = true + val moduleResult = ModuleInstaller.install(context) { step -> + installCurrentModuleStep = step + } + moduleResult.fold( + onSuccess = { + val daemonFileExists = withContext(Dispatchers.IO) { + Shell.cmd("test -f '${Constants.DAEMON_MODE_FILE}'").exec().isSuccess + } + if (!daemonFileExists) { + PreferencesManager.getInstance(context).isDaemonModeEnabled = true + } + if (wasSymlinkEnabled) { + withContext(Dispatchers.IO) { + SymlinkInstaller.enable() + } + } + isInstallSuccess = true + isInstalling = false + isInstallingModule = false + resetForPostInstallation() + forceRefresh() + }, + onFailure = { error -> + installErrorMessage = error.message ?: context.getString(R.string.module_installation_failed) + isInstalling = false + isInstallingModule = false + resetForPostInstallation() + forceRefresh() + } + ) + }, + onFailure = { error -> + installErrorMessage = error.message ?: context.getString(R.string.binary_installation_failed) + isInstalling = false + resetForPostInstallation() + forceRefresh() + } + ) + } + /** * Check if backend is available (working or update available). */ From a4b919a44787e03c8f34f06aa4c72458d66e6241 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:31 +0530 Subject: [PATCH 200/211] refactor: group ContainerCard callbacks into ContainerCardActions The ContainerCard call site passed 11 separate lambdas. Introduce a ContainerCardActions data class and pass one stable object; ContainerCard destructures it internally so the body is unchanged. No behavior change. Signed-off-by: ravindu644 --- .../app/ui/component/ContainerCard.kt | 41 ++++++++++++++----- .../app/ui/screen/ContainersScreen.kt | 3 ++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt index 471c6142..ba426c3c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt @@ -37,25 +37,44 @@ import com.droidspaces.app.util.ContainerOSInfoManager import com.droidspaces.app.util.ContainerStatus import com.droidspaces.app.util.IconUtils +/** + * Per-item callbacks for [ContainerCard], grouped so the call site passes one + * stable object instead of 11 separate lambdas. + */ +data class ContainerCardActions( + val onStart: () -> Unit = {}, + val onStop: () -> Unit = {}, + val onRestart: () -> Unit = {}, + val onEdit: () -> Unit = {}, + val onEnter: () -> Unit = {}, + val onUninstall: () -> Unit = {}, + val onMigrate: () -> Unit = {}, + val onResize: () -> Unit = {}, + val onExport: () -> Unit = {}, + val onToggleExpand: () -> Unit = {}, + val onShowLogs: () -> Unit = {}, +) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ContainerCard( container: ContainerInfo, - onStart: () -> Unit = {}, - onStop: () -> Unit = {}, - onRestart: () -> Unit = {}, - onEdit: () -> Unit = {}, - onEnter: () -> Unit = {}, - onUninstall: () -> Unit = {}, - onMigrate: () -> Unit = {}, - onResize: () -> Unit = {}, - onExport: () -> Unit = {}, + actions: ContainerCardActions = ContainerCardActions(), isOperationRunning: Boolean = false, isExpanded: Boolean = false, - onToggleExpand: () -> Unit = {}, - onShowLogs: () -> Unit = {}, modifier: Modifier = Modifier ) { + val onStart = actions.onStart + val onStop = actions.onStop + val onRestart = actions.onRestart + val onEdit = actions.onEdit + val onEnter = actions.onEnter + val onUninstall = actions.onUninstall + val onMigrate = actions.onMigrate + val onResize = actions.onResize + val onExport = actions.onExport + val onToggleExpand = actions.onToggleExpand + val onShowLogs = actions.onShowLogs val context = LocalContext.current val cardShape = RoundedCornerShape(20.dp) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index 22f30fe1..c6582603 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -41,6 +41,7 @@ import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.PreferencesManager import com.droidspaces.app.util.FilePickerUtils import com.droidspaces.app.ui.component.ContainerCard +import com.droidspaces.app.ui.component.ContainerCardActions import com.droidspaces.app.ui.component.TerminalDialog import com.droidspaces.app.ui.component.EmptyState import com.droidspaces.app.ui.component.ErrorState @@ -177,6 +178,7 @@ fun ContainersScreen( container = container, isOperationRunning = isRunning, isExpanded = expandedContainerName == container.name, + actions = ContainerCardActions( onToggleExpand = { onExpandedContainerNameChange(if (expandedContainerName == container.name) null else container.name) }, @@ -243,6 +245,7 @@ fun ContainersScreen( pendingExportContainer = container exportFileLauncher.launch(fileName) } + ) ) } } From 3d6a4bc0af8ed823e189bd08aa42f33032cb1e24 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:32 +0530 Subject: [PATCH 201/211] refactor: render the container list with LazyColumn + stable keys The container list used Column().verticalScroll() + forEach, re-laying-out and reallocating every row on each recomposition. Switch to LazyColumn with items(containers, key = { it.name }); tap-to-collapse, spacing, FAB, Install button and pull-to-refresh preserved. Signed-off-by: ravindu644 --- .../droidspaces/app/ui/screen/ContainersScreen.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index c6582603..3fdf6f2e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -7,6 +7,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -157,7 +160,7 @@ fun ContainersScreen( } else -> { // Show container cards - Column( + LazyColumn( modifier = Modifier .fillMaxSize() .combinedClickable( @@ -165,12 +168,11 @@ fun ContainersScreen( indication = null, onClick = { onExpandedContainerNameChange(null) } ) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - .padding(top = 8.dp, bottom = 120.dp), // Clear floating tab bar + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = 120.dp), // Clear floating tab bar verticalArrangement = Arrangement.spacedBy(16.dp) ) { - containers.forEach { container -> + items(containers, key = { it.name }) { container -> // Console button is always visible - logs persist for each container val isRunning = opsViewModel.runningOperationContainer == container.name From 9acf48a96242e291236ebe35b91a89fc08cd8c1c Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:32 +0530 Subject: [PATCH 202/211] refactor: dedupe wizard-scoped ViewModel resolution in navigation The install-wizard ViewModel resolution block (viewModel scoped to the ContainerName back-stack entry) was copy-pasted verbatim across 4 wizard routes. Extract a private wizardScopedViewModel(navController, backStackEntry) helper and use it. No behavior change. Signed-off-by: ravindu644 --- .../ui/navigation/DroidspacesNavigation.kt | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index cfb0f5e1..e3d3bd63 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -106,6 +106,22 @@ sealed class Screen(val route: String) { } } +/** + * Resolve the install-wizard [ContainerInstallationViewModel] scoped to the first + * wizard screen's back-stack entry (so all wizard steps share one instance), + * falling back to the current entry if that route isn't on the stack. + */ +@Composable +private fun wizardScopedViewModel( + navController: NavHostController, + backStackEntry: NavBackStackEntry +): ContainerInstallationViewModel = viewModel( + remember(backStackEntry) { + runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } + .getOrElse { backStackEntry } + } +) + @Composable fun DroidspacesNavigation( navController: NavHostController = rememberNavController(), @@ -331,12 +347,7 @@ fun DroidspacesNavigation( enterTransition = defaultEnterTransition, exitTransition = defaultExitTransition ) { backStackEntry -> - val viewModel: ContainerInstallationViewModel = viewModel( - remember(backStackEntry) { - runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } - .getOrElse { backStackEntry } - } - ) + val viewModel = wizardScopedViewModel(navController, backStackEntry) ContainerConfigScreen( initialState = viewModel.configState, @@ -357,12 +368,7 @@ fun DroidspacesNavigation( enterTransition = defaultEnterTransition, exitTransition = defaultExitTransition ) { backStackEntry -> - val viewModel: ContainerInstallationViewModel = viewModel( - remember(backStackEntry) { - runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } - .getOrElse { backStackEntry } - } - ) + val viewModel = wizardScopedViewModel(navController, backStackEntry) SparseImageConfigScreen( initialUseSparseImage = viewModel.useSparseImage, @@ -382,12 +388,7 @@ fun DroidspacesNavigation( enterTransition = defaultEnterTransition, exitTransition = defaultExitTransition ) { backStackEntry -> - val viewModel: ContainerInstallationViewModel = viewModel( - remember(backStackEntry) { - runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } - .getOrElse { backStackEntry } - } - ) + val viewModel = wizardScopedViewModel(navController, backStackEntry) val config = viewModel.buildConfig() val tarballUri = viewModel.tarballUri val ctx = LocalContext.current @@ -433,12 +434,7 @@ fun DroidspacesNavigation( enterTransition = defaultEnterTransition, exitTransition = defaultExitTransition ) { backStackEntry -> - val viewModel: ContainerInstallationViewModel = viewModel( - remember(backStackEntry) { - runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } - .getOrElse { backStackEntry } - } - ) + val viewModel = wizardScopedViewModel(navController, backStackEntry) val config = viewModel.buildConfig() val tarballUri = viewModel.tarballUri From 77689eb806bd0fc823ff7ad466b4473cf5555826 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:32 +0530 Subject: [PATCH 203/211] refactor: collect Settings toggles via collectAsStateWithLifecycle Replace SettingsScreen's hand-rolled SharedPreferences.OnSharedPreferenceChange listener (in a DisposableEffect) with reactive PreferencesManager.daemonModeFlow / symlinkEnabledFlow (callbackFlow over the prefs) collected with collectAsStateWithLifecycle(). Same toggle behavior, persistence and on-disk sync (DaemonModeRepository); the flows also observe writes from syncDaemonModeFromDisk. Signed-off-by: ravindu644 --- .../app/ui/screen/SettingsScreen.kt | 27 +++++-------------- .../app/util/PreferencesManager.kt | 24 +++++++++++++++++ 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt index dea62e35..3670248a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt @@ -1,7 +1,6 @@ package com.droidspaces.app.ui.screen import android.content.Context -import android.content.SharedPreferences import android.content.ClipData import android.content.ClipboardManager import android.content.pm.PackageManager @@ -38,6 +37,7 @@ import androidx.compose.ui.res.painterResource import android.content.Intent import android.net.Uri import com.droidspaces.app.R +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.droidspaces.app.ui.component.AccentColorPicker import com.droidspaces.app.ui.component.BugReportDialog @@ -97,25 +97,12 @@ fun SettingsScreen( var showLanguageDialog by remember { mutableStateOf(false) } var currentAppLocale by remember { mutableStateOf(LocaleHelper.getCurrentAppLocale(context)) } - // Daemon mode state - var isDaemonModeEnabled by remember { mutableStateOf(prefsManager.isDaemonModeEnabled) } - - // Symlink state - var isSymlinkEnabled by remember { mutableStateOf(prefsManager.isSymlinkEnabled) } - - // Register SharedPreferences listener for daemon mode - DisposableEffect(prefsManager) { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _: SharedPreferences, key: String? -> - when (key) { - PreferencesManager.KEY_DAEMON_MODE_ENABLED -> isDaemonModeEnabled = prefsManager.isDaemonModeEnabled - PreferencesManager.KEY_SYMLINK_ENABLED -> isSymlinkEnabled = prefsManager.isSymlinkEnabled - } - } - prefsManager.prefs.registerOnSharedPreferenceChangeListener(listener) - onDispose { - prefsManager.prefs.unregisterOnSharedPreferenceChangeListener(listener) - } - } + // Daemon mode + symlink state, collected reactively from prefs + // (replaces a hand-rolled SharedPreferences change listener). + val isDaemonModeEnabled by prefsManager.daemonModeFlow + .collectAsStateWithLifecycle(initialValue = prefsManager.isDaemonModeEnabled) + val isSymlinkEnabled by prefsManager.symlinkEnabledFlow + .collectAsStateWithLifecycle(initialValue = prefsManager.isSymlinkEnabled) // Listen for locale changes and sync daemon mode from disk LaunchedEffect(Unit) { diff --git a/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt index 2a3dd67a..677240f8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt @@ -3,6 +3,10 @@ package com.droidspaces.app.util import android.content.Context import android.content.SharedPreferences import android.os.Build +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged /** * Ultra-optimized PreferencesManager with zero-allocation hot paths. @@ -145,6 +149,26 @@ class PreferencesManager private constructor(context: Context) { prefs.edit().putBoolean(KEY_SYMLINK_ENABLED, value).apply() } + /** + * Reactive stream of the daemon-mode preference — emits the current value and + * every later change (including writes from [syncDaemonModeFromDisk] / + * DaemonModeRepository), so the UI can collect it with + * collectAsStateWithLifecycle() instead of a hand-rolled change listener. + */ + val daemonModeFlow: Flow = booleanPrefFlow(KEY_DAEMON_MODE_ENABLED, false) + + /** Reactive stream of the symlink-enabled preference. */ + val symlinkEnabledFlow: Flow = booleanPrefFlow(KEY_SYMLINK_ENABLED, false) + + private fun booleanPrefFlow(key: String, default: Boolean): Flow = callbackFlow { + trySend(prefs.getBoolean(key, default)) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey -> + if (changedKey == key || changedKey == null) trySend(prefs.getBoolean(key, default)) + } + prefs.registerOnSharedPreferenceChangeListener(listener) + awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + }.distinctUntilChanged() + /** * Sync daemon mode preference from the root-protected file on disk. * Updates SharedPreferences if the file exists and differs. From 9b2eacc23e2cc9b41813c7fe9c087b803360f92a Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:32 +0530 Subject: [PATCH 204/211] refactor: clear compiler warnings left by the refactor - WelcomeScreen / InstallationSummaryScreen: drop the now-unused 'btnShape' vals left behind when DT-4 extracted PrimaryActionBottomBar. - InstallationScreen: drop a redundant elvis on the (smart-cast non-null) errorMessage introduced by the DT-6 move. No behavior change; compileReleaseKotlin is now warning-clean for these files. Signed-off-by: ravindu644 --- .../java/com/droidspaces/app/ui/screen/InstallationScreen.kt | 2 +- .../com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt | 2 -- .../main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt index d18f9970..e4143735 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt @@ -134,7 +134,7 @@ fun InstallationScreen( } errorMessage != null -> { Text( - text = errorMessage ?: context.getString(R.string.unknown_error), + text = errorMessage, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index 69cf073b..317d7b01 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -37,8 +37,6 @@ fun InstallationSummaryScreen( onInstall: () -> Unit, onBack: () -> Unit ) { - val btnShape = RoundedCornerShape(20.dp) - Scaffold( containerColor = Color.Transparent, topBar = { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt index 39ac02e9..64480dc4 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt @@ -59,8 +59,6 @@ fun WelcomeScreen(onNavigateToRootCheck: () -> Unit) { ShowcaseCard(Icons.Default.PowerSettingsNew, R.string.feat_autoboot_title, R.string.feat_autoboot_desc), ) - val btnShape = RoundedCornerShape(20.dp) - Scaffold( containerColor = Color.Transparent, bottomBar = { From 893eaec5a1f445c782769332611527a876bc3737 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Sun, 26 Jul 2026 18:49:32 +0530 Subject: [PATCH 205/211] feat(ui): swipe between service filter categories on the service list The systemd/OpenRC/procd service list only switched filter categories (Running, Enabled, Disabled, Abnormal, Static, All, Masked) by tapping a chip. Wrap the list in a HorizontalPager (one page per filter) so categories can also be swiped left/right: the pager and chip row stay in sync (tap animates, swipe selects), the selected chip auto-scrolls into view, and far hops jump directly instead of flipping through every intermediate page. Search still overrides with a single results list. Signed-off-by: ravindu644 --- .../app/ui/screen/InitServiceScreen.kt | 82 ++++++++++++++----- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt index 8fe68c52..fd9645c3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt @@ -2,15 +2,18 @@ package com.droidspaces.app.ui.screen import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -115,7 +118,7 @@ private sealed class InitActionState { * systemd / OpenRC / procd; the differences (title, availability probe, service * fetch/mapping, and the filter set) are supplied by the caller. */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun InitServiceScreen( containerName: String, @@ -185,14 +188,6 @@ fun InitServiceScreen( val allRows = (screenState as? InitScreenState.Ready)?.rows ?: emptyList() val selectedFilter = filters.firstOrNull { it.id == selectedFilterId } ?: filters.first() - val filteredRows = remember(allRows, selectedFilterId, searchQuery) { - if (searchQuery.isBlank()) { - allRows.filter(selectedFilter.predicate) - .sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) - } else { - allRows.filter { it.name.contains(searchQuery, ignoreCase = true) } - } - } val counts = remember(allRows) { filters.associate { chip -> chip.id to allRows.count(chip.predicate) } } @@ -218,20 +213,59 @@ fun InitServiceScreen( is InitScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) is InitScreenState.NotAvailable -> InitServiceNotAvailable() is InitScreenState.Ready -> { + val pagerState = rememberPagerState( + initialPage = filters.indexOfFirst { it.id == selectedFilterId }.coerceAtLeast(0), + pageCount = { filters.size } + ) + // Swiping between filter pages keeps the chip row selection in sync. + LaunchedEffect(pagerState.currentPage) { + selectedFilterId = filters[pagerState.currentPage].id + } Column(modifier = Modifier.fillMaxSize()) { InitServiceSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) InitServiceFilterChipsRow( filters = filters, counts = counts, selectedFilterId = selectedFilterId, - onFilterSelected = { selectedFilterId = it; clearFocus() } + onFilterSelected = { id -> + clearFocus() + val idx = filters.indexOfFirst { it.id == id } + if (idx >= 0) scope.launch { + // Animate only for adjacent hops; jump directly for distant + // ones so we don't flip through every page in between. + if (kotlin.math.abs(idx - pagerState.currentPage) <= 1) { + pagerState.animateScrollToPage(idx) + } else { + pagerState.scrollToPage(idx) + } + } + } ) - if (filteredRows.isEmpty()) { - InitServiceEmptyState(emptyRes = selectedFilter.emptyRes, modifier = Modifier.weight(1f)) + if (searchQuery.isNotBlank()) { + // Search overrides the filter pages: one results list across all services. + val results = allRows.filter { it.name.contains(searchQuery, ignoreCase = true) } + if (results.isEmpty()) { + InitServiceEmptyState(emptyRes = selectedFilter.emptyRes, modifier = Modifier.weight(1f)) + } else { + LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(results, key = { it.name }) { row -> + InitServiceCard(row = row, onAction = { actionName, act -> executeAction(row.name, actionName, act) }) + } + } + } } else { - LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - items(filteredRows) { row -> - InitServiceCard(row = row, onAction = { actionName, act -> executeAction(row.name, actionName, act) }) + HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page -> + val pageFilter = filters[page] + val rows = allRows.filter(pageFilter.predicate) + .sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) + if (rows.isEmpty()) { + InitServiceEmptyState(emptyRes = pageFilter.emptyRes, modifier = Modifier.fillMaxSize()) + } else { + LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(rows, key = { it.name }) { row -> + InitServiceCard(row = row, onAction = { actionName, act -> executeAction(row.name, actionName, act) }) + } + } } } } @@ -294,12 +328,20 @@ private fun InitServiceFilterChipsRow( onFilterSelected: (String) -> Unit ) { val context = LocalContext.current - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + val listState = rememberLazyListState() + // Keep the highlighted chip on-screen when the filter changes (swipe or tap). + LaunchedEffect(selectedFilterId) { + val idx = filters.indexOfFirst { it.id == selectedFilterId } + if (idx >= 0) listState.animateScrollToItem(idx) + } + LazyRow( + state = listState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - filters.forEach { chip -> + items(filters) { chip -> val count = counts[chip.id] ?: 0 val isSelected = selectedFilterId == chip.id FilterChip( From 9096c5aa04f968ca6014053251565f8a4b33f8d9 Mon Sep 17 00:00:00 2001 From: seriaTvT <63958697+seriaTvT@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:44:20 +0800 Subject: [PATCH 206/211] fix(monitor): drain console PTY master in background mode (#247) The monitor never read the console PTY master in background mode, only foreground did (via console_monitor_loop). Once a container's init wrote past the ~64 KiB PTY buffer, /dev/console blocked forever in n_tty_write and the whole container wedged permanently. Add the master to the monitor's existing heartbeat poll loop and drain it continuously, appending output to a per-container console log. This both fixes the deadlock and recovers previously-lost background boot logs. --- src/monitor.c | 104 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/src/monitor.c b/src/monitor.c index f79fca3c..4bffd9c3 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -7,6 +7,50 @@ #include "droidspace.h" +/* --------------------------------------------------------------------------- + * ds_console_drain - Empty the container's console PTY master. + * + * In BACKGROUND mode the monitor is the only process holding the console PTY + * master, and nothing reads it (foreground mode drives it from the parent via + * console_monitor_loop). If it is never read, a chatty container init that + * writes past the ~64 KiB PTY buffer blocks forever in n_tty_write and the + * whole container wedges. So the monitor must keep this master drained for the + * container's entire lifetime. + * + * Drained bytes are appended to the per-container console log when one is open + * (log_fd >= 0), which also makes background boot/console output visible for + * debugging. Draining continues even if the log write fails: keeping the PTY + * buffer empty is the load-bearing part; logging is best-effort. + * + * master_fd must be O_NONBLOCK so read() returns EAGAIN once drained. + * ---------------------------------------------------------------------------*/ +static void ds_console_drain(int master_fd, int log_fd, size_t *logged) { + char buf[4096]; + ssize_t n; + + while ((n = read(master_fd, buf, sizeof(buf))) > 0) { + if (log_fd < 0) + continue; + + /* Bound the console log: truncate at 2 MiB. The fd was opened O_APPEND, + * so subsequent writes still land correctly after the truncation. */ + if (logged && *logged >= 2u * 1024 * 1024) { + if (ftruncate(log_fd, 0) == 0) + *logged = 0; + } + + size_t off = 0; + while (off < (size_t)n) { + ssize_t w = write(log_fd, buf + off, (size_t)n - off); + if (w <= 0) + break; + off += (size_t)w; + } + if (logged) + *logged += off; + } +} + /* --------------------------------------------------------------------------- * ds_monitor_run - Supervisor process for a single container instance. * @@ -150,6 +194,33 @@ void ds_monitor_run(struct ds_config *cfg, int sync_pipe_write) { int stdio_redirected = 0; + /* Background console-drain setup (see ds_console_drain). + * Runs once, before the reboot loop, so a single log fd survives internal + * reboot cycles. In background mode the monitor is the only reader of the + * console PTY master: make it non-blocking and open a per-container console + * log so the heartbeat loop below keeps the PTY buffer drained. Foreground + * mode leaves the master untouched - the parent's console_monitor_loop + * drives it. */ + int console_log_fd = -1; + size_t console_logged = 0; + if (!cfg->foreground && cfg->console.master >= 0) { + int fl = fcntl(cfg->console.master, F_GETFL, 0); + if (fl >= 0) + (void)fcntl(cfg->console.master, F_SETFL, fl | O_NONBLOCK); + + char safe_name[256]; + char log_dir[PATH_MAX]; + char log_path[PATH_MAX]; + sanitize_container_name(cfg->container_name, safe_name, sizeof(safe_name)); + snprintf(log_dir, sizeof(log_dir), "%.2048s/" DS_LOGS_SUBDIR "/%.256s", + get_workspace_dir(), safe_name); + mkdir_p(log_dir, 0755); + snprintf(log_path, sizeof(log_path), "%.4080s/console", log_dir); + rotate_log(log_path, 2 * 1024 * 1024); + console_log_fd = + open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); + } + /* Reboot-aware boot loop * Each iteration forks an intermediate child that creates a fresh PID * namespace (unshare(CLONE_NEWPID)) and then forks the container init. @@ -500,14 +571,35 @@ reboot_loop:; ds_virtualize_update(cfg); + /* Poll the signalfd and, in background mode, the console PTY master. + * poll() wakes immediately when the master becomes readable, so draining + * is effectively real-time - a container init that fills the PTY buffer + * is unblocked at once instead of wedging forever in n_tty_write. */ + struct pollfd pfds[2]; + int npfd = 0; + int sfd_slot = -1; + int con_slot = -1; if (sfd >= 0) { - struct pollfd pfd = {.fd = sfd, .events = POLLIN}; - poll(&pfd, 1, 500); - if (pfd.revents & POLLIN) { + pfds[npfd].fd = sfd; + pfds[npfd].events = POLLIN; + sfd_slot = npfd++; + } + if (!cfg->foreground && cfg->console.master >= 0) { + pfds[npfd].fd = cfg->console.master; + pfds[npfd].events = POLLIN; + con_slot = npfd++; + } + if (npfd > 0) { + poll(pfds, (nfds_t)npfd, 500); + if (sfd_slot >= 0 && (pfds[sfd_slot].revents & POLLIN)) { struct signalfd_siginfo si; while (read(sfd, &si, sizeof(si)) == (ssize_t)sizeof(si)) ; /* drain */ } + if (con_slot >= 0 && + (pfds[con_slot].revents & (POLLIN | POLLHUP | POLLERR))) + ds_console_drain(cfg->console.master, console_log_fd, + &console_logged); } else { usleep(500000); } @@ -673,6 +765,12 @@ reboot_loop:; cleanup_container_resources(cfg, 0, 0, 0); monitor_cleanup_and_exit: + /* Capture any final console output, then close the background console log. */ + if (!cfg->foreground && cfg->console.master >= 0) + ds_console_drain(cfg->console.master, console_log_fd, &console_logged); + if (console_log_fd >= 0) + close(console_log_fd); + /* Free dynamically allocated configuration members before exit */ ds_config_free(cfg); _exit(WIFEXITED(status) ? WEXITSTATUS(status) : 0); From 0fc83448315ebe2023ca8922272c648383de0951 Mon Sep 17 00:00:00 2001 From: tux Date: Fri, 24 Jul 2026 12:22:37 +0530 Subject: [PATCH 207/211] docs: add motorola edge 30 to supported devices list (#249) --- Documentation/community-supported-devices.md | 1 + Documentation/zh-CN/community-supported-devices.md | 1 + 2 files changed, 2 insertions(+) diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 6be616dc..4ebb0f2e 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -42,6 +42,7 @@ This document is a community-maintained compatibility list for Android devices k | **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | Working | [@miaizhe](https://github.com/miaizhe) | - | | **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | Working | [@loystonpais](https://github.com/loystonpais) | - | | **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | Working | [@MySelly](https://github.com/MySelly) | - | +| **Motorola Edge 30** | XT2203 | Lineage OS 23.2 - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/tuxdotrs/android_kernel_motorola_sm7325) | [Download](https://github.com/tuxdotrs/android_kernel_motorola_sm7325/releases/latest) | Both | Turnip & Virgl | Working | [@tuxdotrs](https://github.com/tuxdotrs) | - | | **Motorola Moto G Stylus 5G (2022) | XT2215 | Android 16 QPR2 (LineageOS 23.2 or equivalent) | `5.4.306` | ReSukiSU | [Source](https://github.com/chararomandroid/android_kernel_motorola_sm6375) | [Download](https://github.com/CharaROMAndroid/android_kernel_motorola_sm6375/releases/tag/chaoskernel-milanf) | Both | Turnip | Working | [@GarryStraitYT](https://github.com/GarryStraitYT) | Tested on CharaROM 1.0 Beta 2, should work on any LineageOS 23.2 equivalent. Tested with Retail US variant of XT2215-4 (8GB RAM + 256GB ROM), but should work on any variant. | **5.10 Devices**| - | - | 5.10.x | - | - | - | - | - | - | -| - | | **OnePlus Ace 竞速版 (Chinese variant)** | PGZ110 | ColorOS 15.0 - Android 15 | `5.10.167` | Resukisu | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU_oki_5.10.226_Gold_bug_%E5%A4%A9%E7%8E%91%E7%89%B9%E4%BE%9B.zip) | Both | bridge & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | diff --git a/Documentation/zh-CN/community-supported-devices.md b/Documentation/zh-CN/community-supported-devices.md index 3b6e51d3..768e08db 100644 --- a/Documentation/zh-CN/community-supported-devices.md +++ b/Documentation/zh-CN/community-supported-devices.md @@ -82,6 +82,7 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@loystonpais](https://github.com/loystonpais) | - | | **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | 正常运行 | [@superuseryu](https://github.com/superuseryu) | 其他 KSU 变体可能因 SELinux 上下文重置而失败。 | | **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | 正常运行 | [@MySelly](https://github.com/MySelly) | - | +| **Motorola Edge 30** | XT2203 | Lineage OS 23.2 - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/tuxdotrs/android_kernel_motorola_sm7325) | [Download](https://github.com/tuxdotrs/android_kernel_motorola_sm7325/releases/latest) | Both | Turnip & Virgl | Working | [@tuxdotrs](https://github.com/tuxdotrs) | - | | **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | 正常运行 | [@miaizhe](https://github.com/miaizhe) | - | | OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/210ba17c8f19beed0212eb4f209109e35ef35746) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | From 0e022a2abac8c6ba69e25f5623e9f40b52d38f6a Mon Sep 17 00:00:00 2001 From: Yizhou147 <95130813+Yizhou147@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:02:37 +0800 Subject: [PATCH 208/211] Fix blurry launcher icon on Xiaomi tablet desktop and dock (#253) * Fix launcher icon blurry on Xiaomi tablet dock Change ic_launcher_foreground.xml width/height from 24dp to 108dp to match adaptive icon canvas size. This fixes the blurry icon issue on Xiaomi tablet desktop and dock bar where the launcher renders the vector at its intrinsic size before scaling up. * Trigger CI build for testing --- Android/app/src/main/res/drawable/ic_launcher_foreground.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml index f1068cf3..3d653ae1 100644 --- a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,6 +1,6 @@ Date: Wed, 29 Jul 2026 10:24:09 +0530 Subject: [PATCH 209/211] fix: report user namespaces as optional, not a hardening regression The requirements checker listed CONFIG_USER_NS under a [HARDENING] section with inverted polarity: a kernel that *has* user namespace support rendered as "[x] CONFIG_USER_NS disabled". Users read that as a missing requirement and filed it as a detection bug (#254) - detection was correct, the framing was backwards, and it contradicts Kernel-Configuration.md, which recommends CONFIG_USER_NS=y. Drop the [HARDENING] section entirely and move the probe into [OPTIONAL] as a normal positive check, "User namespace". Same detection (/proc/self/ns/user); the description now explains why you would want it - Docker on some kernels, sandboxed apps (Flatpak, Bubblewrap, browsers) and desktop environments - and points at --allow-userns, which is how it is opted into per container. Fixes: https://github.com/ravindu644/Droidspaces-OSS/issues/254 Signed-off-by: ravindu644 --- src/check.c | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/check.c b/src/check.c index 78348a8f..acd3f141 100644 --- a/src/check.c +++ b/src/check.c @@ -400,17 +400,11 @@ int check_requirements_detailed(void) { print_ds_check("Veth pair support", "Required for --net=nat; no fallback exists if absent", check_veth_support(), "OPT"); - - /* HARDENING */ - check_append("\n" C_BOLD "[HARDENING]" C_RESET - "\nThese checks are not required for Droidspaces to work, " - "but are recommended for hardened kernels:\n\n"); - - int has_user_ns = access("/proc/self/ns/user", F_OK) == 0; - print_ds_check("CONFIG_USER_NS disabled", - "Kernel exposes user namespace support, which Droidspaces " - "does not require and hardened kernels should disable", - !has_user_ns, "OPT"); + print_ds_check("User namespace", + "CONFIG_USER_NS; enable per container with --allow-userns. " + "Needed by Docker on some kernels, by sandboxed apps " + "(Flatpak, Bubblewrap, browsers) and by desktop environments", + access("/proc/self/ns/user", F_OK) == 0, "OPT"); /* FINAL SUMMARY */ check_append("\n" C_BOLD "Summary:" C_RESET "\n\n"); From ab96668e31b3f829646a824e91a282b1b3844546 Mon Sep 17 00:00:00 2001 From: ravindu644 Date: Wed, 29 Jul 2026 10:43:03 +0530 Subject: [PATCH 210/211] app: fix: bump termux terminal to 0.118.3 so fish stops printing "5u" Fish 4.x probes for Kitty keyboard protocol support by emitting ESC[=5u. terminal-emulator 0.118.1 has no case for the CSI private parameter byte '=', so it fell out of the escape state machine and rendered the trailing "5u" as literal text after the prompt (#250). Upstream fixed this in 3f23750 by routing ESC [ = and ESC [ < into ESC_CSI_UNSUPPORTED_PARAMETER_BYTE, which consumes the rest of the sequence. The fix ships in v0.118.3. Verified against the published JitPack AARs by feeding the reporter's repro through TerminalEmulator directly: 0.118.1 -> "before>5u "before> --- Android/app/build.gradle.kts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 78a2a40c..dd768519 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -336,8 +336,10 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") // Terminal emulator (ported from LXC-Manager / termux-app) - implementation("com.github.termux.termux-app:terminal-view:0.118.1") - implementation("com.github.termux.termux-app:terminal-emulator:0.118.1") + // 0.118.3 consumes unsupported CSI private-parameter sequences (ESC [ = / ESC [ <) + // instead of leaking them to the screen — fish 4.x probes with ESC[=5u. + implementation("com.github.termux.termux-app:terminal-view:0.118.3") + implementation("com.github.termux.termux-app:terminal-emulator:0.118.3") // Debug debugImplementation("androidx.compose.ui:ui-tooling") From 2242f37a392842cae2b420b5daec56963cd2827f Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 29 Jul 2026 07:17:35 +0200 Subject: [PATCH 211/211] Translated using Weblate (Czech) (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (515 of 515 strings) Translated using Weblate (Czech) Currently translated at 100.0% (3 of 3 strings) Added translation using Weblate (Czech) Added translation using Weblate (Czech) Translated using Weblate (Khmer (Central)) Currently translated at 100.0% (515 of 515 strings) Translate-URL: https://hosted.weblate.org/projects/droidspaces/plurals/cs/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/cs/ Translate-URL: https://hosted.weblate.org/projects/droidspaces/strings/km/ Translation: Droidspaces/Plurals Translation: Droidspaces/Strings Co-authored-by: LayAn Co-authored-by: Michal Várady --- .../app/src/main/res/values-cs/plurals.xml | 21 + .../app/src/main/res/values-cs/strings.xml | 512 ++++++++++++++++++ .../app/src/main/res/values-km/strings.xml | 59 +- 3 files changed, 586 insertions(+), 6 deletions(-) create mode 100644 Android/app/src/main/res/values-cs/plurals.xml create mode 100644 Android/app/src/main/res/values-cs/strings.xml diff --git a/Android/app/src/main/res/values-cs/plurals.xml b/Android/app/src/main/res/values-cs/plurals.xml new file mode 100644 index 00000000..7fa6f83a --- /dev/null +++ b/Android/app/src/main/res/values-cs/plurals.xml @@ -0,0 +1,21 @@ + + + + %d kontejner + %d kontejnery + %d kontejnerů + %d kontejnerů + + + %d commit + %d commity + %d commitů + %d commitů + + + Je spuštěna %d relace + Jsou spuštěny %d relace + Je spuštěno %d relací + Je spuštěno %d relací + + diff --git a/Android/app/src/main/res/values-cs/strings.xml b/Android/app/src/main/res/values-cs/strings.xml new file mode 100644 index 00000000..544b694a --- /dev/null +++ b/Android/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,512 @@ + + + Linuxové kontejnery. Nativně. Bez režie. + Začít + Skutečné linuxové kontejnery + Stejná technologie jmenných prostorů jádra jako u LXC, Podmanu a Dockeru. Bez emulace a bez chrootu. + Nulová režie + Komunikuje přímo s linuxovým jádrem hostitele. Bez hypervizoru a bez vrstvy virtuálního počítače. + Neukončitelné už z principu + Podporuje nativní integraci s init.rc a dvouvrstvé démony v uživatelském prostoru. Nelze jej vůbec ukončit. + systemd / OpenWrt procd / OpenRC jako PID 1 + Plná podpora inicializačních systémů. Skutečná správa služeb, nikoli simulované prostředí. + Úplná izolace + Soukromý strom PID, tabulka připojení, název hostitele, IPC a hierarchie cgroup pro každý kontejner. + Plný přístup k hardwaru/GPU + Zpřístupní kontejnerům přímo hardware hostitele (GPU, senzory, USB a bloková zařízení). + Plně privilegované kontejnery + Pro pokročilé uživatele umožňuje v privilegovaném režimu uvolnit bezpečnostní omezení. + Automatické spuštění při startu + Automaticky spustí kontejnery při startu, i když je obrazovka zamknutá a data zašifrovaná. + Kontrola přístupu roota + Kontroluje se přístup roota... + Přístup roota udělen + Přístup roota odepřen + Ke správě kontejnerů je vyžadován přístup roota. + Zkontrolovat přístup roota + Klepnutím níže zkontrolujte přístup roota + Nyní můžete používat Droidspaces + Přeskočit + Pokračovat + Domů + Kontejnery + Panel + Nastavení + Zpět + Zavřít + Obnovit + Hledat + Vymazat + Kontejner + Spuštěno + Backend: probíhá kontrola... + Backend: nainstalován + Backend: je dostupná aktualizace + Backend: není nainstalován + Backend: poškozen + Přístup roota není dostupný. + Chcete-li pokračovat v procházení aplikace Droidspaces, udělte přístup roota! + Neznámé + Je dostupná aktualizace backendu, klepnutím ji nainstalujete + Backend: je vyžadován zásah + Klepnutím opravíte konfiguraci systému + Backend + Přeinstalovat backend + Přeinstaluje binární soubory a spouštěcí modul + Požadavky + Požadavky na jádro + Požadavky na Termux + Požadavky na jádro bez GKI (<=4.19) + Požadavky na jádro GKI (>=5.4) + Kopírovat + Průvodce + Vzhled + Jazyk + Výchozí nastavení systému + Používat motiv systému + Automaticky se přizpůsobí motivu systému + Tmavý motiv + Zapne tmavý motiv + Režim AMOLED + Čistě černé pozadí pro displeje OLED + Použít výchozí paletu barev systému + Použije motiv Monet systému Android 12+ (Material You) + Barva zvýraznění + Režim démona + Spustí Droidspaces jako trvalého systémového démona, který obchází blokování seccomp v Magisku/APatchi, GrapheneOS apod. Získáváním a spouštěním příkazů z uživatelského prostoru tím umožní vysoce privilegované operace (vyžaduje restart). + Začlenit Droidspaces do systémové cesty + Vytvoří symbolický odkaz v /system/bin, aby byl příkaz droidspaces dostupný v rootovém shellu Androidu. VAROVÁNÍ: ZAPNUTÍ ZPŮSOBÍ BOOTLOOP, POKUD VÁŠ SYSTÉM PŘIPOJENÍ POUŽÍVÁ METAMODULY OVERLAYFS. Tuto možnost používejte pouze se systémem připojení založeným na Magic Mount (vyžaduje restart) + O aplikaci + O aplikaci Droidspaces + Správce kontejnerů ve stylu LXC pro Android + Vývojáři + Správce projektu + Kanál na Telegramu + Přispěvatelé + GitHub + Zdrojový kód + Vytvořit hlášení chyby + Shromáždí systémové informace a protokoly pro ladění + Hlášení chyby + OK + Nejsou nainstalovány žádné kontejnery + Nainstalujte kontejner pomocí tlačítka níže + Nainstalovat kontejner + Nainstalovat + Vyberte soubor .tar.xz nebo .tar.gz. Vybráno: %1$s + Vyberte soubor .tar.xz nebo .tar.gz. Typ souboru se nepodařilo zjistit. + Zastavuje se kontejner... + Odinstalovává se... + Kontejner %1$s se nepodařilo zastavit + Kontejner %1$s se nepodařilo odinstalovat + Kontejner %1$s byl úspěšně odinstalován + Operace se nezdařila! + Operace byla úspěšně dokončena! + Operace „%1$s“ s kontejnerem %2$s selhala + Odinstalovat kontejner? + Tímto trvale odstraníte kontejner „%1$s“ a všechna jeho data. Tuto akci nelze vrátit zpět.\n\nPokud je kontejner spuštěný, bude nejprve zastaven. + Odinstalovat + Potvrďte akci zadáním přesného názvu kontejneru: + Zrušit + Zobrazit protokoly + Upravit konfiguraci kontejneru + Odinstalovat kontejner + SPUŠTĚNO + RESTARTUJE SE + ZASTAVENO + PID: %1$d + Název hostitele: %1$s + Možnosti: %1$s + Spustit + Zastavit + Restartovat + Zakázat IPv6 + Úložiště + HW + GPU + X11 + VirGL + PulseAudio + Permisivní režim SELinuxu + Nestálý režim + cgroup v1 + Ochrana před deadlockem + Privilegovaný režim + Uživatelské jmenné prostory + Souhrn instalace + Zkontrolovat konfiguraci + Archiv tar + Adresář + Cesta instalace + Nejsou zapnuty žádné další možnosti + Možnosti + %1$s: %2$s + %1$s: %2$s + Informace o kontejneru + Distribuce + Verze + Název hostitele + IP adresa + Doba běhu + Informace o kontejneru nelze načíst + Využití disku + Využito %1$s z %2$s + Počítá se… + systemd + Probíhá kontrola + Není dostupné + Spravovat + Upravit kontejner: %1$s + Kontejner je spuštěný + Změny se projeví po restartování + Zakázat IPv6 + Zakáže v tomto kontejneru IPv6. Zakázání IPv6 může narušit aplikace VPN spuštěné v hostiteli. + V režimech sítě NAT a Žádná je IPv6 vždy zakázán. + Úložiště Androidu + Připojí úložiště Androidu + Přístup k hardwaru + Plný přístup k hardwaru + Přístup ke GPU + Zrcadlí uzly GPU do izolovaného /dev (bez úplného přímého předání hardwaru) + Nastavit Termux:X11 + Zapne připojení soketu X11 pro Termux:X11 + Nastavit PulseAudio + Zapne podporu zvuku prostřednictvím PulseAudio + Další příznaky Termux:X11 + např. -dpi 160 -force-sysvshm + Nastavit 3D akceleraci VirGL + Zapne hardwarovou 3D akceleraci prostřednictvím VirGL + Další příznaky VirGL + např. --multi-clients --angle-vulkan + Permisivní režim SELinuxu + Nastaví SELinux do permisivního režimu + Povolit uživatelské jmenné prostory + Povolí neprivilegované uživatelské jmenné prostory, které vyžadují Flatpak, bwrap a Firefox. Bez opraveného jádra však můžete být zranitelní vůči některým exploitům, jako je Fragnesia + Není podporováno. V jádře není zapnuto CONFIG_USER_NS + Spouštět při startu + Spustí kontejner při startu + Priorita automatického spuštění + Pořadí, ve kterém se spustí kontejnery nastavené ke spuštění při startu + Pořadí změníte přetažením. Kontejnery se spouštějí shora dolů – bránu (např. OpenWRT) umístěte jako první, aby se spustila před kontejnery, které přes ni směrují provoz. + Žádný kontejner nemá zapnuté spouštění při startu. + Přetažením změníte pořadí + Vynutit cgroup v1 + Vynutí použití starší hierarchie cgroup v1 + Ruční ochrana před deadlockem + Blokováním vytváření vnořených jmenných prostorů brání deadlockům VFS na úrovni jádra ve starších jádrech (např. 4.14.x).\n\nVAROVÁNÍ: Zapnutí této možnosti zablokuje uvnitř kontejneru podporu sandboxingu Dockeru, Podmanu, LXC a systemd. + Síť + Integrace a hardware + Zabezpečení a spouštění + Pokročilé možnosti + Režim sítě + Hostitel + NAT (výchozí) + Žádná síť (izolováno) + Hostitel + NAT + Žádná + Brána (virtuální směrovač) + Brána + Nastavení brány + Směruje tento kontejner přes jiný kontejner, který funguje jako směrovač (např. OpenWRT). Tato brána musí být spuštěna jako první. + Kontejner brány + Název sítě LAN + lan + Rozhraní v bráně + eth1 + Most hostitele + ds-lan + Nastavit bránu + Nastavit bránu + Tato nastavení určují, jak se kontejner připojí k virtuální síti LAN brány. Výchozí hodnoty fungují pro jednu síť LAN za jednou bránou – měňte je pouze při provozu více segmentů LAN nebo více bran. + Název rozhraní, pod kterým se toto připojení zobrazí UVNITŘ kontejneru brány (např. eth1 v OpenWRT). Musí odpovídat rozhraní, na kterém má brána poskytovat DHCP. Každý samostatný segment stejné brány musí mít jedinečný název. Výchozí: eth1. + Označení tohoto segmentu LAN. Kontejnery se stejnou bránou a názvem sítě LAN se připojí ke stejnému virtuálnímu přepínači a navzájem se uvidí. Chcete-li vytvořit druhý izolovaný segment, použijte jiný název. Výchozí: lan. + Název virtuálního přepínače vytvořeného na hostiteli Androidu. Ponecháte-li pole prázdné, název se vytvoří automaticky z názvu sítě LAN (ds-<lan>). Nastavujte jej pouze v případě, že potřebujete přesný název mostu. Nejvýše 15 znaků. + Vyberte kontejner brány + Kontejner nemůže být svou vlastní bránou + Tento kontejner brány není nainstalován + Nainstalujte jiný kontejner, který bude sloužit jako brána + Použijte pouze písmena, číslice, _ nebo - + Příliš dlouhé – nejvýše 15 znaků + Most již používá „%1$s“. Použijte jiný název sítě LAN. + Rozhraní již používá jiný segment („%1$s“). Použijte jiné. + Nastavení NAT + Výstupní rozhraní (volitelné) + Přiřadí přístup k internetu ke konkrétním rozhraním. Ponecháte-li pole prázdné, aktivní výstupní spojení se zjistí automaticky. + Přidat výstupní rozhraní + Dostupná systémová rozhraní: + Nebo jej zadejte ručně (jsou podporovány i zástupné znaky): + Název rozhraní (např. wlan0, v4-rmnet_data*) + Přesměrování portů + Přidat přesměrování portu + Port hostitele (1–65535 nebo rozsah 100–200) + Port kontejneru (1–65535 nebo rozsah) + Protokol + Volitelné (např. 8080 nebo 1000–2000) + Pro symetrické mapování ponechte prázdné + Neplatný formát rozsahu (ZAČÁTEK–KONEC) + Porty musí být čísla + Porty musí být v rozsahu 1–65535 + Počáteční hodnota musí být menší než koncová + Port musí být číslo + Rozsahy hostitele a kontejneru mají různou šířku + Překrývá se s existujícím pravidlem přesměrování portů + (Symetrické) + Příklady: 8080 (jeden port), 8000–8010 (rozsah). Pro symetrické mapování ponechte cíl prázdný. + Uloženo + Ukládá se... + Uložit změny + Statická IP adresa + Nastaví tomuto kontejneru statickou adresu IPv4 v podsíti 172.28.x.x. Ponecháte-li pole prázdné, adresa se při prvním spuštění přidělí automaticky. + Oktet %1$d + Rozsah: 1–254 + Tato IP adresa je již přiřazena kontejneru „%1$s“ + Konfiguraci se nepodařilo aktualizovat + Nestálý režim + Režim pouze v RAM (změny po zastavení zmizí) + Vazebná připojení + Přidat + Přidat vazebné připojení + Zadejte cestu v kontejneru + např. /mnt/sdcard + Cesta v hostiteli: %1$s + Cesta v kontejneru: %1$s + Jen pro čtení + Servery DNS + např. 1.1.1.1,8.8.8.8 (volitelné) + Pouze číslice, tečky a dvojtečky + Proměnné prostředí + Nastavené proměnné: %1$d + Nenastaveno + Každý řádek obsahuje jednu dvojici KLÍČ=HODNOTA + KLÍČ + HODNOTA + Přidat proměnnou + Vlastní binární soubor init + /sbin/init (výchozí) + Chcete-li použít výchozí init (/sbin/init), ponechte pole prázdné + Cesta musí být absolutní (začínat znakem /) + Toto nastavení neměňte, pokud přesně nevíte, co děláte. Nesprávný binární soubor init zabrání spuštění kontejneru. + Vlastní init + Nastavení kontejneru + Informace o kontejneru + Název kontejneru * + Pro výchozí hodnotu ponechte prázdné + Ponecháte-li pole prázdné, jako název hostitele se použije název kontejneru + Kontejner s tímto názvem již existuje + Další: konfigurace + Konfigurace + Možnosti kontejneru + Další: úložiště + Instaluje se kontejner + Instalace byla dokončena + Instalace se nezdařila + Hotovo + Instaluje se modul + Instaluje se Droidspaces + Modul Magisk byl úspěšně nainstalován + Backend Droidspaces byl úspěšně nainstalován + Došlo k neznámé chybě + Instalace modulu se nezdařila + Instalace binárního souboru se nezdařila + Odstraňuje se starý modul... + Rozbalují se soubory modulu... + Instaluje se modul... + Nastavují se oprávnění... + Ověřuje se instalace... + Připravuje se instalace modulu... + Zjištěná architektura: %1$s + Vytvářejí se adresáře... + Instaluje se binární soubor: %1$s + Udělují se oprávnění... + Připravuje se instalace... + Filtrujte nebo zadejte absolutní cestu… + Žádné shody + Prázdný adresář + Vybrat složku + TCP + UDP + CPU: + RAM: + Protokoly terminálu + Protokoly instalace + Terminál + 8 + 4-512 + Nejsou spuštěny žádné kontejnery + Nejprve spusťte kontejner, aby se zde zobrazil + Využití CPU + Využití RAM + %1$.1f%% + %1$d MB (%2$.1f%%) + Počet spuštěných relací: %1$d · klepnutím obnovíte + Spuštěna 1 relace · klepnutím obnovíte + Služby systemd + Služby OpenWrt + OpenWrt + Byl zjištěn OpenWrt/procd + Správa služeb pro %1$s bude přidána v příští opravě. + Služby OpenRC + OpenRC + Inicializační systém + systemd/OpenWrt/OpenRC není dostupný + V tomto kontejneru není nainstalován systemd, OpenWrt/procd ani OpenRC + Načítají se služby... + Hledat služby... + Povolené + Zakázané + Statické + Nestandardní + Neznámé + Maskované + Vše + Odmaskovat + Povolit + Zakázat + Restartovat službu + Znovu načíst službu + Maskovat službu + Akce „%1$s“ pro službu %2$s proběhla úspěšně + Akce „%1$s“ pro službu %2$s selhala + Chyba: %1$s + Nebyly nalezeny žádné služby + „%1$s“ nebylo v repozitáři nalezeno + Žádné spuštěné služby + Žádné povolené služby + Žádné zakázané služby + Žádné statické služby + Žádné nestandardní služby + Žádné služby s neznámým stavem + Žádné maskované služby + Jádro + Stav SELinuxu + Verze Androidu + Architektura + Podporovaná ABI + Načítá se... + Backend není dostupný + Backend Droidspaces není nainstalován nebo je poškozený. Nejprve jej nainstalujte. + Přístup roota není dostupný + Tato stránka je zakázána, protože přístup roota není dostupný + Operace se nezdařila + Není dostupný žádný výstup + Zavřít + Počkejte prosím na zpracování požadavku... + Dostupní uživatelé + Přihlašovací příkaz pro uživatele %1$s byl zkopírován + Obnovit uživatele + Nebyli nalezeni žádní místní uživatelé + Vybrat uživatele + Kopírovat přihlašovací příkaz + , + %1$d GB + Nastavení řídkého obrazu + Nastavení úložiště + O řídkých obrazech + Řídké obrazy jsou doporučeny při používání kontejnerů v šifrovaném úložišti, na oddílech /data se systémem F2FS nebo v jiných souborových systémech, které mohou mít potíže s přímým připojením adresáře. + Řídký obraz vytvoří vyhrazený soubor s obrazem souborového systému ext4, který se připojí jako smyčkové zařízení, a zajistí tak lepší izolaci a kompatibilitu. + Použít řídký obraz + Vytvoří kontejner v souboru s řídkým obrazem + Velikost obrazu + Je vyžadována velikost + Neplatné číslo + Minimální velikost je 4 GB + Maximální velikost je 512 GB + Velikost (GB) + Zadejte velikost od 4 do 512 GB + Protokoly: %1$s + Vymazat protokoly + Kopírovat protokoly + Název kontejneru nesmí být prázdný + Název kontejneru smí obsahovat pouze písmena, číslice, spojovníky (-), podtržítka (_), tečky (.) a mezery + Název je příliš dlouhý – nejvýše 17 znaků (omezení cesty smyčkového zařízení jádra) + Název hostitele smí obsahovat pouze písmena, číslice a spojovníky (-) + Zkontrolovat požadavky + Kontrola požadavků + Požadavky pro Droidspaces + Kontrola požadavků byla úspěšně dokončena + Kontrola požadavků byla dokončena s chybami + Požadavky na jádro byly zkopírovány do schránky + Protokoly byly zkopírovány do schránky + Terminál + Obnovit + Otevře interaktivní shell uvnitř tohoto kontejneru + Otevřít + Otevřít terminál + Vyberte uživatele, pod kterým chcete vstoupit do kontejneru: + Oprávnění k oznámením + Droidspaces musí zobrazovat trvalé oznámení, aby relace terminálu zůstaly spuštěné na pozadí. Bez něj může Android (zejména na zařízeních Samsung) službu terminálu neočekávaně ukončit. + Udělit oprávnění + Rozumím + Terminál Droidspaces + UKONČIT + Zámek probuzení: ZAPNUT + Zámek probuzení: VYPNUT + Relace terminálu + Aktivní terminálové relace kontejnerů + Převést na řídký obraz + Změnit velikost řídkého obrazu + Exportovat kontejner jako archiv tar + Kontejner %1$s se nepodařilo exportovat + Spouští se export... + Převést na řídký obraz + Změnit velikost řídkého obrazu + Převodem kontejneru „%1$s“ na řídký obraz se vytvoří vyhrazený souborový systém ext4. Přerušení této operace může poškodit data. Zálohujte si data. + Mění se velikost řídkého obrazu kontejneru „%1$s“. Zvětšení je bezpečnější než zmenšení. Zálohujte si data. + Spouští se převod... + Spouští se změna velikosti... + Začínáte s Droidspaces? + Klepnutím sem se dozvíte, jak nastavit svůj první linuxový kontejner! + Privilegovaný režim + Nastavte dílčí uvolnění bezpečnostních omezení pro vnořenou virtualizaci nebo pokročilý přístup k hardwaru. + Vypne ochranné maskování a povolí zápis do /proc a /sys. Poznámka: /sys zůstane jen pro čtení, pokud není zapnutý také přístup k hardwaru. + Neodebírá žádné schopnosti (capabilities) Linuxu (plný přístup roota). + Vypne filtrování systémových volání seccomp. Tím povolí pokročilé funkce jádra, například neprivilegované uživatelské jmenné prostory, které jsou nezbytné pro vnořený sandboxing (Bubblewrap, Flatpak), a také načítání modulů jádra mimo strom. + Zapne sdílené šíření připojení MS_SHARED do hostitele. + Obejde filtrování zařízení. Zobrazí se všechny uzly /dev hostitele. + Zapne všechny privilegované příznaky najednou. + NEBEZPEČNÁ OBLAST + Zapnutím těchto možností ohrožujete zabezpečení svého zařízení. NENESEME odpovědnost za ztrátu dat, znefunkčněná zařízení ani podivné chyby. Potíže vzniklé při používání těchto režimů NEHLASTE. + Potvrďte zadáním přesné fráze „ŽÁDNÉ CHYBY HLÁSIT NEBUDU“ velkými písmeny: + ŽÁDNÉ CHYBY HLÁSIT NEBUDU + K použití pravidel SELinuxu a spuštění démona při startu se doporučuje zařízení restartovat. + Od verze Droidspaces 6.1.0 má udevd skutečný přístup k subsystémům USB, blokových a vstupních zařízení, TTY a sítě. Kontejner může převzít hardware, který právě používá Android.\n\nPokud po spuštění kontejneru přestane v Androidu fungovat klávesnice, karta SD nebo zařízení USB, NEJDE o chybu. Hardware převzal kontejner. Vyžádali jste si plný přístup k hardwaru.\n\nPotíže způsobené konflikty hardwaru nebo panikou jádra při používání tohoto režimu NEHLASTE. + Repozitář rootfs + Repozitář + Obnovit + Archivy tar rootfs v části „Droidspaces developers“ jsou open source a jejich obsah je transparentní. Klepnutím na tuto kartu můžete naše změny ručně stáhnout nebo prozkoumat. + Repozitář se nepodařilo načíst + Stáhnout + Zrušit + Nainstalovat + Zkusit znovu + Stahování rootfs pro Droidspaces + V nejnovějším vydání nebyly nalezeny žádné archivy tar rootfs + Chyba sítě + Neznámá chyba + Ve stahování nelze pokračovat + Úložiště nebylo nalezeno + Soubor již existuje + Chyba souborového systému + Chyba dat HTTP + Nedostatek místa v úložišti + Příliš mnoho přesměrování + Neočekávaná odpověď HTTP + Soubor nebyl nalezen (404) + Kód chyby %1$d + PŘIPRAVENO + STAHUJE SE + SELHALO + Přidat vlastní repozitář + Spravovat repozitáře + Odebírejte vlastní zdroje rootfs.json. + Název repozitáře + Adresa URL souboru rootfs.json + Přihlásit odběr + Odstranit + Název nesmí být prázdný + Adresa URL nesmí být prázdná + Adresa URL musí začínat https:// + Zatím neodebíráte žádné vlastní repozitáře. + Ladění + diff --git a/Android/app/src/main/res/values-km/strings.xml b/Android/app/src/main/res/values-km/strings.xml index 71581e00..b0fc9c6d 100644 --- a/Android/app/src/main/res/values-km/strings.xml +++ b/Android/app/src/main/res/values-km/strings.xml @@ -88,7 +88,7 @@ កុងតឺន័រ Linux ពិតប្រាកដ បើកសិទ្ធិឱ្យកុងតឺន័ររបស់អ្នក ចូលប្រើប្រាស់ Hardware របស់ម៉ាស៊ីនផ្ទាល់ (GPU, សេនស័រ, USB, ឧបករណ៍ Block) ដោយផ្ទាល់។ លុបកុងតឺន័រចេញ - ប្រើប្រាស់ systemd / OpenRC ជា PID 1 + ប្រើប្រាស់ systemd / OpenWrt procd / OpenRC ជា PID 1 កុងតឺន័រដែលមានសិទ្ធិពេញលេញ ចុចដើម្បីជួសជុលការកំណត់រចនាសម្ព័ន្ធប្រព័ន្ធ គាំទ្រប្រព័ន្ធ Init ពេញលេញ (Full init system support)។ ការគ្រប់គ្រងសេវាកម្មពិតប្រាកដ មិនមែនជាបរិស្ថានធ្វើត្រាប់តាម (Simulated environment) នោះទេ។ @@ -111,7 +111,7 @@ ចាប់ផ្ដើមដំណើរការដោយស្វ័យប្រវត្តិនៅពេលបើកម៉ាស៊ីន តម្លើង ឈ្មោះម៉ាស៊ីន: %1$s - បង្កើត Symlink នៅត្រង់ /system/bin ដើម្បីចូលប្រើប្រាស់បញ្ជា (Command) droidspaces នៅក្នុង Android Root Shell។\n\nការព្រមាន៖ ការបើកមុខងារនេះនឹងបង្កឱ្យមានបញ្ហាគាំងបើកឧបករណ៍មិនឡើង (Bootloop) ប្រសិនបើប្រព័ន្ធ Mount របស់អ្នកជាប្រភេទ OverlayFS Meta Modules។ សូមប្រើប្រាស់ជម្រើសនេះ តែក្នុងករណីដែលអ្នកកំពុងប្រើប្រាស់ប្រព័ន្ធ Mount ផ្អែកលើ Magic Mount តែប៉ុណ្ណោះ (តម្រូវឱ្យចាប់ផ្តើមឧបករណ៍ឡើងវិញ)។ + បង្កើត Symlink នៅត្រង់ /system/bin ដើម្បីចូលប្រើប្រាស់បញ្ជា (Command) droidspaces នៅក្នុង Android Root Shell។ការព្រមាន៖ ការបើកមុខងារនេះនឹងបង្កឱ្យមានបញ្ហាគាំងបើកឧបករណ៍មិនឡើង (Bootloop) ប្រសិនបើប្រព័ន្ធ Mount របស់អ្នកជាប្រភេទ OverlayFS Meta Modules។ សូមប្រើប្រាស់ជម្រើសនេះ តែក្នុងករណីដែលអ្នកកំពុងប្រើប្រាស់ប្រព័ន្ធ Mount ផ្អែកលើ Magic Mount តែប៉ុណ្ណោះ (តម្រូវឱ្យចាប់ផ្តើមឧបករណ៍ឡើងវិញ)។ រចនាឡើងមិនអាចបំផ្លាញបាន តម្រូវការខឺណែលមិនមែន GKI (<=4.19) តម្រូវការខឺណែល GKI (>=5.4) @@ -296,8 +296,8 @@ សេវាកម្ម OpenRC OpenRC ប្រព័ន្ធ Init - មិនអាចប្រើប្រព័ន្ធ Systemd/OpenRC បានទេ - កុងតឺន័រនេះមិនមាន systemd ឬ OpenRC ដែលបានដំឡើងទេ + ប្រព័ន្ធ Systemd / OpenWrt / OpenRC មិនមានទេ + ខនតឺន័រនេះមិនមានដំឡើង systemd, OpenWrt/procd ឬ OpenRC ទេ កំពុងទាញយកសេវាកម្ម... ស្វែងរកសេវាកម្ម... បានបើកដំណើរការ @@ -403,7 +403,7 @@ https://github.com/ravindu644/Droidspaces-OSS/blob/main/Documentation/Installation-Android.md របៀប Privileged កំណត់រចនាសម្ព័ន្ធបន្ធូរបន្ថយសន្តិសុខតាមកម្រិតលម្អិត សម្រាប់ការធ្វើនិម្មិតកម្មត្រួតគ្នា (Nested Virtualization) ឬការចូលប្រើប្រាស់ Hardware កម្រិតខ្ពស់។ - "បិទ Jail Masks ដើម្បីអនុញ្ញាតឱ្យមានសិទ្ធិកែប្រែ (Write Access) ទៅលើ /proc និង /sys។ សម្គាល់៖ /sys នៅតែរក្សាស្ថានភាពសម្រាប់តែអានប៉ុណ្ណោះ (Read-only) លុះត្រាតែមុខងារចូលប្រើប្រាស់ Hardware ត្រូវបានបើកដំណើរការផងដែរ។" + បិទ Jail Masks ដើម្បីអនុញ្ញាតឱ្យមានសិទ្ធិកែប្រែ (Write Access) ទៅលើ /proc និង /sys។ សម្គាល់៖ /sys នៅតែរក្សាស្ថានភាពសម្រាប់តែអានប៉ុណ្ណោះ (Read-only) លុះត្រាតែមុខងារចូលប្រើប្រាស់ Hardware ត្រូវបានបើកដំណើរការផងដែរ។ មិនលុបបំបាត់សមត្ថភាព Linux ណាមួយឡើយ (ទទួលបានអំណាច root ពេញលេញ)។ បិទការច្រោះ Seccomp Syscall។ មុខងារនេះនឹងបើកដំណើរការលក្ខណៈពិសេសកម្រិតខ្ពស់របស់ Kernel ដូចជា Unprivileged User Namespaces ដែលជាលក្ខខណ្ឌតម្រូវដាច់ខាតសម្រាប់ Nested Sandboxing (Bubblewrap, Flatpak) ហើយវាក៏អនុញ្ញាតឱ្យផ្ទុក Out-of-tree Kernel Modules ផងដែរ។ បើកដំណើរការការបញ្ជូនទិន្នន័យម៉ោន (Mount Propagation) បែប MS_SHARED ទៅកាន់ម៉ាស៊ីនផ្ទាល់ (Host)។ @@ -414,7 +414,7 @@ វាយពាក្យ \"I WON\'T REPORT ANY BUGS\" ឱ្យបានត្រឹមត្រូវជាអក្សរធំ ដើម្បីបញ្ជាក់៖ I WON\'T REPORT ANY BUGS ណែនាំឱ្យចាប់ផ្តើមឧបករណ៍ឡើងវិញ (Reboot) ដើម្បីអនុវត្តច្បាប់ SELinux និងចាប់ផ្តើមដំណើរការ Daemon រៀងរាល់ពេលបើកឧបករណ៍។ - ចាប់ផ្តើមពី Droidspaces v6.1.0 ទៅ udevd មានសិទ្ធិចូលប្រើប្រាស់ពិតប្រាកដទៅលើ Subsystems របស់ USB, Block, Input, TTY, និង Network។ កុងតឺន័រ (Container) របស់អ្នកអាចកាន់កាប់ Hardware ដែល Android កំពុងប្រើប្រាស់នាពេលបច្ចុប្បន្នបាន។\n\nប្រសិនបើក្តារចុច (Keyboard) កាត SD ឬឧបករណ៍ USB របស់អ្នកឈប់ដំណើរការនៅក្នុង Android បន្ទាប់ពីបើកដំណើរការកុងតឺន័រ — នោះមិនមែនជាបញ្ហាបច្ចេកទេស (Bug) ឡើយ។ គឺកុងតឺន័របានយកវាទៅប្រើ។ អ្នកបានស្នើសុំសិទ្ធិចូលប្រើប្រាស់ Hardware ពេញលេញដោយខ្លួនឯង។\n\nសូមកុំរាយការណ៍ពីបញ្ហាដែលបង្កឡើងដោយការជម្លោះ Hardware (Hardware Conflicts) ឬ Kernel Panics អំឡុងពេលកំពុងប្រើប្រាស់របៀប (Mode) នេះ។ + ចាប់ផ្តើមពី Droidspaces v6.1.0 ទៅ udevd មានសិទ្ធិចូលប្រើប្រាស់ពិតប្រាកដទៅលើ Subsystems របស់ USB, Block, Input, TTY, និង Network។ កុងតឺន័រ (Container) របស់អ្នកអាចកាន់កាប់ Hardware ដែល Android កំពុងប្រើប្រាស់នាពេលបច្ចុប្បន្នបាន។\n\nប្រសិនបើក្តារចុច (Keyboard) កាត SD ឬឧបករណ៍ USB របស់អ្នកឈប់ដំណើរការនៅក្នុង Android បន្ទាប់ពីបើកដំណើរការកុងតឺន័រ — នោះមិនមែនជាបញ្ហាបច្ចេកទេស (Bug) ឡើយ។ គឺកុងតឺន័របានយកវាទៅប្រើ។ អ្នកបានស្នើសុំសិទ្ធិចូលប្រើប្រាស់ Hardware ពេញលេញដោយខ្លួនឯង។\n\nសូមកុំរាយការណ៍ពីបញ្ហាដែលបង្កឡើងដោយការជម្លោះ Hardware ឬ Kernel Panics អំឡុងពេលកំពុងប្រើប្រាស់របៀប (Mode) នេះ។ មិនអាចកែបាន រកមិនឃើញទិន្នន័យ %1$s ឃ្លាំងផ្ទុក Rootfs @@ -465,4 +465,51 @@ បើកដំណើរការមុខងារបង្កើនល្បឿនផ្នែករឹង 3D តាមរយៈ VirGL ប៉ារ៉ាម៉ែត្របន្ថែមសម្រាប់ VirGL ឧទាហរណ៍៖ --multi-clients --angle-vulkan + ពណ៌សម្គាល់ + វាយបញ្ចូលឈ្មោះកុងតែន័រឱ្យបានត្រឹមត្រូវដើម្បីបញ្ជាក់៖ + User Namespaces + ការប្រើប្រាស់ថាស + បានប្រើប្រាស់ %1$s / \\%2$s + កំពុងគណនា… + អនុញ្ញាត User Namespaces + អនុញ្ញាតឱ្យប្រើប្រាស់ unprivileged user namespaces ដែល Flatpak/bwrap/Firefox ត្រូវការ។ ប៉ុន្តែ អ្នកប្រហែលជាប្រឈមនឹងការវាយប្រហារមួយចំនួនដូចជា Fragnesia ប្រសិនបើមិនទាន់បានអាប់ដេត kernel (patched kernel) ទេ + មិនគាំទ្រទេ​. CONFIG_USER_NS មិនត្រូវបានបើកដំណើរការនៅក្នុង kernel ទេ. + អាទិភាពចាប់ផ្ដើមស្វ័យប្រវត្តិ + លំដាប់នៃការចាប់ផ្តើមខនតឺន័រដំណើរការពេលចាប់ផ្ដើមប្រព័ន្ធ + អូសដើម្បីប្ដូរលំដាប់។ ខនតឺន័រចាប់ផ្ដើមពីលើចុះក្រោម - សូមដាក់ច្រកទ្វារ (ឧ. OpenWRT) មុនគេ ដើម្បីវាចាប់ផ្ដើមមុនខនតឺន័រដែលត្រូវតភ្ជាប់ឆ្លងកាត់វា។ + គ្មានខនតឺន័រណាមួយត្រូវបានបើកដំណើរការពេលចាប់ផ្ដើមប្រព័ន្ធទេ។ + អូសដើម្បីប្ដូរលំដាប់ + ច្រកទ្វារ (រ៉ោតទ័រនិម្មិត) + ច្រកទ្វារ (Gateway) + ការកំណត់ច្រកទ្វារ + បញ្ជូនបណ្តាញរបស់ខនតឺន័រនេះឆ្លងកាត់ខនតឺន័រផ្សេងទៀតដែលធ្វើជាតួអង្គរ៉ោតទ័រ (ឧ. OpenWRT)។ ខនតឺន័រច្រកទ្វារនោះត្រូវតែដំណើរការមុន។ + ខនតឺន័រច្រកទ្វារ + ឈ្មោះបណ្តាញ LAN + បណ្តាញ lan + ចំណុចប្រទាក់ក្នុងច្រកទ្វារ (Interface in Gateway) + eth1 + ស្ពានតភ្ជាប់ប្រព័ន្ធគោល (Host Bridge) + ds-lan + កំណត់រចនាសម្ព័ន្ធច្រកទ្វារ + កំណត់រចនាសម្ព័ន្ធច្រកទ្វារ + ការកំណត់ទាំងនេះគ្រប់គ្រងពីរបៀបដែលខនតឺន័រនេះភ្ជាប់ទៅនឹងបណ្តាញ LAN និម្មិតរបស់ច្រកទ្វារ។ តម្លៃលំនាំដើមគឺសាកសមសម្រាប់បណ្តាញ LAN តែមួយនៅពីក្រោយច្រកទ្វារតែមួយ - សូមផ្លាស់ប្តូរវាលុះត្រាតែអ្នកដំណើរការបណ្តាញ LAN ច្រើន ឬមានច្រកទ្វារច្រើនជាងមួយ។ + ឈ្មោះចំណុចប្រទាក់ដែលការតភ្ជាប់នេះបង្ហាញ ខាងក្នុង ខនតឺន័រច្រកទ្វារ (ឧ. eth1 ក្នុង OpenWRT)។ វាត្រូវតែត្រូវគ្នាទៅនឹងចំណុចប្រទាក់ដែលច្រកទ្វាររបស់អ្នកត្រូវបានកំណត់ឱ្យផ្ដល់សេវា DHCP។ សេនហ្កាម (Segment) នីមួយៗនៅលើច្រកទ្វារតែមួយ ត្រូវការឈ្មោះផ្ទាល់ខ្លួនដែលមិនត្រួតស៊ីគ្នា។ លំនាំដើម៖ eth1។ + ស្លាកសម្រាប់បណ្តាញ LAN នេះ។ ខនតឺន័រដែលប្រើប្រាស់ច្រកទ្វារ និងឈ្មោះបណ្តាញ LAN ដូចគ្នា នឹងចូលរួមក្នុងស្វ៊ីចនិម្មិត (virtual switch) តែមួយ និងអាចមើលឃើញគ្នាទៅវិញទៅមក។ សូមប្រើប្រាស់ឈ្មោះផ្សេងដើម្បីបង្កើតបណ្តាញដាច់ដោយឡែកទីពីរ។ លំនាំដើម៖ lan។ + ឈ្មោះរបស់ស្ពាននិម្មិត (virtual switch) ដែលបានបង្កើតនៅលើប្រព័ន្ធប្រតិបត្តិការ Android គោល។ សូមទុកវាឱ្យនៅទว่างដើម្បីដាក់ឈ្មោះដោយស្វ័យប្រវត្តិផ្អែកលើឈ្មោះ LAN (ds-)។ កំណត់វាលុះត្រាតែអ្នកត្រូវការឈ្មោះស្ពានពិតប្រាកដប៉ុណ្ណោះ។ អតិបរមា ១៥ តួអក្សរ។ + ជ្រើសរើសខនតឺន័រច្រកទ្វារ (Select a gateway container) + ខនតឺន័រមិនអាចធ្វើជាច្រកទ្វារខ្លួនឯងបានទេ + ខនតឺន័រច្រកទ្វារនេះមិនទាន់ត្រូវបានដំឡើងទេ + ដំឡើងខនតឺន័រផ្សេងទៀតដើម្បីធ្វើជាច្រកទ្វារ + ប្រើប្រាស់តែអក្សរ លេខ(_) ឬសញ្ញា hyphen (-) ប៉ុណ្ណោះ + វែងពេក - អតិបរមា ១៥ តួអក្សរ + ស្ពានត្រូវបានប្រើប្រាស់រួចហើយដោយ \'%1$s\'។ សូមប្រើឈ្មោះបណ្តាញ LAN ផ្សេង។ + ចំណុចប្រទាក់ត្រូវបានប្រើប្រាស់រួចហើយដោយបណ្តាញផ្សេង (\'%1$s\')។ សូមប្រើចំណុចប្រទាក់ផ្សេង។ + សេវាកម្មOpenWrt + OpenWrt + បានរកឃើញ OpenWrt/procd + ការគ្រប់គ្រងសេវាកម្មសម្រាប់ %1$s នឹងត្រូវបន្ថែមនៅក្នុងកំណែបច្ចុប្បន្នភាពបន្ទាប់។ + មិនស្គាល់ + ផ្ទុកសេវាកម្មឡើងវិញ (Reload service) + គ្មានសេវាកម្មដែលមានស្ថានភាពមិនស្គាល់ទេ + ការបំបាត់កំហុស (Debugging)