You've already forked KernelSU-Next
mirror of
https://github.com/KernelSU-Next/KernelSU-Next.git
synced 2025-08-27 23:46:34 +00:00
manager: common detectable package id replaced - me.weishu.kernelsu -> com.rifsxd.ksunext
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package com.rifsxd.ksunext
|
||||
|
||||
import android.app.Application
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
||||
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
||||
import java.io.File
|
||||
|
||||
lateinit var ksuApp: KernelSUApplication
|
||||
|
||||
class KernelSUApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ksuApp = this
|
||||
|
||||
val context = this
|
||||
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||
Coil.setImageLoader(
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(AppIconKeyer())
|
||||
add(AppIconFetcher.Factory(iconSize, false, context))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
|
||||
val webroot = File(dataDir, "webroot")
|
||||
if (!webroot.exists()) {
|
||||
webroot.mkdir()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.rifsxd.ksunext
|
||||
|
||||
import android.system.Os
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2022/12/10.
|
||||
*/
|
||||
|
||||
data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) {
|
||||
override fun toString(): String {
|
||||
return "$major.$patchLevel.$subLevel"
|
||||
}
|
||||
|
||||
fun isGKI(): Boolean {
|
||||
|
||||
// kernel 6.x
|
||||
if (major > 5) {
|
||||
return true
|
||||
}
|
||||
|
||||
// kernel 5.10.x
|
||||
if (major == 5) {
|
||||
return patchLevel >= 10
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun parseKernelVersion(version: String): KernelVersion {
|
||||
val find = "(\\d+)\\.(\\d+)\\.(\\d+)".toRegex().find(version)
|
||||
return if (find != null) {
|
||||
KernelVersion(find.groupValues[1].toInt(), find.groupValues[2].toInt(), find.groupValues[3].toInt())
|
||||
} else {
|
||||
KernelVersion(-1, -1, -1)
|
||||
}
|
||||
}
|
||||
|
||||
fun getKernelVersion(): KernelVersion {
|
||||
Os.uname().release.let {
|
||||
return parseKernelVersion(it)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.rifsxd.ksunext
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2022/12/8.
|
||||
*/
|
||||
object Natives {
|
||||
// minimal supported kernel version
|
||||
// 10915: allowlist breaking change, add app profile
|
||||
// 10931: app profile struct add 'version' field
|
||||
// 10946: add capabilities
|
||||
// 10977: change groups_count and groups to avoid overflow write
|
||||
// 11071: Fix the issue of failing to set a custom SELinux type
|
||||
// 11998: Next experimental modifications.
|
||||
const val MINIMAL_SUPPORTED_KERNEL = 11998
|
||||
|
||||
// 11640: Support query working mode, LKM or GKI
|
||||
// 12016: Next now supports GKI -> LKM/KMI modes!
|
||||
// when MINIMAL_SUPPORTED_KERNEL > 11640, we can remove this constant.
|
||||
const val MINIMAL_SUPPORTED_KERNEL_LKM = 12016
|
||||
const val KERNEL_SU_DOMAIN = "u:r:su:s0"
|
||||
|
||||
const val ROOT_UID = 0
|
||||
const val ROOT_GID = 0
|
||||
|
||||
init {
|
||||
System.loadLibrary("kernelsu")
|
||||
}
|
||||
|
||||
// become root manager, return true if success.
|
||||
external fun becomeManager(pkg: String?): Boolean
|
||||
val version: Int
|
||||
external get
|
||||
|
||||
// get the uid list of allowed su processes.
|
||||
val allowList: IntArray
|
||||
external get
|
||||
|
||||
val isSafeMode: Boolean
|
||||
external get
|
||||
|
||||
val isLkmMode: Boolean
|
||||
external get
|
||||
|
||||
external fun uidShouldUmount(uid: Int): Boolean
|
||||
|
||||
/**
|
||||
* Get the profile of the given package.
|
||||
* @param key usually the package name
|
||||
* @return return null if failed.
|
||||
*/
|
||||
external fun getAppProfile(key: String?, uid: Int): Profile
|
||||
external fun setAppProfile(profile: Profile?): Boolean
|
||||
|
||||
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
|
||||
private const val NOBODY_UID = 9999
|
||||
|
||||
fun setDefaultUmountModules(umountModules: Boolean): Boolean {
|
||||
Profile(
|
||||
NON_ROOT_DEFAULT_PROFILE_KEY,
|
||||
NOBODY_UID,
|
||||
false,
|
||||
umountModules = umountModules
|
||||
).let {
|
||||
return setAppProfile(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun isDefaultUmountModules(): Boolean {
|
||||
getAppProfile(NON_ROOT_DEFAULT_PROFILE_KEY, NOBODY_UID).let {
|
||||
return it.umountModules
|
||||
}
|
||||
}
|
||||
|
||||
fun requireNewKernel(): Boolean {
|
||||
return version < MINIMAL_SUPPORTED_KERNEL
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Parcelize
|
||||
@Keep
|
||||
data class Profile(
|
||||
// and there is a default profile for root and non-root
|
||||
val name: String,
|
||||
// current uid for the package, this is convivent for kernel to check
|
||||
// if the package name doesn't match uid, then it should be invalidated.
|
||||
val currentUid: Int = 0,
|
||||
|
||||
// if this is true, kernel will grant root permission to this package
|
||||
val allowSu: Boolean = false,
|
||||
|
||||
// these are used for root profile
|
||||
val rootUseDefault: Boolean = true,
|
||||
val rootTemplate: String? = null,
|
||||
val uid: Int = ROOT_UID,
|
||||
val gid: Int = ROOT_GID,
|
||||
val groups: List<Int> = mutableListOf(),
|
||||
val capabilities: List<Int> = mutableListOf(),
|
||||
val context: String = KERNEL_SU_DOMAIN,
|
||||
val namespace: Int = Namespace.INHERITED.ordinal,
|
||||
|
||||
val nonRootUseDefault: Boolean = true,
|
||||
val umountModules: Boolean = true,
|
||||
var rules: String = "", // this field is save in ksud!!
|
||||
) : Parcelable {
|
||||
enum class Namespace {
|
||||
INHERITED,
|
||||
GLOBAL,
|
||||
INDIVIDUAL,
|
||||
}
|
||||
|
||||
constructor() : this("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rifsxd.ksunext.profile
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/6/3.
|
||||
*/
|
||||
enum class Capabilities(val cap: Int, val display: String, val desc: String) {
|
||||
CAP_CHOWN(0, "CHOWN", "Make arbitrary changes to file UIDs and GIDs (see chown(2))"),
|
||||
CAP_DAC_OVERRIDE(1, "DAC_OVERRIDE", "Bypass file read, write, and execute permission checks"),
|
||||
CAP_DAC_READ_SEARCH(2, "DAC_READ_SEARCH", "Bypass file read permission checks and directory read and execute permission checks"),
|
||||
CAP_FOWNER(3, "FOWNER", "Bypass permission checks on operations that normally require the filesystem UID of the process to match the UID of the file (e.g., chmod(2), utime(2)), excluding those operations covered by CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH"),
|
||||
CAP_FSETID(4, "FSETID", "Don’t clear set-user-ID and set-group-ID permission bits when a file is modified; set the set-group-ID bit for a file whose GID does not match the filesystem or any of the supplementary GIDs of the calling process"),
|
||||
CAP_KILL(5, "KILL", "Bypass permission checks for sending signals (see kill(2))."),
|
||||
CAP_SETGID(6, "SETGID", "Make arbitrary manipulations of process GIDs and supplementary GID list; allow setgid(2) manipulation of the caller’s effective and real group IDs"),
|
||||
CAP_SETUID(7, "SETUID", "Make arbitrary manipulations of process UIDs (setuid(2), setreuid(2), setresuid(2), setfsuid(2)); allow changing the current process user IDs; allow changing of the current process group ID to any value in the system’s range of legal group IDs"),
|
||||
CAP_SETPCAP(8, "SETPCAP", "If file capabilities are supported: grant or remove any capability in the caller’s permitted capability set to or from any other process. (This property supersedes the obsolete notion of giving a process all capabilities by granting all capabilities in its permitted set, and of removing all capabilities from a process by granting no capabilities in its permitted set. It does not permit any actions that were not permitted before.)"),
|
||||
CAP_LINUX_IMMUTABLE(9, "LINUX_IMMUTABLE", "Set the FS_APPEND_FL and FS_IMMUTABLE_FL inode flags (see chattr(1))."),
|
||||
CAP_NET_BIND_SERVICE(10, "NET_BIND_SERVICE", "Bind a socket to Internet domain"),
|
||||
CAP_NET_BROADCAST(11, "NET_BROADCAST", "Make socket broadcasts, and listen to multicasts"),
|
||||
CAP_NET_ADMIN(12, "NET_ADMIN", "Perform various network-related operations: interface configuration, administration of IP firewall, masquerading, and accounting, modify routing tables, bind to any address for transparent proxying, set type-of-service (TOS), clear driver statistics, set promiscuous mode, enabling multicasting, use setsockopt(2) to set the following socket options: SO_DEBUG, SO_MARK, SO_PRIORITY (for a priority outside the range 0 to 6), SO_RCVBUFFORCE, and SO_SNDBUFFORCE"),
|
||||
CAP_NET_RAW(13, "NET_RAW", "Use RAW and PACKET sockets"),
|
||||
CAP_IPC_LOCK(14, "IPC_LOCK", "Lock memory (mlock(2), mlockall(2), mmap(2), shmctl(2))"),
|
||||
CAP_IPC_OWNER(15, "IPC_OWNER", "Bypass permission checks for operations on System V IPC objects"),
|
||||
CAP_SYS_MODULE(16, "SYS_MODULE", "Load and unload kernel modules (see init_module(2) and delete_module(2)); in kernels before 2.6.25, this also granted rights for various other operations related to kernel modules"),
|
||||
CAP_SYS_RAWIO(17, "SYS_RAWIO", "Perform I/O port operations (iopl(2) and ioperm(2)); access /proc/kcore"),
|
||||
CAP_SYS_CHROOT(18, "SYS_CHROOT", "Use chroot(2)"),
|
||||
CAP_SYS_PTRACE(19, "SYS_PTRACE", "Trace arbitrary processes using ptrace(2)"),
|
||||
CAP_SYS_PACCT(20, "SYS_PACCT", "Use acct(2)"),
|
||||
CAP_SYS_ADMIN(21, "SYS_ADMIN", "Perform a range of system administration operations including: quotactl(2), mount(2), umount(2), swapon(2), swapoff(2), sethostname(2), and setdomainname(2); set and modify process resource limits (setrlimit(2)); perform various network-related operations (e.g., setting privileged socket options, enabling multicasting, interface configuration); perform various IPC operations (e.g., SysV semaphores, POSIX message queues, System V shared memory); allow reboot and kexec_load(2); override /proc/sys kernel tunables; perform ptrace(2) PTRACE_SECCOMP_GET_FILTER operation; perform some tracing and debugging operations (see ptrace(2)); administer the lifetime of kernel tracepoints (tracefs(5)); perform the KEYCTL_CHOWN and KEYCTL_SETPERM keyctl(2) operations; perform the following keyctl(2) operations: KEYCTL_CAPABILITIES, KEYCTL_CAPSQUASH, and KEYCTL_PKEY_ OPERATIONS; set state for the Extensible Authentication Protocol (EAP) kernel module; and override the RLIMIT_NPROC resource limit; allow ioperm/iopl access to I/O ports"),
|
||||
CAP_SYS_BOOT(22, "SYS_BOOT", "Use reboot(2) and kexec_load(2), reboot and load a new kernel for later execution"),
|
||||
CAP_SYS_NICE(23, "SYS_NICE", "Raise process nice value (nice(2), setpriority(2)) and change the nice value for arbitrary processes; set real-time scheduling policies for calling process, and set scheduling policies and priorities for arbitrary processes (sched_setscheduler(2), sched_setparam(2)"),
|
||||
CAP_SYS_RESOURCE(24, "SYS_RESOURCE", "Override resource Limits. Set resource limits (setrlimit(2), prlimit(2)), override quota limits (quota(2), quotactl(2)), override reserved space on ext2 filesystem (ext2_ioctl(2)), override size restrictions on IPC message queues (msg(2)) and system V shared memory segments (shmget(2)), and override the /proc/sys/fs/pipe-size-max limit"),
|
||||
CAP_SYS_TIME(25, "SYS_TIME", "Set system clock (settimeofday(2), stime(2), adjtimex(2)); set real-time (hardware) clock"),
|
||||
CAP_SYS_TTY_CONFIG(26, "SYS_TTY_CONFIG", "Use vhangup(2); employ various privileged ioctl(2) operations on virtual terminals"),
|
||||
CAP_MKNOD(27, "MKNOD", "Create special files using mknod(2)"),
|
||||
CAP_LEASE(28, "LEASE", "Establish leases on arbitrary files (see fcntl(2))"),
|
||||
CAP_AUDIT_WRITE(29, "AUDIT_WRITE", "Write records to kernel auditing log"),
|
||||
CAP_AUDIT_CONTROL(30, "AUDIT_CONTROL", "Enable and disable kernel auditing; change auditing filter rules; retrieve auditing status and filtering rules"),
|
||||
CAP_SETFCAP(31, "SETFCAP", "If file capabilities are supported: grant or remove any capability in any capability set to any file"),
|
||||
CAP_MAC_OVERRIDE(32, "MAC_OVERRIDE", "Override Mandatory Access Control (MAC). Implemented for the Smack Linux Security Module (LSM)"),
|
||||
CAP_MAC_ADMIN(33, "MAC_ADMIN", "Allow MAC configuration or state changes. Implemented for the Smack LSM"),
|
||||
CAP_SYSLOG(34, "SYSLOG", "Perform privileged syslog(2) operations. See syslog(2) for information on which operations require privilege"),
|
||||
CAP_WAKE_ALARM(35, "WAKE_ALARM", "Trigger something that will wake up the system"),
|
||||
CAP_BLOCK_SUSPEND(36, "BLOCK_SUSPEND", "Employ features that can block system suspend"),
|
||||
CAP_AUDIT_READ(37, "AUDIT_READ", "Allow reading the audit log via a multicast netlink socket"),
|
||||
CAP_PERFMON(38, "PERFMON", "Allow performance monitoring via perf_event_open(2)"),
|
||||
CAP_BPF(39, "BPF", "Allow BPF operations via bpf(2)"),
|
||||
CAP_CHECKPOINT_RESTORE(40, "CHECKPOINT_RESTORE", "Allow processes to be checkpointed via checkpoint/restore in user namespace(2)"),
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.rifsxd.ksunext.profile
|
||||
|
||||
/**
|
||||
* https://cs.android.com/android/platform/superproject/main/+/main:system/core/libcutils/include/private/android_filesystem_config.h
|
||||
* @author weishu
|
||||
* @date 2023/6/3.
|
||||
*/
|
||||
enum class Groups(val gid: Int, val display: String, val desc: String) {
|
||||
ROOT(0, "root", "traditional unix root user"),
|
||||
DAEMON(1, "daemon", "Traditional unix daemon owner."),
|
||||
BIN(2, "bin", "Traditional unix binaries owner."),
|
||||
SYS(3, "sys", "A group with the same gid on Linux/macOS/Android."),
|
||||
SYSTEM(1000, "system", "system server"),
|
||||
RADIO(1001, "radio", "telephony subsystem, RIL"),
|
||||
BLUETOOTH(1002, "bluetooth", "bluetooth subsystem"),
|
||||
GRAPHICS(1003, "graphics", "graphics devices"),
|
||||
INPUT(1004, "input", "input devices"),
|
||||
AUDIO(1005, "audio", "audio devices"),
|
||||
CAMERA(1006, "camera", "camera devices"),
|
||||
LOG(1007, "log", "log devices"),
|
||||
COMPASS(1008, "compass", "compass device"),
|
||||
MOUNT(1009, "mount", "mountd socket"),
|
||||
WIFI(1010, "wifi", "wifi subsystem"),
|
||||
ADB(1011, "adb", "android debug bridge (adbd)"),
|
||||
INSTALL(1012, "install", "group for installing packages"),
|
||||
MEDIA(1013, "media", "mediaserver process"),
|
||||
DHCP(1014, "dhcp", "dhcp client"),
|
||||
SDCARD_RW(1015, "sdcard_rw", "external storage write access"),
|
||||
VPN(1016, "vpn", "vpn system"),
|
||||
KEYSTORE(1017, "keystore", "keystore subsystem"),
|
||||
USB(1018, "usb", "USB devices"),
|
||||
DRM(1019, "drm", "DRM server"),
|
||||
MDNSR(1020, "mdnsr", "MulticastDNSResponder (service discovery)"),
|
||||
GPS(1021, "gps", "GPS daemon"),
|
||||
UNUSED1(1022, "unused1", "deprecated, DO NOT USE"),
|
||||
MEDIA_RW(1023, "media_rw", "internal media storage write access"),
|
||||
MTP(1024, "mtp", "MTP USB driver access"),
|
||||
UNUSED2(1025, "unused2", "deprecated, DO NOT USE"),
|
||||
DRMRPC(1026, "drmrpc", "group for drm rpc"),
|
||||
NFC(1027, "nfc", "nfc subsystem"),
|
||||
SDCARD_R(1028, "sdcard_r", "external storage read access"),
|
||||
CLAT(1029, "clat", "clat part of nat464"),
|
||||
LOOP_RADIO(1030, "loop_radio", "loop radio devices"),
|
||||
MEDIA_DRM(1031, "media_drm", "MediaDrm plugins"),
|
||||
PACKAGE_INFO(1032, "package_info", "access to installed package details"),
|
||||
SDCARD_PICS(1033, "sdcard_pics", "external storage photos access"),
|
||||
SDCARD_AV(1034, "sdcard_av", "external storage audio/video access"),
|
||||
SDCARD_ALL(1035, "sdcard_all", "access all users external storage"),
|
||||
LOGD(1036, "logd", "log daemon"),
|
||||
SHARED_RELRO(1037, "shared_relro", "creator of shared GNU RELRO files"),
|
||||
DBUS(1038, "dbus", "dbus-daemon IPC broker process"),
|
||||
TLSDATE(1039, "tlsdate", "tlsdate unprivileged user"),
|
||||
MEDIA_EX(1040, "media_ex", "mediaextractor process"),
|
||||
AUDIOSERVER(1041, "audioserver", "audioserver process"),
|
||||
METRICS_COLL(1042, "metrics_coll", "metrics_collector process"),
|
||||
METRICSD(1043, "metricsd", "metricsd process"),
|
||||
WEBSERV(1044, "webserv", "webservd process"),
|
||||
DEBUGGERD(1045, "debuggerd", "debuggerd unprivileged user"),
|
||||
MEDIA_CODEC(1046, "media_codec", "media_codec process"),
|
||||
CAMERASERVER(1047, "cameraserver", "cameraserver process"),
|
||||
FIREWALL(1048, "firewall", "firewall process"),
|
||||
TRUNKS(1049, "trunks", "trunksd process"),
|
||||
NVRAM(1050, "nvram", "nvram daemon"),
|
||||
DNS(1051, "dns", "DNS resolution daemon (system: netd)"),
|
||||
DNS_TETHER(1052, "dns_tether", "DNS resolution daemon (tether: dnsmasq)"),
|
||||
WEBVIEW_ZYGOTE(1053, "webview_zygote", "WebView zygote process"),
|
||||
VEHICLE_NETWORK(1054, "vehicle_network", "Vehicle network service"),
|
||||
MEDIA_AUDIO(1055, "media_audio", "GID for audio files on internal media storage"),
|
||||
MEDIA_VIDEO(1056, "media_video", "GID for video files on internal media storage"),
|
||||
MEDIA_IMAGE(1057, "media_image", "GID for image files on internal media storage"),
|
||||
TOMBSTONED(1058, "tombstoned", "tombstoned user"),
|
||||
MEDIA_OBB(1059, "media_obb", "GID for OBB files on internal media storage"),
|
||||
ESE(1060, "ese", "embedded secure element (eSE) subsystem"),
|
||||
OTA_UPDATE(1061, "ota_update", "resource tracking UID for OTA updates"),
|
||||
AUTOMOTIVE_EVS(1062, "automotive_evs", "Automotive rear and surround view system"),
|
||||
LOWPAN(1063, "lowpan", "LoWPAN subsystem"),
|
||||
HSM(1064, "lowpan", "hardware security module subsystem"),
|
||||
RESERVED_DISK(1065, "reserved_disk", "GID that has access to reserved disk space"),
|
||||
STATSD(1066, "statsd", "statsd daemon"),
|
||||
INCIDENTD(1067, "incidentd", "incidentd daemon"),
|
||||
SECURE_ELEMENT(1068, "secure_element", "secure element subsystem"),
|
||||
LMKD(1069, "lmkd", "low memory killer daemon"),
|
||||
LLKD(1070, "llkd", "live lock daemon"),
|
||||
IORAPD(1071, "iorapd", "input/output readahead and pin daemon"),
|
||||
GPU_SERVICE(1072, "gpu_service", "GPU service daemon"),
|
||||
NETWORK_STACK(1073, "network_stack", "network stack service"),
|
||||
GSID(1074, "GSID", "GSI service daemon"),
|
||||
FSVERITY_CERT(1075, "fsverity_cert", "fs-verity key ownership in keystore"),
|
||||
CREDSTORE(1076, "credstore", "identity credential manager service"),
|
||||
EXTERNAL_STORAGE(1077, "external_storage", "Full external storage access including USB OTG volumes"),
|
||||
EXT_DATA_RW(1078, "ext_data_rw", "GID for app-private data directories on external storage"),
|
||||
EXT_OBB_RW(1079, "ext_obb_rw", "GID for OBB directories on external storage"),
|
||||
CONTEXT_HUB(1080, "context_hub", "GID for access to the Context Hub"),
|
||||
VIRTUALIZATIONSERVICE(1081, "virtualizationservice", "VirtualizationService daemon"),
|
||||
ARTD(1082, "artd", "ART Service daemon"),
|
||||
UWB(1083, "uwb", "UWB subsystem"),
|
||||
THREAD_NETWORK(1084, "thread_network", "Thread Network subsystem"),
|
||||
DICED(1085, "diced", "Android's DICE daemon"),
|
||||
DMESGD(1086, "dmesgd", "dmesg parsing daemon for kernel report collection"),
|
||||
JC_WEAVER(1087, "jc_weaver", "Javacard Weaver HAL - to manage omapi ARA rules"),
|
||||
JC_STRONGBOX(1088, "jc_strongbox", "Javacard Strongbox HAL - to manage omapi ARA rules"),
|
||||
JC_IDENTITYCRED(1089, "jc_identitycred", "Javacard Identity Cred HAL - to manage omapi ARA rules"),
|
||||
SDK_SANDBOX(1090, "sdk_sandbox", "SDK sandbox virtual UID"),
|
||||
SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"),
|
||||
PRNG_SEEDER(1092, "prng_seeder", "PRNG seeder daemon"),
|
||||
|
||||
SHELL(2000, "shell", "adb and debug shell user"),
|
||||
CACHE(2001, "cache", "cache access"),
|
||||
DIAG(2002, "diag", "access to diagnostic resources"),
|
||||
|
||||
/* The 3000 series are intended for use as supplemental group id's only.
|
||||
* They indicate special Android capabilities that the kernel is aware of. */
|
||||
NET_BT_ADMIN(3001, "net_bt_admin", "bluetooth: create any socket"),
|
||||
NET_BT(3002, "net_bt", "bluetooth: create sco, rfcomm or l2cap sockets"),
|
||||
INET(3003, "inet", "can create AF_INET and AF_INET6 sockets"),
|
||||
NET_RAW(3004, "net_raw", "can create raw INET sockets"),
|
||||
NET_ADMIN(3005, "net_admin", "can configure interfaces and routing tables."),
|
||||
NET_BW_STATS(3006, "net_bw_stats", "read bandwidth statistics"),
|
||||
NET_BW_ACCT(3007, "net_bw_acct", "change bandwidth statistics accounting"),
|
||||
NET_BT_STACK(3008, "net_bt_stack", "access to various bluetooth management functions"),
|
||||
READPROC(3009, "readproc", "Allow /proc read access"),
|
||||
WAKELOCK(3010, "wakelock", "Allow system wakelock read/write access"),
|
||||
UHID(3011, "uhid", "Allow read/write to /dev/uhid node"),
|
||||
READTRACEFS(3012, "readtracefs", "Allow tracefs read"),
|
||||
|
||||
EVERYBODY(9997, "everybody", "Shared external storage read/write"),
|
||||
MISC(9998, "misc", "Access to misc storage"),
|
||||
NOBODY(9999, "nobody", "Reserved"),
|
||||
APP(10000, "app", "Access to app data"),
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.rifsxd.ksunext.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.IBinder;
|
||||
import android.os.UserHandle;
|
||||
import android.os.UserManager;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.topjohnwu.superuser.ipc.RootService;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.rifsxd.ksunext.IKsuInterface;
|
||||
import rikka.parcelablelist.ParcelableListSlice;
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/4/18.
|
||||
*/
|
||||
|
||||
public class KsuService extends RootService {
|
||||
|
||||
private static final String TAG = "KsuService";
|
||||
|
||||
class Stub extends IKsuInterface.Stub {
|
||||
@Override
|
||||
public ParcelableListSlice<PackageInfo> getPackages(int flags) {
|
||||
List<PackageInfo> list = getInstalledPackagesAll(flags);
|
||||
Log.i(TAG, "getPackages: " + list.size());
|
||||
return new ParcelableListSlice<>(list);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(@NonNull Intent intent) {
|
||||
return new Stub();
|
||||
}
|
||||
|
||||
List<Integer> getUserIds() {
|
||||
List<Integer> result = new ArrayList<>();
|
||||
UserManager um = (UserManager) getSystemService(Context.USER_SERVICE);
|
||||
List<UserHandle> userProfiles = um.getUserProfiles();
|
||||
for (UserHandle userProfile : userProfiles) {
|
||||
int userId = userProfile.hashCode();
|
||||
result.add(userProfile.hashCode());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ArrayList<PackageInfo> getInstalledPackagesAll(int flags) {
|
||||
ArrayList<PackageInfo> packages = new ArrayList<>();
|
||||
for (Integer userId : getUserIds()) {
|
||||
Log.i(TAG, "getInstalledPackagesAll: " + userId);
|
||||
packages.addAll(getInstalledPackagesAsUser(flags, userId));
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
List<PackageInfo> getInstalledPackagesAsUser(int flags, int userId) {
|
||||
try {
|
||||
PackageManager pm = getPackageManager();
|
||||
Method getInstalledPackagesAsUser = pm.getClass().getDeclaredMethod("getInstalledPackagesAsUser", int.class, int.class);
|
||||
return (List<PackageInfo>) getInstalledPackagesAsUser.invoke(pm, flags, userId);
|
||||
} catch (Throwable e) {
|
||||
Log.e(TAG, "err", e);
|
||||
}
|
||||
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.rifsxd.ksunext.ui
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
|
||||
import com.ramcosta.composedestinations.generated.NavGraphs
|
||||
import com.ramcosta.composedestinations.utils.isRouteOnBackStackAsState
|
||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.ksuApp
|
||||
import com.rifsxd.ksunext.ui.screen.BottomBarDestination
|
||||
import com.rifsxd.ksunext.ui.theme.KernelSUTheme
|
||||
import com.rifsxd.ksunext.ui.util.LocalSnackbarHost
|
||||
import com.rifsxd.ksunext.ui.util.rootAvailable
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
// Enable edge to edge
|
||||
enableEdgeToEdge()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
KernelSUTheme {
|
||||
val navController = rememberNavController()
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
Scaffold(
|
||||
bottomBar = { BottomBar(navController) },
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0)
|
||||
) { innerPadding ->
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarHost provides snackBarHostState,
|
||||
) {
|
||||
DestinationsNavHost(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
navGraph = NavGraphs.root,
|
||||
navController = navController,
|
||||
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
|
||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition
|
||||
get() = { fadeIn(animationSpec = tween(340)) }
|
||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition
|
||||
get() = { fadeOut(animationSpec = tween(340)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(navController: NavHostController) {
|
||||
val navigator = navController.rememberDestinationsNavigator()
|
||||
val isManager = Natives.becomeManager(ksuApp.packageName)
|
||||
val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable()
|
||||
NavigationBar(
|
||||
tonalElevation = 8.dp,
|
||||
windowInsets = WindowInsets.systemBars.union(WindowInsets.displayCutout).only(
|
||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
|
||||
)
|
||||
) {
|
||||
BottomBarDestination.entries.forEach { destination ->
|
||||
if (!fullFeatured && destination.rootRequired) return@forEach
|
||||
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
|
||||
NavigationBarItem(
|
||||
selected = isCurrentDestOnBackStack,
|
||||
onClick = {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
navigator.popBackStack(destination.direction, false)
|
||||
}
|
||||
navigator.navigate(destination.direction) {
|
||||
popUpTo(NavGraphs.root) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
Icon(destination.iconSelected, stringResource(destination.label))
|
||||
} else {
|
||||
Icon(destination.iconNotSelected, stringResource(destination.label))
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
alwaysShowLabel = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.rifsxd.ksunext.ui.component
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.rifsxd.ksunext.BuildConfig
|
||||
import com.rifsxd.ksunext.R
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AboutCard() {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
AboutCardContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AboutDialog(dismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = { dismiss() }
|
||||
) {
|
||||
AboutCard()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutCardContent() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
color = colorResource(id = R.color.ic_launcher_background),
|
||||
shape = CircleShape
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "icon",
|
||||
modifier = Modifier.scale(1.2f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
|
||||
Text(
|
||||
stringResource(id = R.string.app_name),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
BuildConfig.VERSION_NAME,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val annotatedString = AnnotatedString.Companion.fromHtml(
|
||||
htmlString = stringResource(
|
||||
id = R.string.about_source_code,
|
||||
"<b><a href=\"https://github.com/rifsxd/KernelSU\">GitHub</a></b>"
|
||||
),
|
||||
linkStyles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
pressedStyle = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
background = MaterialTheme.colorScheme.secondaryContainer,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = annotatedString,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package com.rifsxd.ksunext.ui.component
|
||||
|
||||
import android.graphics.text.LineBreaker
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.text.Layout
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.utils.NoCopySpannableFactory
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val TAG = "DialogComponent"
|
||||
|
||||
interface ConfirmDialogVisuals : Parcelable {
|
||||
val title: String
|
||||
val content: String
|
||||
val isMarkdown: Boolean
|
||||
val confirm: String?
|
||||
val dismiss: String?
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
private data class ConfirmDialogVisualsImpl(
|
||||
override val title: String,
|
||||
override val content: String,
|
||||
override val isMarkdown: Boolean,
|
||||
override val confirm: String?,
|
||||
override val dismiss: String?,
|
||||
) : ConfirmDialogVisuals {
|
||||
companion object {
|
||||
val Empty: ConfirmDialogVisuals = ConfirmDialogVisualsImpl("", "", false, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
interface DialogHandle {
|
||||
val isShown: Boolean
|
||||
val dialogType: String
|
||||
fun show()
|
||||
fun hide()
|
||||
}
|
||||
|
||||
interface LoadingDialogHandle : DialogHandle {
|
||||
suspend fun <R> withLoading(block: suspend () -> R): R
|
||||
fun showLoading()
|
||||
}
|
||||
|
||||
sealed interface ConfirmResult {
|
||||
object Confirmed : ConfirmResult
|
||||
object Canceled : ConfirmResult
|
||||
}
|
||||
|
||||
interface ConfirmDialogHandle : DialogHandle {
|
||||
val visuals: ConfirmDialogVisuals
|
||||
|
||||
fun showConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
markdown: Boolean = false,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
)
|
||||
|
||||
suspend fun awaitConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
markdown: Boolean = false,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
): ConfirmResult
|
||||
}
|
||||
|
||||
private abstract class DialogHandleBase(
|
||||
val visible: MutableState<Boolean>,
|
||||
val coroutineScope: CoroutineScope
|
||||
) : DialogHandle {
|
||||
override val isShown: Boolean
|
||||
get() = visible.value
|
||||
|
||||
override fun show() {
|
||||
coroutineScope.launch {
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
final override fun hide() {
|
||||
coroutineScope.launch {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return dialogType
|
||||
}
|
||||
}
|
||||
|
||||
private class LoadingDialogHandleImpl(
|
||||
visible: MutableState<Boolean>,
|
||||
coroutineScope: CoroutineScope
|
||||
) : LoadingDialogHandle, DialogHandleBase(visible, coroutineScope) {
|
||||
override suspend fun <R> withLoading(block: suspend () -> R): R {
|
||||
return coroutineScope.async {
|
||||
try {
|
||||
visible.value = true
|
||||
block()
|
||||
} finally {
|
||||
visible.value = false
|
||||
}
|
||||
}.await()
|
||||
}
|
||||
|
||||
override fun showLoading() {
|
||||
show()
|
||||
}
|
||||
|
||||
override val dialogType: String get() = "LoadingDialog"
|
||||
}
|
||||
|
||||
typealias NullableCallback = (() -> Unit)?
|
||||
|
||||
interface ConfirmCallback {
|
||||
|
||||
val onConfirm: NullableCallback
|
||||
|
||||
val onDismiss: NullableCallback
|
||||
|
||||
val isEmpty: Boolean get() = onConfirm == null && onDismiss == null
|
||||
|
||||
companion object {
|
||||
operator fun invoke(onConfirmProvider: () -> NullableCallback, onDismissProvider: () -> NullableCallback): ConfirmCallback {
|
||||
return object : ConfirmCallback {
|
||||
override val onConfirm: NullableCallback
|
||||
get() = onConfirmProvider()
|
||||
override val onDismiss: NullableCallback
|
||||
get() = onDismissProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ConfirmDialogHandleImpl(
|
||||
visible: MutableState<Boolean>,
|
||||
coroutineScope: CoroutineScope,
|
||||
callback: ConfirmCallback,
|
||||
override var visuals: ConfirmDialogVisuals = ConfirmDialogVisualsImpl.Empty,
|
||||
private val resultFlow: ReceiveChannel<ConfirmResult>
|
||||
) : ConfirmDialogHandle, DialogHandleBase(visible, coroutineScope) {
|
||||
private class ResultCollector(
|
||||
private val callback: ConfirmCallback
|
||||
) : FlowCollector<ConfirmResult> {
|
||||
fun handleResult(result: ConfirmResult) {
|
||||
Log.d(TAG, "handleResult: ${result.javaClass.simpleName}")
|
||||
when (result) {
|
||||
ConfirmResult.Confirmed -> onConfirm()
|
||||
ConfirmResult.Canceled -> onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
fun onConfirm() {
|
||||
callback.onConfirm?.invoke()
|
||||
}
|
||||
|
||||
fun onDismiss() {
|
||||
callback.onDismiss?.invoke()
|
||||
}
|
||||
|
||||
override suspend fun emit(value: ConfirmResult) {
|
||||
handleResult(value)
|
||||
}
|
||||
}
|
||||
|
||||
private val resultCollector = ResultCollector(callback)
|
||||
|
||||
private var awaitContinuation: CancellableContinuation<ConfirmResult>? = null
|
||||
|
||||
private val isCallbackEmpty = callback.isEmpty
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
resultFlow
|
||||
.consumeAsFlow()
|
||||
.onEach { result ->
|
||||
awaitContinuation?.let {
|
||||
awaitContinuation = null
|
||||
if (it.isActive) {
|
||||
it.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEach { hide() }
|
||||
.collect(resultCollector)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitResult(): ConfirmResult {
|
||||
return suspendCancellableCoroutine {
|
||||
awaitContinuation = it.apply {
|
||||
if (isCallbackEmpty) {
|
||||
invokeOnCancellation {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateVisuals(visuals: ConfirmDialogVisuals) {
|
||||
this.visuals = visuals
|
||||
}
|
||||
|
||||
override fun show() {
|
||||
if (visuals !== ConfirmDialogVisualsImpl.Empty) {
|
||||
super.show()
|
||||
} else {
|
||||
throw UnsupportedOperationException("can't show confirm dialog with the Empty visuals")
|
||||
}
|
||||
}
|
||||
|
||||
override fun showConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
markdown: Boolean,
|
||||
confirm: String?,
|
||||
dismiss: String?
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
updateVisuals(ConfirmDialogVisualsImpl(title, content, markdown, confirm, dismiss))
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun awaitConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
markdown: Boolean,
|
||||
confirm: String?,
|
||||
dismiss: String?
|
||||
): ConfirmResult {
|
||||
coroutineScope.launch {
|
||||
updateVisuals(ConfirmDialogVisualsImpl(title, content, markdown, confirm, dismiss))
|
||||
show()
|
||||
}
|
||||
return awaitResult()
|
||||
}
|
||||
|
||||
override val dialogType: String get() = "ConfirmDialog"
|
||||
|
||||
override fun toString(): String {
|
||||
return "${super.toString()}(visuals: $visuals)"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun Saver(
|
||||
visible: MutableState<Boolean>,
|
||||
coroutineScope: CoroutineScope,
|
||||
callback: ConfirmCallback,
|
||||
resultChannel: ReceiveChannel<ConfirmResult>
|
||||
) = Saver<ConfirmDialogHandle, ConfirmDialogVisuals>(
|
||||
save = {
|
||||
it.visuals
|
||||
},
|
||||
restore = {
|
||||
Log.d(TAG, "ConfirmDialog restore, visuals: $it")
|
||||
ConfirmDialogHandleImpl(visible, coroutineScope, callback, it, resultChannel)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class CustomDialogHandleImpl(
|
||||
visible: MutableState<Boolean>,
|
||||
coroutineScope: CoroutineScope
|
||||
) : DialogHandleBase(visible, coroutineScope) {
|
||||
override val dialogType: String get() = "CustomDialog"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLoadingDialog(): LoadingDialogHandle {
|
||||
val visible = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (visible.value) {
|
||||
LoadingDialog()
|
||||
}
|
||||
|
||||
return remember {
|
||||
LoadingDialogHandleImpl(visible, coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberConfirmDialog(visuals: ConfirmDialogVisuals, callback: ConfirmCallback): ConfirmDialogHandle {
|
||||
val visible = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val resultChannel = remember {
|
||||
Channel<ConfirmResult>()
|
||||
}
|
||||
|
||||
val handle = rememberSaveable(
|
||||
saver = ConfirmDialogHandleImpl.Saver(visible, coroutineScope, callback, resultChannel),
|
||||
init = {
|
||||
ConfirmDialogHandleImpl(visible, coroutineScope, callback, visuals, resultChannel)
|
||||
}
|
||||
)
|
||||
|
||||
if (visible.value) {
|
||||
ConfirmDialog(
|
||||
handle.visuals,
|
||||
confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
|
||||
dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } }
|
||||
)
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberConfirmCallback(onConfirm: NullableCallback, onDismiss: NullableCallback): ConfirmCallback {
|
||||
val currentOnConfirm by rememberUpdatedState(newValue = onConfirm)
|
||||
val currentOnDismiss by rememberUpdatedState(newValue = onDismiss)
|
||||
return remember {
|
||||
ConfirmCallback({ currentOnConfirm }, { currentOnDismiss })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberConfirmDialog(onConfirm: NullableCallback = null, onDismiss: NullableCallback = null): ConfirmDialogHandle {
|
||||
return rememberConfirmDialog(rememberConfirmCallback(onConfirm, onDismiss))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle {
|
||||
return rememberConfirmDialog(ConfirmDialogVisualsImpl.Empty, callback)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCustomDialog(composable: @Composable (dismiss: () -> Unit) -> Unit): DialogHandle {
|
||||
val visible = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
if (visible.value) {
|
||||
composable { visible.value = false }
|
||||
}
|
||||
return remember {
|
||||
CustomDialogHandleImpl(visible, coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingDialog() {
|
||||
Dialog(
|
||||
onDismissRequest = {},
|
||||
properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmDialog(visuals: ConfirmDialogVisuals, confirm: () -> Unit, dismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
dismiss()
|
||||
},
|
||||
title = {
|
||||
Text(text = visuals.title)
|
||||
},
|
||||
text = {
|
||||
if (visuals.isMarkdown) {
|
||||
MarkdownContent(content = visuals.content)
|
||||
} else {
|
||||
Text(text = visuals.content)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = confirm) {
|
||||
Text(text = visuals.confirm ?: stringResource(id = android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = dismiss) {
|
||||
Text(text = visuals.dismiss ?: stringResource(id = android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkdownContent(content: String) {
|
||||
val contentColor = LocalContentColor.current
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
|
||||
}
|
||||
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
update = {
|
||||
Markwon.create(it.context).setMarkdown(it, content)
|
||||
it.setTextColor(contentColor.toArgb())
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.rifsxd.ksunext.ui.component
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
|
||||
@Composable
|
||||
fun KeyEventBlocker(predicate: (KeyEvent) -> Boolean) {
|
||||
val requester = remember { FocusRequester() }
|
||||
Box(
|
||||
Modifier
|
||||
.onKeyEvent {
|
||||
predicate(it)
|
||||
}
|
||||
.focusRequester(requester)
|
||||
.focusable()
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
requester.requestFocus()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.rifsxd.ksunext.ui.component
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val TAG = "SearchBar"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
searchText: String,
|
||||
onSearchTextChange: (String) -> Unit,
|
||||
onClearClick: () -> Unit,
|
||||
onBackClick: (() -> Unit)? = null,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dropdownContent: @Composable (() -> Unit)? = null,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var onSearch by remember { mutableStateOf(false) }
|
||||
|
||||
if (onSearch) {
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
Box {
|
||||
AnimatedVisibility(
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
visible = !onSearch,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
content = { title() }
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = onSearch,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp, bottom = 2.dp, end = if (onBackClick != null) 0.dp else 14.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) onSearch = true
|
||||
Log.d(TAG, "onFocusChanged: $focusState")
|
||||
},
|
||||
value = searchText,
|
||||
onValueChange = onSearchTextChange,
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSearch = false
|
||||
keyboardController?.hide()
|
||||
onClearClick()
|
||||
},
|
||||
content = { Icon(Icons.Filled.Close, null) }
|
||||
)
|
||||
},
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
onConfirm?.invoke()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (onBackClick != null) {
|
||||
IconButton(
|
||||
onClick = onBackClick,
|
||||
content = { Icon(Icons.AutoMirrored.Outlined.ArrowBack, null) }
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
AnimatedVisibility(
|
||||
visible = !onSearch
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onSearch = true },
|
||||
content = { Icon(Icons.Filled.Search, null) }
|
||||
)
|
||||
}
|
||||
|
||||
if (dropdownContent != null) {
|
||||
dropdownContent()
|
||||
}
|
||||
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SearchAppBarPreview() {
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
SearchAppBar(
|
||||
title = { Text("Search text") },
|
||||
searchText = searchText,
|
||||
onSearchTextChange = { searchText = it },
|
||||
onClearClick = { searchText = "" }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.rifsxd.ksunext.ui.component
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
|
||||
@Composable
|
||||
fun SwitchItem(
|
||||
icon: ImageVector? = null,
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.toggleable(
|
||||
value = checked,
|
||||
interactionSource = interactionSource,
|
||||
role = Role.Switch,
|
||||
enabled = enabled,
|
||||
indication = LocalIndication.current,
|
||||
onValueChange = onCheckedChange
|
||||
),
|
||||
headlineContent = {
|
||||
Text(title)
|
||||
},
|
||||
leadingContent = icon?.let {
|
||||
{ Icon(icon, title) }
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onCheckedChange,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
if (summary != null) {
|
||||
Text(summary)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadioItem(
|
||||
title: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(title)
|
||||
},
|
||||
leadingContent = {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.rifsxd.ksunext.ui.component.profile
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.SwitchItem
|
||||
|
||||
@Composable
|
||||
fun AppProfileConfig(
|
||||
modifier: Modifier = Modifier,
|
||||
fixedName: Boolean,
|
||||
enabled: Boolean,
|
||||
profile: Natives.Profile,
|
||||
onProfileChange: (Natives.Profile) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (!fixedName) {
|
||||
OutlinedTextField(
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
value = profile.name,
|
||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
SwitchItem(
|
||||
title = stringResource(R.string.profile_umount_modules),
|
||||
summary = stringResource(R.string.profile_umount_modules_summary),
|
||||
checked = if (enabled) {
|
||||
profile.umountModules
|
||||
} else {
|
||||
Natives.isDefaultUmountModules()
|
||||
},
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
umountModules = it,
|
||||
nonRootUseDefault = false
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AppProfileConfigPreview() {
|
||||
var profile by remember { mutableStateOf(Natives.Profile("")) }
|
||||
AppProfileConfig(fixedName = true, enabled = false, profile = profile) {
|
||||
profile = it
|
||||
}
|
||||
}
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
package com.rifsxd.ksunext.ui.component.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.input.InputDialog
|
||||
import com.maxkeppeler.sheets.input.models.InputHeader
|
||||
import com.maxkeppeler.sheets.input.models.InputSelection
|
||||
import com.maxkeppeler.sheets.input.models.InputTextField
|
||||
import com.maxkeppeler.sheets.input.models.InputTextFieldType
|
||||
import com.maxkeppeler.sheets.input.models.ValidationResult
|
||||
import com.maxkeppeler.sheets.list.ListDialog
|
||||
import com.maxkeppeler.sheets.list.models.ListOption
|
||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.profile.Capabilities
|
||||
import com.rifsxd.ksunext.profile.Groups
|
||||
import com.rifsxd.ksunext.ui.component.rememberCustomDialog
|
||||
import com.rifsxd.ksunext.ui.util.isSepolicyValid
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RootProfileConfig(
|
||||
modifier: Modifier = Modifier,
|
||||
fixedName: Boolean,
|
||||
profile: Natives.Profile,
|
||||
onProfileChange: (Natives.Profile) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (!fixedName) {
|
||||
OutlinedTextField(
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
value = profile.name,
|
||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val currentNamespace = when (profile.namespace) {
|
||||
Natives.Profile.Namespace.INHERITED.ordinal -> stringResource(R.string.profile_namespace_inherited)
|
||||
Natives.Profile.Namespace.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global)
|
||||
Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual)
|
||||
else -> stringResource(R.string.profile_namespace_inherited)
|
||||
}
|
||||
ListItem(headlineContent = {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.profile_namespace)) },
|
||||
value = currentNamespace,
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
||||
},
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_inherited)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_global)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_individual)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
UidPanel(uid = profile.uid, label = "uid", onUidChange = {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
uid = it,
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
UidPanel(uid = profile.gid, label = "gid", onUidChange = {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
gid = it,
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e ->
|
||||
e.mapNotNull { g ->
|
||||
Groups.entries.find { it.gid == g }
|
||||
}
|
||||
}
|
||||
GroupsPanel(selectedGroups) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
groups = it.map { group -> group.gid }.ifEmpty { listOf(0) },
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val selectedCaps = profile.capabilities.mapNotNull { e ->
|
||||
Capabilities.entries.find { it.cap == e }
|
||||
}
|
||||
|
||||
CapsPanel(selectedCaps) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
capabilities = it.map { cap -> cap.cap },
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SELinuxPanel(profile = profile, onSELinuxChange = { domain, rules ->
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
context = domain,
|
||||
rules = rules,
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) {
|
||||
val selectGroupsDialog = rememberCustomDialog { dismiss: () -> Unit ->
|
||||
val groups = Groups.entries.toTypedArray().sortedWith(
|
||||
compareBy<Groups> { if (selected.contains(it)) 0 else 1 }
|
||||
.then(compareBy {
|
||||
when (it) {
|
||||
Groups.ROOT -> 0
|
||||
Groups.SYSTEM -> 1
|
||||
Groups.SHELL -> 2
|
||||
else -> Int.MAX_VALUE
|
||||
}
|
||||
})
|
||||
.then(compareBy { it.name })
|
||||
|
||||
)
|
||||
val options = groups.map { value ->
|
||||
ListOption(
|
||||
titleText = value.display,
|
||||
subtitleText = value.desc,
|
||||
selected = selected.contains(value),
|
||||
)
|
||||
}
|
||||
|
||||
val selection = HashSet(selected)
|
||||
ListDialog(
|
||||
state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
||||
closeSelection(selection)
|
||||
}, onCloseRequest = {
|
||||
dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.profile_groups),
|
||||
),
|
||||
selection = ListSelection.Multiple(
|
||||
showCheckBoxes = true,
|
||||
options = options,
|
||||
maxChoices = 32, // Kernel only supports 32 groups at most
|
||||
) { indecies, _ ->
|
||||
// Handle selection
|
||||
selection.clear()
|
||||
indecies.forEach { index ->
|
||||
val group = groups[index]
|
||||
selection.add(group)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
selectGroupsDialog.show()
|
||||
}
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.profile_groups))
|
||||
FlowRow {
|
||||
selected.forEach { group ->
|
||||
AssistChip(
|
||||
modifier = Modifier.padding(3.dp),
|
||||
onClick = { /*TODO*/ },
|
||||
label = { Text(group.display) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CapsPanel(
|
||||
selected: Collection<Capabilities>,
|
||||
closeSelection: (selection: Set<Capabilities>) -> Unit
|
||||
) {
|
||||
val selectCapabilitiesDialog = rememberCustomDialog { dismiss ->
|
||||
val caps = Capabilities.entries.toTypedArray().sortedWith(
|
||||
compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 }
|
||||
.then(compareBy { it.name })
|
||||
)
|
||||
val options = caps.map { value ->
|
||||
ListOption(
|
||||
titleText = value.display,
|
||||
subtitleText = value.desc,
|
||||
selected = selected.contains(value),
|
||||
)
|
||||
}
|
||||
|
||||
val selection = HashSet(selected)
|
||||
ListDialog(
|
||||
state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
||||
closeSelection(selection)
|
||||
}, onCloseRequest = {
|
||||
dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.profile_capabilities),
|
||||
),
|
||||
selection = ListSelection.Multiple(
|
||||
showCheckBoxes = true,
|
||||
options = options
|
||||
) { indecies, _ ->
|
||||
// Handle selection
|
||||
selection.clear()
|
||||
indecies.forEach { index ->
|
||||
val group = caps[index]
|
||||
selection.add(group)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
selectCapabilitiesDialog.show()
|
||||
}
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.profile_capabilities))
|
||||
FlowRow {
|
||||
selected.forEach { group ->
|
||||
AssistChip(
|
||||
modifier = Modifier.padding(3.dp),
|
||||
onClick = { /*TODO*/ },
|
||||
label = { Text(group.display) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UidPanel(uid: Int, label: String, onUidChange: (Int) -> Unit) {
|
||||
|
||||
ListItem(headlineContent = {
|
||||
var isError by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var lastValidUid by remember {
|
||||
mutableIntStateOf(uid)
|
||||
}
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
value = uid.toString(),
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
}),
|
||||
onValueChange = {
|
||||
if (it.isEmpty()) {
|
||||
onUidChange(0)
|
||||
return@OutlinedTextField
|
||||
}
|
||||
val valid = isTextValidUid(it)
|
||||
|
||||
val targetUid = if (valid) it.toInt() else lastValidUid
|
||||
if (valid) {
|
||||
lastValidUid = it.toInt()
|
||||
}
|
||||
|
||||
onUidChange(targetUid)
|
||||
|
||||
isError = !valid
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SELinuxPanel(
|
||||
profile: Natives.Profile,
|
||||
onSELinuxChange: (domain: String, rules: String) -> Unit
|
||||
) {
|
||||
val editSELinuxDialog = rememberCustomDialog { dismiss ->
|
||||
var domain by remember { mutableStateOf(profile.context) }
|
||||
var rules by remember { mutableStateOf(profile.rules) }
|
||||
|
||||
val inputOptions = listOf(
|
||||
InputTextField(
|
||||
text = domain,
|
||||
header = InputHeader(
|
||||
title = stringResource(id = R.string.profile_selinux_domain),
|
||||
),
|
||||
type = InputTextFieldType.OUTLINED,
|
||||
required = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
resultListener = {
|
||||
domain = it ?: ""
|
||||
},
|
||||
validationListener = { value ->
|
||||
// value can be a-zA-Z0-9_
|
||||
val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$")
|
||||
if (value?.matches(regex) == true) ValidationResult.Valid
|
||||
else ValidationResult.Invalid("Domain must be in the format of \"user:role:type:level\"")
|
||||
}
|
||||
),
|
||||
InputTextField(
|
||||
text = rules,
|
||||
header = InputHeader(
|
||||
title = stringResource(id = R.string.profile_selinux_rules),
|
||||
),
|
||||
type = InputTextFieldType.OUTLINED,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
singleLine = false,
|
||||
resultListener = {
|
||||
rules = it ?: ""
|
||||
},
|
||||
validationListener = { value ->
|
||||
if (isSepolicyValid(value)) ValidationResult.Valid
|
||||
else ValidationResult.Invalid("SELinux rules is invalid!")
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
InputDialog(
|
||||
state = rememberUseCaseState(visible = true,
|
||||
onFinishedRequest = {
|
||||
onSELinuxChange(domain, rules)
|
||||
},
|
||||
onCloseRequest = {
|
||||
dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.profile_selinux_context),
|
||||
),
|
||||
selection = InputSelection(
|
||||
input = inputOptions,
|
||||
onPositiveClick = { result ->
|
||||
// Handle selection
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(headlineContent = {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
editSELinuxDialog.show()
|
||||
},
|
||||
enabled = false,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledBorderColor = MaterialTheme.colorScheme.outline,
|
||||
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
label = { Text(text = stringResource(R.string.profile_selinux_context)) },
|
||||
value = profile.context,
|
||||
onValueChange = { }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RootProfileConfigPreview() {
|
||||
var profile by remember { mutableStateOf(Natives.Profile("")) }
|
||||
RootProfileConfig(fixedName = true, profile = profile) {
|
||||
profile = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTextValidUid(text: String): Boolean {
|
||||
return text.isNotEmpty() && text.isDigitsOnly() && text.toInt() >= 0 && text.toInt() <= Int.MAX_VALUE
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.rifsxd.ksunext.ui.component.profile
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ReadMore
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.util.listAppProfileTemplates
|
||||
import com.rifsxd.ksunext.ui.util.setSepolicy
|
||||
import com.rifsxd.ksunext.ui.viewmodel.getTemplateInfoById
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/10/21.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TemplateConfig(
|
||||
profile: Natives.Profile,
|
||||
onViewTemplate: (id: String) -> Unit = {},
|
||||
onManageTemplate: () -> Unit = {},
|
||||
onProfileChange: (Natives.Profile) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var template by rememberSaveable {
|
||||
mutableStateOf(profile.rootTemplate ?: "")
|
||||
}
|
||||
val profileTemplates = listAppProfileTemplates()
|
||||
val noTemplates = profileTemplates.isEmpty()
|
||||
|
||||
ListItem(headlineContent = {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.profile_template)) },
|
||||
value = template.ifEmpty { "None" },
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
if (noTemplates) {
|
||||
IconButton(
|
||||
onClick = onManageTemplate
|
||||
) {
|
||||
Icon(Icons.Filled.Create, null)
|
||||
}
|
||||
} else if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
||||
},
|
||||
)
|
||||
if (profileTemplates.isEmpty()) {
|
||||
return@ExposedDropdownMenuBox
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
profileTemplates.forEach { tid ->
|
||||
val templateInfo =
|
||||
getTemplateInfoById(tid) ?: return@forEach
|
||||
DropdownMenuItem(
|
||||
text = { Text(tid) },
|
||||
onClick = {
|
||||
template = tid
|
||||
if (setSepolicy(tid, templateInfo.rules.joinToString("\n"))) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
rootTemplate = tid,
|
||||
rootUseDefault = false,
|
||||
uid = templateInfo.uid,
|
||||
gid = templateInfo.gid,
|
||||
groups = templateInfo.groups,
|
||||
capabilities = templateInfo.capabilities,
|
||||
context = templateInfo.context,
|
||||
namespace = templateInfo.namespace,
|
||||
)
|
||||
)
|
||||
}
|
||||
expanded = false
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
onViewTemplate(tid)
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ReadMore, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Android
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlinx.coroutines.launch
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.SwitchItem
|
||||
import com.rifsxd.ksunext.ui.component.profile.AppProfileConfig
|
||||
import com.rifsxd.ksunext.ui.component.profile.RootProfileConfig
|
||||
import com.rifsxd.ksunext.ui.component.profile.TemplateConfig
|
||||
import com.rifsxd.ksunext.ui.util.LocalSnackbarHost
|
||||
import com.rifsxd.ksunext.ui.util.forceStopApp
|
||||
import com.rifsxd.ksunext.ui.util.getSepolicy
|
||||
import com.rifsxd.ksunext.ui.util.launchApp
|
||||
import com.rifsxd.ksunext.ui.util.restartApp
|
||||
import com.rifsxd.ksunext.ui.util.setSepolicy
|
||||
import com.rifsxd.ksunext.ui.viewmodel.SuperUserViewModel
|
||||
import com.rifsxd.ksunext.ui.viewmodel.getTemplateInfoById
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/5/16.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun AppProfileScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
appInfo: SuperUserViewModel.AppInfo,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val scope = rememberCoroutineScope()
|
||||
val failToUpdateAppProfile = stringResource(R.string.failed_to_update_app_profile).format(appInfo.label)
|
||||
val failToUpdateSepolicy = stringResource(R.string.failed_to_update_sepolicy).format(appInfo.label)
|
||||
|
||||
val packageName = appInfo.packageName
|
||||
val initialProfile = Natives.getAppProfile(packageName, appInfo.uid)
|
||||
if (initialProfile.allowSu) {
|
||||
initialProfile.rules = getSepolicy(packageName)
|
||||
}
|
||||
var profile by rememberSaveable {
|
||||
mutableStateOf(initialProfile)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
onBack = { navigator.popBackStack() },
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { paddingValues ->
|
||||
AppProfileInner(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
packageName = appInfo.packageName,
|
||||
appLabel = appInfo.label,
|
||||
appIcon = {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context).data(appInfo.packageInfo).crossfade(true).build(),
|
||||
contentDescription = appInfo.label,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
)
|
||||
},
|
||||
profile = profile,
|
||||
onViewTemplate = {
|
||||
getTemplateInfoById(it)?.let { info ->
|
||||
navigator.navigate(TemplateEditorScreenDestination(info))
|
||||
}
|
||||
},
|
||||
onManageTemplate = {
|
||||
navigator.navigate(AppProfileTemplateScreenDestination())
|
||||
},
|
||||
onProfileChange = {
|
||||
scope.launch {
|
||||
if (it.allowSu && !it.rootUseDefault && it.rules.isNotEmpty()) {
|
||||
if (!setSepolicy(profile.name, it.rules)) {
|
||||
snackBarHost.showSnackbar(failToUpdateSepolicy)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
if (!Natives.setAppProfile(it)) {
|
||||
snackBarHost.showSnackbar(failToUpdateAppProfile.format(appInfo.uid))
|
||||
} else {
|
||||
profile = it
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppProfileInner(
|
||||
modifier: Modifier = Modifier,
|
||||
packageName: String,
|
||||
appLabel: String,
|
||||
appIcon: @Composable () -> Unit,
|
||||
profile: Natives.Profile,
|
||||
onViewTemplate: (id: String) -> Unit = {},
|
||||
onManageTemplate: () -> Unit = {},
|
||||
onProfileChange: (Natives.Profile) -> Unit,
|
||||
) {
|
||||
val isRootGranted = profile.allowSu
|
||||
|
||||
Column(modifier = modifier) {
|
||||
AppMenuBox(packageName) {
|
||||
ListItem(
|
||||
headlineContent = { Text(appLabel) },
|
||||
supportingContent = { Text(packageName) },
|
||||
leadingContent = appIcon,
|
||||
)
|
||||
}
|
||||
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(id = R.string.superuser),
|
||||
checked = isRootGranted,
|
||||
onCheckedChange = { onProfileChange(profile.copy(allowSu = it)) },
|
||||
)
|
||||
|
||||
Crossfade(targetState = isRootGranted, label = "") { current ->
|
||||
Column(
|
||||
modifier = Modifier.padding(bottom = 6.dp + 48.dp + 6.dp /* SnackBar height */)
|
||||
) {
|
||||
if (current) {
|
||||
val initialMode = if (profile.rootUseDefault) {
|
||||
Mode.Default
|
||||
} else if (profile.rootTemplate != null) {
|
||||
Mode.Template
|
||||
} else {
|
||||
Mode.Custom
|
||||
}
|
||||
var mode by rememberSaveable {
|
||||
mutableStateOf(initialMode)
|
||||
}
|
||||
ProfileBox(mode, true) {
|
||||
// template mode shouldn't change profile here!
|
||||
if (it == Mode.Default || it == Mode.Custom) {
|
||||
onProfileChange(profile.copy(rootUseDefault = it == Mode.Default))
|
||||
}
|
||||
mode = it
|
||||
}
|
||||
Crossfade(targetState = mode, label = "") { currentMode ->
|
||||
if (currentMode == Mode.Template) {
|
||||
TemplateConfig(
|
||||
profile = profile,
|
||||
onViewTemplate = onViewTemplate,
|
||||
onManageTemplate = onManageTemplate,
|
||||
onProfileChange = onProfileChange
|
||||
)
|
||||
} else if (mode == Mode.Custom) {
|
||||
RootProfileConfig(
|
||||
fixedName = true,
|
||||
profile = profile,
|
||||
onProfileChange = onProfileChange
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val mode = if (profile.nonRootUseDefault) Mode.Default else Mode.Custom
|
||||
ProfileBox(mode, false) {
|
||||
onProfileChange(profile.copy(nonRootUseDefault = (it == Mode.Default)))
|
||||
}
|
||||
Crossfade(targetState = mode, label = "") { currentMode ->
|
||||
val modifyEnabled = currentMode == Mode.Custom
|
||||
AppProfileConfig(
|
||||
fixedName = true,
|
||||
profile = profile,
|
||||
enabled = modifyEnabled,
|
||||
onProfileChange = onProfileChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Mode(@StringRes private val res: Int) {
|
||||
Default(R.string.profile_default), Template(R.string.profile_template), Custom(R.string.profile_custom);
|
||||
|
||||
val text: String
|
||||
@Composable get() = stringResource(res)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
onBack: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(stringResource(R.string.profile))
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileBox(
|
||||
mode: Mode,
|
||||
hasTemplate: Boolean,
|
||||
onModeChange: (Mode) -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.profile)) },
|
||||
supportingContent = { Text(mode.text) },
|
||||
leadingContent = { Icon(Icons.Filled.AccountCircle, null) },
|
||||
)
|
||||
HorizontalDivider(thickness = Dp.Hairline)
|
||||
ListItem(headlineContent = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
FilterChip(
|
||||
selected = mode == Mode.Default,
|
||||
label = { Text(stringResource(R.string.profile_default)) },
|
||||
onClick = { onModeChange(Mode.Default) },
|
||||
)
|
||||
if (hasTemplate) {
|
||||
FilterChip(
|
||||
selected = mode == Mode.Template,
|
||||
label = { Text(stringResource(R.string.profile_template)) },
|
||||
onClick = { onModeChange(Mode.Template) },
|
||||
)
|
||||
}
|
||||
FilterChip(
|
||||
selected = mode == Mode.Custom,
|
||||
label = { Text(stringResource(R.string.profile_custom)) },
|
||||
onClick = { onModeChange(Mode.Custom) },
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppMenuBox(packageName: String, content: @Composable () -> Unit) {
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var touchPoint: Offset by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
touchPoint = it
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
content()
|
||||
|
||||
val (offsetX, offsetY) = with(density) {
|
||||
(touchPoint.x.toDp()) to (touchPoint.y.toDp())
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
offset = DpOffset(offsetX, -offsetY),
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
},
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = R.string.launch_app)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
launchApp(packageName)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = R.string.force_stop_app)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
forceStopApp(packageName)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = R.string.restart_app)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
restartApp(packageName)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AppProfilePreview() {
|
||||
var profile by remember { mutableStateOf(Natives.Profile("")) }
|
||||
AppProfileInner(
|
||||
packageName = "icu.nullptr.test",
|
||||
appLabel = "Test",
|
||||
appIcon = { Icon(Icons.Filled.Android, null) },
|
||||
profile = profile,
|
||||
onProfileChange = {
|
||||
profile = it
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.ramcosta.composedestinations.generated.destinations.HomeScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.ModuleScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.SuperUserScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.SettingScreenDestination
|
||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
||||
import com.rifsxd.ksunext.R
|
||||
|
||||
enum class BottomBarDestination(
|
||||
val direction: DirectionDestinationSpec,
|
||||
@StringRes val label: Int,
|
||||
val iconSelected: ImageVector,
|
||||
val iconNotSelected: ImageVector,
|
||||
val rootRequired: Boolean,
|
||||
) {
|
||||
Home(HomeScreenDestination, R.string.home, Icons.Filled.Home, Icons.Outlined.Home, false),
|
||||
SuperUser(SuperUserScreenDestination, R.string.superuser, Icons.Filled.AdminPanelSettings, Icons.Outlined.AdminPanelSettings, true),
|
||||
Module(ModuleScreenDestination, R.string.module, Icons.Filled.Layers, Icons.Outlined.Layers, true),
|
||||
Settings(SettingScreenDestination, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings, false)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.KeyEventBlocker
|
||||
import com.rifsxd.ksunext.ui.util.LocalSnackbarHost
|
||||
import com.rifsxd.ksunext.ui.util.runModuleAction
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
val logContent = rememberSaveable { StringBuilder() }
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
var actionResult: Boolean
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (text.isNotEmpty()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
runModuleAction(
|
||||
moduleId = moduleId,
|
||||
onStdout = {
|
||||
text += "$it\n"
|
||||
logContent.append(it).append("\n")
|
||||
},
|
||||
onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
}
|
||||
).let {
|
||||
actionResult = it
|
||||
}
|
||||
}
|
||||
if (actionResult) navigator.popBackStack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
onBack = {
|
||||
navigator.popBackStack()
|
||||
},
|
||||
onSave = {
|
||||
scope.launch {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
val date = format.format(Date())
|
||||
val file = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"KernelSU_module_action_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
||||
) { innerPadding ->
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
LaunchedEffect(text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = text,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(onBack: () -> Unit = {}, onSave: () -> Unit = {}) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.action)) },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSave) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
contentDescription = stringResource(id = R.string.save_log),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.KeyEventBlocker
|
||||
import com.rifsxd.ksunext.ui.util.LkmSelection
|
||||
import com.rifsxd.ksunext.ui.util.LocalSnackbarHost
|
||||
import com.rifsxd.ksunext.ui.util.flashModule
|
||||
import com.rifsxd.ksunext.ui.util.installBoot
|
||||
import com.rifsxd.ksunext.ui.util.reboot
|
||||
import com.rifsxd.ksunext.ui.util.restoreBoot
|
||||
import com.rifsxd.ksunext.ui.util.uninstallPermanently
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
enum class FlashingStatus {
|
||||
FLASHING,
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/1/1.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
val logContent = rememberSaveable { StringBuilder() }
|
||||
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
var flashing by rememberSaveable {
|
||||
mutableStateOf(FlashingStatus.FLASHING)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (text.isNotEmpty()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
flashIt(flashIt, onFinish = { showReboot, code ->
|
||||
if (code != 0) {
|
||||
text += "Error: exit code = $code.\nPlease save and check the log.\n"
|
||||
}
|
||||
if (showReboot) {
|
||||
text += "\n\n\n"
|
||||
showFloatAction = true
|
||||
}
|
||||
flashing = if (code == 0) FlashingStatus.SUCCESS else FlashingStatus.FAILED
|
||||
}, onStdout = {
|
||||
text += "$it\n"
|
||||
logContent.append(it).append("\n")
|
||||
}, onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
flashing,
|
||||
onBack = {
|
||||
navigator.popBackStack()
|
||||
},
|
||||
onSave = {
|
||||
scope.launch {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
val date = format.format(Date())
|
||||
val file = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"KernelSU_install_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (showFloatAction) {
|
||||
val reboot = stringResource(id = R.string.reboot)
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
reboot()
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = { Icon(Icons.Filled.Refresh, reboot) },
|
||||
text = { Text(text = reboot) },
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
LaunchedEffect(text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = text,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed class FlashIt : Parcelable {
|
||||
data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean) :
|
||||
FlashIt()
|
||||
|
||||
data class FlashModule(val uri: Uri) : FlashIt()
|
||||
|
||||
data object FlashRestore : FlashIt()
|
||||
|
||||
data object FlashUninstall : FlashIt()
|
||||
}
|
||||
|
||||
fun flashIt(
|
||||
flashIt: FlashIt, onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
) {
|
||||
when (flashIt) {
|
||||
is FlashIt.FlashBoot -> installBoot(
|
||||
flashIt.boot,
|
||||
flashIt.lkm,
|
||||
flashIt.ota,
|
||||
onFinish,
|
||||
onStdout,
|
||||
onStderr
|
||||
)
|
||||
|
||||
is FlashIt.FlashModule -> flashModule(flashIt.uri, onFinish, onStdout, onStderr)
|
||||
|
||||
FlashIt.FlashRestore -> restoreBoot(onFinish, onStdout, onStderr)
|
||||
|
||||
FlashIt.FlashUninstall -> uninstallPermanently(onFinish, onStdout, onStderr)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
status: FlashingStatus,
|
||||
onBack: () -> Unit = {},
|
||||
onSave: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
when (status) {
|
||||
FlashingStatus.FLASHING -> R.string.flashing
|
||||
FlashingStatus.SUCCESS -> R.string.flash_success
|
||||
FlashingStatus.FAILED -> R.string.flash_failed
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSave) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
contentDescription = "Localized description"
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun InstallPreview() {
|
||||
InstallScreen(EmptyDestinationsNavigator)
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.system.Os
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.*
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.InstallScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.rifsxd.ksunext.*
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.rememberConfirmDialog
|
||||
import com.rifsxd.ksunext.ui.util.*
|
||||
import com.rifsxd.ksunext.ui.util.module.LatestVersionInfo
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>(start = true)
|
||||
@Composable
|
||||
fun HomeScreen(navigator: DestinationsNavigator) {
|
||||
val kernelVersion = getKernelVersion()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
kernelVersion,
|
||||
onInstallClick = {
|
||||
navigator.navigate(InstallScreenDestination)
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
val isManager = Natives.becomeManager(ksuApp.packageName)
|
||||
SideEffect {
|
||||
if (isManager) {
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
install()
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
val ksuVersion = if (isManager) Natives.version else null
|
||||
val lkmMode = ksuVersion?.let {
|
||||
if (it >= Natives.MINIMAL_SUPPORTED_KERNEL_LKM && kernelVersion.isGKI()) Natives.isLkmMode else null
|
||||
}
|
||||
|
||||
StatusCard(kernelVersion, ksuVersion, lkmMode) {
|
||||
navigator.navigate(InstallScreenDestination)
|
||||
}
|
||||
if (isManager && Natives.requireNewKernel()) {
|
||||
WarningCard(
|
||||
stringResource(id = R.string.require_kernel_version).format(
|
||||
ksuVersion, Natives.MINIMAL_SUPPORTED_KERNEL
|
||||
)
|
||||
)
|
||||
}
|
||||
if (ksuVersion != null && !rootAvailable()) {
|
||||
WarningCard(
|
||||
stringResource(id = R.string.grant_root_failed)
|
||||
)
|
||||
}
|
||||
val checkUpdate =
|
||||
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
.getBoolean("check_update", true)
|
||||
if (checkUpdate) {
|
||||
UpdateCard()
|
||||
}
|
||||
NextCard()
|
||||
InfoCard()
|
||||
EXperimentalCard()
|
||||
Spacer(Modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateCard() {
|
||||
val context = LocalContext.current
|
||||
val latestVersionInfo = LatestVersionInfo()
|
||||
val newVersion by produceState(initialValue = latestVersionInfo) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
checkNewVersion()
|
||||
}
|
||||
}
|
||||
|
||||
val currentVersionCode = getManagerVersion(context).second
|
||||
val newVersionCode = newVersion.versionCode
|
||||
val newVersionUrl = newVersion.downloadUrl
|
||||
val changelog = newVersion.changelog
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val title = stringResource(id = R.string.module_changelog)
|
||||
val updateText = stringResource(id = R.string.module_update)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = newVersionCode > currentVersionCode,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
val updateDialog = rememberConfirmDialog(onConfirm = { uriHandler.openUri(newVersionUrl) })
|
||||
WarningCard(
|
||||
message = stringResource(id = R.string.new_version_available).format(newVersionCode),
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
) {
|
||||
if (changelog.isEmpty()) {
|
||||
uriHandler.openUri(newVersionUrl)
|
||||
} else {
|
||||
updateDialog.showConfirm(
|
||||
title = title,
|
||||
content = changelog,
|
||||
markdown = true,
|
||||
confirm = updateText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RebootDropdownItem(@StringRes id: Int, reason: String = "") {
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id))
|
||||
}, onClick = {
|
||||
reboot(reason)
|
||||
})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
kernelVersion: KernelVersion,
|
||||
onInstallClick: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
actions = {
|
||||
if (kernelVersion.isGKI()) {
|
||||
IconButton(onClick = onInstallClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Archive,
|
||||
contentDescription = stringResource(id = R.string.install)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = {
|
||||
showDropdown = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(id = R.string.reboot)
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
||||
showDropdown = false
|
||||
}) {
|
||||
|
||||
RebootDropdownItem(id = R.string.reboot)
|
||||
|
||||
val pm = LocalContext.current.getSystemService(Context.POWER_SERVICE) as PowerManager?
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && pm?.isRebootingUserspaceSupported == true) {
|
||||
RebootDropdownItem(id = R.string.reboot_userspace, reason = "userspace")
|
||||
}
|
||||
RebootDropdownItem(id = R.string.reboot_recovery, reason = "recovery")
|
||||
RebootDropdownItem(id = R.string.reboot_bootloader, reason = "bootloader")
|
||||
RebootDropdownItem(id = R.string.reboot_download, reason = "download")
|
||||
RebootDropdownItem(id = R.string.reboot_edl, reason = "edl")
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusCard(
|
||||
kernelVersion: KernelVersion,
|
||||
ksuVersion: Int?,
|
||||
lkmMode: Boolean?,
|
||||
onClickInstall: () -> Unit = {}
|
||||
) {
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = run {
|
||||
if (ksuVersion != null) MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.errorContainer
|
||||
})
|
||||
) {
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (kernelVersion.isGKI()) {
|
||||
onClickInstall()
|
||||
}
|
||||
}
|
||||
.padding(24.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
when {
|
||||
ksuVersion != null -> {
|
||||
val safeMode = when {
|
||||
Natives.isSafeMode -> " [${stringResource(id = R.string.safe_mode)}]"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val workingMode = when (lkmMode) {
|
||||
null -> " <LTS>"
|
||||
true -> " <LKM>"
|
||||
else -> " <GKI>"
|
||||
}
|
||||
|
||||
val workingText =
|
||||
"${stringResource(id = R.string.home_working)}$workingMode$safeMode"
|
||||
|
||||
Icon(Icons.Filled.LocalFireDepartment, stringResource(R.string.home_working))
|
||||
Column(Modifier.padding(start = 20.dp)) {
|
||||
Text(
|
||||
text = workingText,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_working_version, ksuVersion),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.home_superuser_count, getSuperuserCount()
|
||||
), style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_module_count, getModuleCount()),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
kernelVersion.isGKI() -> {
|
||||
Icon(Icons.Filled.AutoFixHigh, stringResource(R.string.home_not_installed))
|
||||
Column(Modifier.padding(start = 20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.home_not_installed),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_click_to_install),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Icon(Icons.Filled.Dangerous, stringResource(R.string.home_failure))
|
||||
Column(Modifier.padding(start = 20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.home_failure),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_failure_reason),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WarningCard(
|
||||
message: String, color: Color = MaterialTheme.colorScheme.error, onClick: (() -> Unit)? = null
|
||||
) {
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = color
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(onClick?.let { Modifier.clickable { it() } } ?: Modifier)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = message, style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard() {
|
||||
val context = LocalContext.current
|
||||
|
||||
ElevatedCard {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 16.dp)
|
||||
) {
|
||||
val contents = StringBuilder()
|
||||
val uname = Os.uname()
|
||||
|
||||
@Composable
|
||||
fun InfoCardItem(label: String, content: String, icon: ImageVector? = null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
InfoCardItem(stringResource(R.string.home_kernel),
|
||||
uname.release,
|
||||
icon = Icons.Filled.Memory,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
InfoCardItem(
|
||||
stringResource(R.string.home_android),
|
||||
"${Build.VERSION.RELEASE} (${Build.VERSION.SDK_INT})",
|
||||
icon = Icons.Filled.Android,
|
||||
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
val managerVersion = getManagerVersion(context)
|
||||
InfoCardItem(
|
||||
stringResource(R.string.home_manager_version),
|
||||
"${managerVersion.first}-next (${managerVersion.second})",
|
||||
icon = Icons.AutoMirrored.Filled.Article,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
InfoCardItem(
|
||||
label = stringResource(R.string.home_selinux_status),
|
||||
content = getSELinuxStatus(),
|
||||
icon = Icons.Filled.Security,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NextCard() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val url = stringResource(R.string.home_next_kernelsu_repo)
|
||||
|
||||
ElevatedCard {
|
||||
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
uriHandler.openUri(url)
|
||||
}
|
||||
.padding(24.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.home_next_kernelsu),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_next_kernelsu_body),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EXperimentalCard() {
|
||||
/*val uriHandler = LocalUriHandler.current
|
||||
val url = stringResource(R.string.home_experimental_kernelsu_repo)
|
||||
*/
|
||||
|
||||
ElevatedCard {
|
||||
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
/*.clickable {
|
||||
uriHandler.openUri(url)
|
||||
}
|
||||
*/
|
||||
.padding(24.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.home_experimental_kernelsu),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_experimental_kernelsu_body),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_experimental_kernelsu_body_point_1),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_experimental_kernelsu_body_point_2),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.home_experimental_kernelsu_body_point_3),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getManagerVersion(context: Context): Pair<String, Long> {
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)!!
|
||||
val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||
return Pair(packageInfo.versionName!!, versionCode)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun StatusCardPreview() {
|
||||
Column {
|
||||
StatusCard(KernelVersion(5, 10, 101), 1, null)
|
||||
StatusCard(KernelVersion(5, 10, 101), 20000, true)
|
||||
StatusCard(KernelVersion(5, 10, 101), null, true)
|
||||
StatusCard(KernelVersion(4, 10, 101), null, false)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun WarningCardPreview() {
|
||||
Column {
|
||||
WarningCard(message = "Warning message")
|
||||
WarningCard(
|
||||
message = "Warning message ",
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
onClick = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.list.ListDialog
|
||||
import com.maxkeppeler.sheets.list.models.ListOption
|
||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.DialogHandle
|
||||
import com.rifsxd.ksunext.ui.component.rememberConfirmDialog
|
||||
import com.rifsxd.ksunext.ui.component.rememberCustomDialog
|
||||
import com.rifsxd.ksunext.ui.util.LkmSelection
|
||||
import com.rifsxd.ksunext.ui.util.getCurrentKmi
|
||||
import com.rifsxd.ksunext.ui.util.getSupportedKmis
|
||||
import com.rifsxd.ksunext.ui.util.isAbDevice
|
||||
import com.rifsxd.ksunext.ui.util.isInitBoot
|
||||
import com.rifsxd.ksunext.ui.util.rootAvailable
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2024/3/12.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun InstallScreen(navigator: DestinationsNavigator) {
|
||||
var installMethod by remember {
|
||||
mutableStateOf<InstallMethod?>(null)
|
||||
}
|
||||
|
||||
var lkmSelection by remember {
|
||||
mutableStateOf<LkmSelection>(LkmSelection.KmiNone)
|
||||
}
|
||||
|
||||
val onInstall = {
|
||||
installMethod?.let { method ->
|
||||
val flashIt = FlashIt.FlashBoot(
|
||||
boot = if (method is InstallMethod.SelectFile) method.uri else null,
|
||||
lkm = lkmSelection,
|
||||
ota = method is InstallMethod.DirectInstallToInactiveSlot
|
||||
)
|
||||
navigator.navigate(FlashScreenDestination(flashIt))
|
||||
}
|
||||
}
|
||||
|
||||
val currentKmi by produceState(initialValue = "") { value = getCurrentKmi() }
|
||||
|
||||
val selectKmiDialog = rememberSelectKmiDialog { kmi ->
|
||||
kmi?.let {
|
||||
lkmSelection = LkmSelection.KmiString(it)
|
||||
onInstall()
|
||||
}
|
||||
}
|
||||
|
||||
val onClickNext = {
|
||||
if (lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank()) {
|
||||
// no lkm file selected and cannot get current kmi
|
||||
selectKmiDialog.show()
|
||||
} else {
|
||||
onInstall()
|
||||
}
|
||||
}
|
||||
|
||||
val selectLkmLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
it.data?.data?.let { uri ->
|
||||
lkmSelection = LkmSelection.LkmUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onLkmUpload = {
|
||||
selectLkmLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "application/octet-stream"
|
||||
})
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
onBack = { navigator.popBackStack() },
|
||||
onLkmUpload = onLkmUpload,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SelectInstallMethod { method ->
|
||||
installMethod = method
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
(lkmSelection as? LkmSelection.LkmUri)?.let {
|
||||
Text(
|
||||
stringResource(
|
||||
id = R.string.selected_lkm,
|
||||
it.uri.lastPathSegment ?: "(file)"
|
||||
)
|
||||
)
|
||||
}
|
||||
Button(modifier = Modifier.fillMaxWidth(),
|
||||
enabled = installMethod != null,
|
||||
onClick = {
|
||||
onClickNext()
|
||||
}) {
|
||||
Text(
|
||||
stringResource(id = R.string.install_next),
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class InstallMethod {
|
||||
data class SelectFile(
|
||||
val uri: Uri? = null,
|
||||
@StringRes override val label: Int = R.string.select_file,
|
||||
override val summary: String?
|
||||
) : InstallMethod()
|
||||
|
||||
data object DirectInstall : InstallMethod() {
|
||||
override val label: Int
|
||||
get() = R.string.direct_install
|
||||
}
|
||||
|
||||
data object DirectInstallToInactiveSlot : InstallMethod() {
|
||||
override val label: Int
|
||||
get() = R.string.install_inactive_slot
|
||||
}
|
||||
|
||||
abstract val label: Int
|
||||
open val summary: String? = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) {
|
||||
val rootAvailable = rootAvailable()
|
||||
val isAbDevice = isAbDevice()
|
||||
val selectFileTip = stringResource(
|
||||
id = R.string.select_file_tip, if (isInitBoot()) "init_boot" else "boot"
|
||||
)
|
||||
val radioOptions =
|
||||
mutableListOf<InstallMethod>(InstallMethod.SelectFile(summary = selectFileTip))
|
||||
if (rootAvailable) {
|
||||
radioOptions.add(InstallMethod.DirectInstall)
|
||||
|
||||
if (isAbDevice) {
|
||||
radioOptions.add(InstallMethod.DirectInstallToInactiveSlot)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedOption by remember { mutableStateOf<InstallMethod?>(null) }
|
||||
val selectImageLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
it.data?.data?.let { uri ->
|
||||
val option = InstallMethod.SelectFile(uri, summary = selectFileTip)
|
||||
selectedOption = option
|
||||
onSelected(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val confirmDialog = rememberConfirmDialog(onConfirm = {
|
||||
selectedOption = InstallMethod.DirectInstallToInactiveSlot
|
||||
onSelected(InstallMethod.DirectInstallToInactiveSlot)
|
||||
}, onDismiss = null)
|
||||
val dialogTitle = stringResource(id = android.R.string.dialog_alert_title)
|
||||
val dialogContent = stringResource(id = R.string.install_inactive_slot_warning)
|
||||
|
||||
val onClick = { option: InstallMethod ->
|
||||
|
||||
when (option) {
|
||||
is InstallMethod.SelectFile -> {
|
||||
selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "application/octet-stream"
|
||||
})
|
||||
}
|
||||
|
||||
is InstallMethod.DirectInstall -> {
|
||||
selectedOption = option
|
||||
onSelected(option)
|
||||
}
|
||||
|
||||
is InstallMethod.DirectInstallToInactiveSlot -> {
|
||||
confirmDialog.showConfirm(dialogTitle, dialogContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
radioOptions.forEach { option ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(
|
||||
value = option.javaClass == selectedOption?.javaClass,
|
||||
onValueChange = {
|
||||
onClick(option)
|
||||
},
|
||||
role = Role.RadioButton,
|
||||
indication = LocalIndication.current,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = option.javaClass == selectedOption?.javaClass,
|
||||
onClick = {
|
||||
onClick(option)
|
||||
},
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = option.label),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||
fontFamily = MaterialTheme.typography.titleMedium.fontFamily,
|
||||
fontStyle = MaterialTheme.typography.titleMedium.fontStyle
|
||||
)
|
||||
option.summary?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontFamily = MaterialTheme.typography.bodySmall.fontFamily,
|
||||
fontStyle = MaterialTheme.typography.bodySmall.fontStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun rememberSelectKmiDialog(onSelected: (String?) -> Unit): DialogHandle {
|
||||
return rememberCustomDialog { dismiss ->
|
||||
val supportedKmi by produceState(initialValue = emptyList<String>()) {
|
||||
value = getSupportedKmis()
|
||||
}
|
||||
val options = supportedKmi.map { value ->
|
||||
ListOption(
|
||||
titleText = value
|
||||
)
|
||||
}
|
||||
|
||||
var selection by remember { mutableStateOf<String?>(null) }
|
||||
ListDialog(state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
||||
onSelected(selection)
|
||||
}, onCloseRequest = {
|
||||
dismiss()
|
||||
}), header = Header.Default(
|
||||
title = stringResource(R.string.select_kmi),
|
||||
), selection = ListSelection.Single(
|
||||
showRadioButtons = true,
|
||||
options = options,
|
||||
) { _, option ->
|
||||
selection = option.titleText
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
onBack: () -> Unit = {},
|
||||
onLkmUpload: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.install)) }, navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
}, actions = {
|
||||
IconButton(onClick = onLkmUpload) {
|
||||
Icon(Icons.Filled.FileUpload, contentDescription = null)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun SelectInstallPreview() {
|
||||
InstallScreen(EmptyDestinationsNavigator)
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.Wysiwyg
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.ConfirmResult
|
||||
import com.rifsxd.ksunext.ui.component.rememberConfirmDialog
|
||||
import com.rifsxd.ksunext.ui.component.rememberLoadingDialog
|
||||
import com.rifsxd.ksunext.ui.util.DownloadListener
|
||||
import com.rifsxd.ksunext.ui.util.LocalSnackbarHost
|
||||
import com.rifsxd.ksunext.ui.util.download
|
||||
import com.rifsxd.ksunext.ui.util.hasMagisk
|
||||
import com.rifsxd.ksunext.ui.util.reboot
|
||||
import com.rifsxd.ksunext.ui.util.toggleModule
|
||||
import com.rifsxd.ksunext.ui.util.uninstallModule
|
||||
import com.rifsxd.ksunext.ui.viewmodel.ModuleViewModel
|
||||
import com.rifsxd.ksunext.ui.webui.WebUIActivity
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun ModuleScreen(navigator: DestinationsNavigator) {
|
||||
val viewModel = viewModel<ModuleViewModel>()
|
||||
val context = LocalContext.current
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (viewModel.moduleList.isEmpty() || viewModel.isNeedRefresh) {
|
||||
viewModel.fetchModuleList()
|
||||
}
|
||||
}
|
||||
|
||||
val isSafeMode = Natives.isSafeMode
|
||||
val hasMagisk = hasMagisk()
|
||||
|
||||
val hideInstallButton = isSafeMode || hasMagisk
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
var zipUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
title = { Text(stringResource(R.string.module)) },
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!hideInstallButton) {
|
||||
val moduleInstall = stringResource(id = R.string.module_install)
|
||||
val selectZipLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode != RESULT_OK) {
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
val data = result.data ?: return@rememberLauncherForActivityResult
|
||||
val uri = data.data ?: return@rememberLauncherForActivityResult
|
||||
|
||||
// save the selected Uri and trigger confirmation dialog
|
||||
zipUri = uri
|
||||
showConfirmDialog = true
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
// select the zip file to install
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "application/zip"
|
||||
}
|
||||
selectZipLauncher.launch(intent)
|
||||
},
|
||||
icon = { Icon(Icons.Filled.Add, moduleInstall) },
|
||||
text = { Text(text = moduleInstall) },
|
||||
)
|
||||
}
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) }
|
||||
) { innerPadding ->
|
||||
// confirmation dialog
|
||||
if (showConfirmDialog && zipUri != null) {
|
||||
// extract the module name from the zipUri
|
||||
val moduleName = zipUri?.lastPathSegment?.substringAfterLast('/') ?: "Unknown Module"
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showConfirmDialog = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showConfirmDialog = false
|
||||
navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(zipUri!!)))
|
||||
}) {
|
||||
Text(stringResource(R.string.confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showConfirmDialog = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.confirm_module_installation)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.module_install_prompt_with_name, moduleName)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
hasMagisk -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.module_magisk_conflict),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
ModuleList(
|
||||
navigator,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
boxModifier = Modifier.padding(innerPadding),
|
||||
onInstallModule = {
|
||||
navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(it)))
|
||||
},
|
||||
onClickModule = { id, name, hasWebUi ->
|
||||
if (hasWebUi) {
|
||||
context.startActivity(
|
||||
Intent(context, WebUIActivity::class.java)
|
||||
.setData(Uri.parse("kernelsu://webui/$id"))
|
||||
.putExtra("id", id)
|
||||
.putExtra("name", name)
|
||||
)
|
||||
}
|
||||
},
|
||||
context = context,
|
||||
snackBarHost = snackBarHost
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ModuleList(
|
||||
navigator: DestinationsNavigator,
|
||||
viewModel: ModuleViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
boxModifier: Modifier = Modifier,
|
||||
onInstallModule: (Uri) -> Unit,
|
||||
onClickModule: (id: String, name: String, hasWebUi: Boolean) -> Unit,
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState
|
||||
) {
|
||||
val failedEnable = stringResource(R.string.module_failed_to_enable)
|
||||
val failedDisable = stringResource(R.string.module_failed_to_disable)
|
||||
val failedUninstall = stringResource(R.string.module_uninstall_failed)
|
||||
val successUninstall = stringResource(R.string.module_uninstall_success)
|
||||
val reboot = stringResource(R.string.reboot)
|
||||
val rebootToApply = stringResource(R.string.reboot_to_apply)
|
||||
val moduleStr = stringResource(R.string.module)
|
||||
val uninstall = stringResource(R.string.uninstall)
|
||||
val cancel = stringResource(android.R.string.cancel)
|
||||
val moduleUninstallConfirm = stringResource(R.string.module_uninstall_confirm)
|
||||
val updateText = stringResource(R.string.module_update)
|
||||
val changelogText = stringResource(R.string.module_changelog)
|
||||
val downloadingText = stringResource(R.string.module_downloading)
|
||||
val startDownloadingText = stringResource(R.string.module_start_downloading)
|
||||
val fetchChangeLogFailed = stringResource(R.string.module_changelog_failed)
|
||||
|
||||
val loadingDialog = rememberLoadingDialog()
|
||||
val confirmDialog = rememberConfirmDialog()
|
||||
|
||||
suspend fun onModuleUpdate(
|
||||
module: ModuleViewModel.ModuleInfo,
|
||||
changelogUrl: String,
|
||||
downloadUrl: String,
|
||||
fileName: String
|
||||
) {
|
||||
val changelogResult = loadingDialog.withLoading {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
OkHttpClient().newCall(
|
||||
okhttp3.Request.Builder().url(changelogUrl).build()
|
||||
).execute().body!!.string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showToast: suspend (String) -> Unit = { msg ->
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
msg,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
val changelog = changelogResult.getOrElse {
|
||||
showToast(fetchChangeLogFailed.format(it.message))
|
||||
return
|
||||
}.ifBlank {
|
||||
showToast(fetchChangeLogFailed.format(module.name))
|
||||
return
|
||||
}
|
||||
|
||||
// changelog is not empty, show it and wait for confirm
|
||||
val confirmResult = confirmDialog.awaitConfirm(
|
||||
changelogText,
|
||||
content = changelog,
|
||||
markdown = true,
|
||||
confirm = updateText,
|
||||
)
|
||||
|
||||
if (confirmResult != ConfirmResult.Confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
showToast(startDownloadingText.format(module.name))
|
||||
|
||||
val downloading = downloadingText.format(module.name)
|
||||
withContext(Dispatchers.IO) {
|
||||
download(
|
||||
context,
|
||||
downloadUrl,
|
||||
fileName,
|
||||
downloading,
|
||||
onDownloaded = onInstallModule,
|
||||
onDownloading = {
|
||||
launch(Dispatchers.Main) {
|
||||
Toast.makeText(context, downloading, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onModuleUninstall(module: ModuleViewModel.ModuleInfo) {
|
||||
val confirmResult = confirmDialog.awaitConfirm(
|
||||
moduleStr,
|
||||
content = moduleUninstallConfirm.format(module.name),
|
||||
confirm = uninstall,
|
||||
dismiss = cancel
|
||||
)
|
||||
if (confirmResult != ConfirmResult.Confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
val success = loadingDialog.withLoading {
|
||||
withContext(Dispatchers.IO) {
|
||||
uninstallModule(module.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
viewModel.fetchModuleList()
|
||||
}
|
||||
val message = if (success) {
|
||||
successUninstall.format(module.name)
|
||||
} else {
|
||||
failedUninstall.format(module.name)
|
||||
}
|
||||
val actionLabel = if (success) {
|
||||
reboot
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = snackBarHost.showSnackbar(
|
||||
message = message,
|
||||
actionLabel = actionLabel,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
reboot()
|
||||
}
|
||||
}
|
||||
PullToRefreshBox(
|
||||
modifier = boxModifier,
|
||||
onRefresh = {
|
||||
viewModel.fetchModuleList()
|
||||
},
|
||||
isRefreshing = viewModel.isRefreshing
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = remember {
|
||||
PaddingValues(
|
||||
start = 16.dp,
|
||||
top = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp + 56.dp + 16.dp + 48.dp + 6.dp /* Scaffold Fab Spacing + Fab container height + SnackBar height */
|
||||
)
|
||||
},
|
||||
) {
|
||||
when {
|
||||
viewModel.moduleList.isEmpty() -> {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.module_empty),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
items(viewModel.moduleList) { module ->
|
||||
var isChecked by rememberSaveable(module) { mutableStateOf(module.enabled) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val updatedModule by produceState(initialValue = Triple("", "", "")) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
value = viewModel.checkUpdate(module)
|
||||
}
|
||||
}
|
||||
|
||||
ModuleItem(
|
||||
navigator = navigator,
|
||||
module = module,
|
||||
isChecked = isChecked,
|
||||
updateUrl = updatedModule.first,
|
||||
onUninstall = {
|
||||
scope.launch { onModuleUninstall(module) }
|
||||
},
|
||||
onCheckChanged = {
|
||||
scope.launch {
|
||||
val success = loadingDialog.withLoading {
|
||||
withContext(Dispatchers.IO) {
|
||||
toggleModule(module.id, !isChecked)
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
isChecked = it
|
||||
viewModel.fetchModuleList()
|
||||
|
||||
val result = snackBarHost.showSnackbar(
|
||||
message = rebootToApply,
|
||||
actionLabel = reboot,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
reboot()
|
||||
}
|
||||
} else {
|
||||
val message = if (isChecked) failedDisable else failedEnable
|
||||
snackBarHost.showSnackbar(message.format(module.name))
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate = {
|
||||
scope.launch {
|
||||
onModuleUpdate(
|
||||
module,
|
||||
updatedModule.third,
|
||||
updatedModule.first,
|
||||
"${module.name}-${updatedModule.second}.zip"
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onClickModule(it.id, it.name, it.hasWebUi)
|
||||
}
|
||||
)
|
||||
|
||||
// fix last item shadow incomplete in LazyColumn
|
||||
Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DownloadListener(context, onInstallModule)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModuleItem(
|
||||
navigator: DestinationsNavigator,
|
||||
module: ModuleViewModel.ModuleInfo,
|
||||
isChecked: Boolean,
|
||||
updateUrl: String,
|
||||
onUninstall: (ModuleViewModel.ModuleInfo) -> Unit,
|
||||
onCheckChanged: (Boolean) -> Unit,
|
||||
onUpdate: (ModuleViewModel.ModuleInfo) -> Unit,
|
||||
onClick: (ModuleViewModel.ModuleInfo) -> Unit
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val textDecoration = if (!module.remove) null else TextDecoration.LineThrough
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val indication = LocalIndication.current
|
||||
val viewModel = viewModel<ModuleViewModel>()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(22.dp, 18.dp, 22.dp, 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
val moduleVersion = stringResource(id = R.string.module_version)
|
||||
val moduleAuthor = stringResource(id = R.string.module_author)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(0.8f)
|
||||
) {
|
||||
Text(
|
||||
text = module.name,
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||
fontFamily = MaterialTheme.typography.titleMedium.fontFamily,
|
||||
textDecoration = textDecoration,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "$moduleVersion: ${module.version}",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||
fontFamily = MaterialTheme.typography.bodySmall.fontFamily,
|
||||
textDecoration = textDecoration
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "$moduleAuthor: ${module.author}",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||
fontFamily = MaterialTheme.typography.bodySmall.fontFamily,
|
||||
textDecoration = textDecoration
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Switch(
|
||||
enabled = !module.update,
|
||||
checked = isChecked,
|
||||
onCheckedChange = onCheckChanged,
|
||||
interactionSource = if (!module.hasWebUi) interactionSource else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = module.description,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontFamily = MaterialTheme.typography.bodySmall.fontFamily,
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||
fontWeight = MaterialTheme.typography.bodySmall.fontWeight,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 4,
|
||||
textDecoration = textDecoration
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
HorizontalDivider(thickness = Dp.Hairline)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
if (module.hasActionScript) {
|
||||
FilledTonalButton(
|
||||
modifier = Modifier.defaultMinSize(52.dp, 32.dp),
|
||||
onClick = {
|
||||
navigator.navigate(ExecuteModuleActionScreenDestination(module.id))
|
||||
viewModel.markNeedRefresh()
|
||||
},
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = Icons.Outlined.PlayArrow,
|
||||
contentDescription = null
|
||||
)
|
||||
if (!module.hasWebUi && updateUrl.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 7.dp),
|
||||
text = stringResource(R.string.action),
|
||||
fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(0.1f, true))
|
||||
}
|
||||
|
||||
if (module.hasWebUi) {
|
||||
FilledTonalButton(
|
||||
modifier = Modifier.defaultMinSize(52.dp, 32.dp),
|
||||
onClick = { onClick(module) },
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = Icons.AutoMirrored.Outlined.Wysiwyg,
|
||||
contentDescription = null
|
||||
)
|
||||
if (!module.hasActionScript && updateUrl.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 7.dp),
|
||||
fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
text = stringResource(R.string.open)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f, true))
|
||||
|
||||
if (updateUrl.isNotEmpty()) {
|
||||
Button(
|
||||
modifier = Modifier.defaultMinSize(52.dp, 32.dp),
|
||||
onClick = { onUpdate(module) },
|
||||
shape = ButtonDefaults.textShape,
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = Icons.Outlined.Download,
|
||||
contentDescription = null
|
||||
)
|
||||
if (!module.hasActionScript || !module.hasWebUi) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 7.dp),
|
||||
fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
text = stringResource(R.string.module_update)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(0.1f, true))
|
||||
}
|
||||
|
||||
FilledTonalButton(
|
||||
modifier = Modifier.defaultMinSize(52.dp, 32.dp),
|
||||
enabled = !module.remove,
|
||||
onClick = { onUninstall(module) },
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = null
|
||||
)
|
||||
if (!module.hasActionScript && !module.hasWebUi && updateUrl.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 7.dp),
|
||||
fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
text = stringResource(R.string.uninstall)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ModuleItemPreview() {
|
||||
val module = ModuleViewModel.ModuleInfo(
|
||||
id = "id",
|
||||
name = "name",
|
||||
version = "version",
|
||||
versionCode = 1,
|
||||
author = "author",
|
||||
description = "I am a test module and i do nothing but show a very long description",
|
||||
enabled = true,
|
||||
update = true,
|
||||
remove = false,
|
||||
updateJson = "",
|
||||
hasWebUi = false,
|
||||
hasActionScript = false
|
||||
)
|
||||
ModuleItem(EmptyDestinationsNavigator, module, true, "", {}, {}, {}, {})
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Compress
|
||||
import androidx.compose.material.icons.filled.ContactPage
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.DeveloperMode
|
||||
import androidx.compose.material.icons.filled.Fence
|
||||
import androidx.compose.material.icons.filled.RemoveModerator
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Update
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.list.ListDialog
|
||||
import com.maxkeppeler.sheets.list.models.ListOption
|
||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.rifsxd.ksunext.BuildConfig
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.AboutDialog
|
||||
import com.rifsxd.ksunext.ui.component.ConfirmResult
|
||||
import com.rifsxd.ksunext.ui.component.DialogHandle
|
||||
import com.rifsxd.ksunext.ui.component.SwitchItem
|
||||
import com.rifsxd.ksunext.ui.component.rememberConfirmDialog
|
||||
import com.rifsxd.ksunext.ui.component.rememberCustomDialog
|
||||
import com.rifsxd.ksunext.ui.component.rememberLoadingDialog
|
||||
import com.rifsxd.ksunext.ui.util.LocalSnackbarHost
|
||||
import com.rifsxd.ksunext.ui.util.getBugreportFile
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/1/1.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun SettingScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackBarHost) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { paddingValues ->
|
||||
val aboutDialog = rememberCustomDialog {
|
||||
AboutDialog(it)
|
||||
}
|
||||
val loadingDialog = rememberLoadingDialog()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val exportBugreportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/gzip")
|
||||
) { uri: Uri? ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch(Dispatchers.IO) {
|
||||
loadingDialog.show()
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
getBugreportFile(context).inputStream().use {
|
||||
it.copyTo(output)
|
||||
}
|
||||
}
|
||||
loadingDialog.hide()
|
||||
snackBarHost.showSnackbar(context.getString(R.string.log_saved))
|
||||
}
|
||||
}
|
||||
|
||||
val profileTemplate = stringResource(id = R.string.settings_profile_template)
|
||||
ListItem(
|
||||
leadingContent = { Icon(Icons.Filled.Fence, profileTemplate) },
|
||||
headlineContent = { Text(profileTemplate) },
|
||||
supportingContent = { Text(stringResource(id = R.string.settings_profile_template_summary)) },
|
||||
modifier = Modifier.clickable {
|
||||
navigator.navigate(AppProfileTemplateScreenDestination)
|
||||
}
|
||||
)
|
||||
|
||||
var umountChecked by rememberSaveable {
|
||||
mutableStateOf(Natives.isDefaultUmountModules())
|
||||
}
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.RemoveModerator,
|
||||
title = stringResource(id = R.string.settings_umount_modules_default),
|
||||
summary = stringResource(id = R.string.settings_umount_modules_default_summary),
|
||||
checked = umountChecked
|
||||
) {
|
||||
if (Natives.setDefaultUmountModules(it)) {
|
||||
umountChecked = it
|
||||
}
|
||||
}
|
||||
|
||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
var checkUpdate by rememberSaveable {
|
||||
mutableStateOf(
|
||||
prefs.getBoolean("check_update", true)
|
||||
)
|
||||
}
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.Update,
|
||||
title = stringResource(id = R.string.settings_check_update),
|
||||
summary = stringResource(id = R.string.settings_check_update_summary),
|
||||
checked = checkUpdate
|
||||
) {
|
||||
prefs.edit().putBoolean("check_update", it).apply()
|
||||
checkUpdate = it
|
||||
}
|
||||
|
||||
var enableWebDebugging by rememberSaveable {
|
||||
mutableStateOf(
|
||||
prefs.getBoolean("enable_web_debugging", false)
|
||||
)
|
||||
}
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.DeveloperMode,
|
||||
title = stringResource(id = R.string.enable_web_debugging),
|
||||
summary = stringResource(id = R.string.enable_web_debugging_summary),
|
||||
checked = enableWebDebugging
|
||||
) {
|
||||
prefs.edit().putBoolean("enable_web_debugging", it).apply()
|
||||
enableWebDebugging = it
|
||||
}
|
||||
|
||||
var showBottomsheet by remember { mutableStateOf(false) }
|
||||
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.BugReport,
|
||||
stringResource(id = R.string.send_log)
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(stringResource(id = R.string.send_log)) },
|
||||
modifier = Modifier.clickable {
|
||||
showBottomsheet = true
|
||||
}
|
||||
)
|
||||
if (showBottomsheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomsheet = false },
|
||||
content = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.clickable {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm")
|
||||
val current = LocalDateTime.now().format(formatter)
|
||||
exportBugreportLauncher.launch("KernelSU_bugreport_${current}.tar.gz")
|
||||
showBottomsheet = false
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.save_log),
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
textAlign = TextAlign.Center.also {
|
||||
LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Center,
|
||||
trim = LineHeightStyle.Trim.None
|
||||
)
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.clickable {
|
||||
scope.launch {
|
||||
val bugreport = loadingDialog.withLoading {
|
||||
withContext(Dispatchers.IO) {
|
||||
getBugreportFile(context)
|
||||
}
|
||||
}
|
||||
|
||||
val uri: Uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
bugreport
|
||||
)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
setDataAndType(uri, "application/gzip")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
context.getString(R.string.send_log)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Share,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.send_log),
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
textAlign = TextAlign.Center.also {
|
||||
LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Center,
|
||||
trim = LineHeightStyle.Trim.None
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val lkmMode = Natives.version >= Natives.MINIMAL_SUPPORTED_KERNEL_LKM && Natives.isLkmMode
|
||||
if (lkmMode) {
|
||||
UninstallItem(navigator) {
|
||||
loadingDialog.withLoading(it)
|
||||
}
|
||||
}
|
||||
|
||||
val about = stringResource(id = R.string.about)
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.ContactPage,
|
||||
about
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(about) },
|
||||
modifier = Modifier.clickable {
|
||||
aboutDialog.show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UninstallItem(
|
||||
navigator: DestinationsNavigator,
|
||||
withLoading: suspend (suspend () -> Unit) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val uninstallConfirmDialog = rememberConfirmDialog()
|
||||
val showTodo = {
|
||||
Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val uninstallDialog = rememberUninstallDialog { uninstallType ->
|
||||
scope.launch {
|
||||
val result = uninstallConfirmDialog.awaitConfirm(
|
||||
title = context.getString(uninstallType.title),
|
||||
content = context.getString(uninstallType.message)
|
||||
)
|
||||
if (result == ConfirmResult.Confirmed) {
|
||||
withLoading {
|
||||
when (uninstallType) {
|
||||
UninstallType.TEMPORARY -> showTodo()
|
||||
UninstallType.PERMANENT -> navigator.navigate(
|
||||
FlashScreenDestination(FlashIt.FlashUninstall)
|
||||
)
|
||||
UninstallType.RESTORE_STOCK_IMAGE -> navigator.navigate(
|
||||
FlashScreenDestination(FlashIt.FlashRestore)
|
||||
)
|
||||
UninstallType.NONE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val uninstall = stringResource(id = R.string.settings_uninstall)
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
uninstall
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(uninstall) },
|
||||
modifier = Modifier.clickable {
|
||||
uninstallDialog.show()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
enum class UninstallType(val title: Int, val message: Int, val icon: ImageVector) {
|
||||
TEMPORARY(
|
||||
R.string.settings_uninstall_temporary,
|
||||
R.string.settings_uninstall_temporary_message,
|
||||
Icons.Filled.Delete
|
||||
),
|
||||
PERMANENT(
|
||||
R.string.settings_uninstall_permanent,
|
||||
R.string.settings_uninstall_permanent_message,
|
||||
Icons.Filled.DeleteForever
|
||||
),
|
||||
RESTORE_STOCK_IMAGE(
|
||||
R.string.settings_restore_stock_image,
|
||||
R.string.settings_restore_stock_image_message,
|
||||
Icons.AutoMirrored.Filled.Undo
|
||||
),
|
||||
NONE(0, 0, Icons.Filled.Delete)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun rememberUninstallDialog(onSelected: (UninstallType) -> Unit): DialogHandle {
|
||||
return rememberCustomDialog { dismiss ->
|
||||
val options = listOf(
|
||||
// UninstallType.TEMPORARY,
|
||||
UninstallType.PERMANENT,
|
||||
UninstallType.RESTORE_STOCK_IMAGE
|
||||
)
|
||||
val listOptions = options.map {
|
||||
ListOption(
|
||||
titleText = stringResource(it.title),
|
||||
subtitleText = if (it.message != 0) stringResource(it.message) else null,
|
||||
icon = IconSource(it.icon)
|
||||
)
|
||||
}
|
||||
|
||||
var selection = UninstallType.NONE
|
||||
ListDialog(state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
||||
if (selection != UninstallType.NONE) {
|
||||
onSelected(selection)
|
||||
}
|
||||
}, onCloseRequest = {
|
||||
dismiss()
|
||||
}), header = Header.Default(
|
||||
title = stringResource(R.string.settings_uninstall),
|
||||
), selection = ListSelection.Single(
|
||||
showRadioButtons = false,
|
||||
options = listOptions,
|
||||
) { index, _ ->
|
||||
selection = options[index]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsPreview() {
|
||||
SettingScreen(EmptyDestinationsNavigator)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.AppProfileScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlinx.coroutines.launch
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.SearchAppBar
|
||||
import com.rifsxd.ksunext.ui.viewmodel.SuperUserViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun SuperUserScreen(navigator: DestinationsNavigator) {
|
||||
val viewModel = viewModel<SuperUserViewModel>()
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(key1 = navigator) {
|
||||
viewModel.search = ""
|
||||
if (viewModel.appList.isEmpty()) {
|
||||
viewModel.fetchAppList()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel.search) {
|
||||
if (viewModel.search.isEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SearchAppBar(
|
||||
title = { Text(stringResource(R.string.superuser)) },
|
||||
searchText = viewModel.search,
|
||||
onSearchTextChange = { viewModel.search = it },
|
||||
onClearClick = { viewModel.search = "" },
|
||||
dropdownContent = {
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(
|
||||
onClick = { showDropdown = true },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(id = R.string.settings)
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
||||
showDropdown = false
|
||||
}) {
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(R.string.refresh))
|
||||
}, onClick = {
|
||||
scope.launch {
|
||||
viewModel.fetchAppList()
|
||||
}
|
||||
showDropdown = false
|
||||
})
|
||||
DropdownMenuItem(text = {
|
||||
Text(
|
||||
if (viewModel.showSystemApps) {
|
||||
stringResource(R.string.hide_system_apps)
|
||||
} else {
|
||||
stringResource(R.string.show_system_apps)
|
||||
}
|
||||
)
|
||||
}, onClick = {
|
||||
viewModel.showSystemApps = !viewModel.showSystemApps
|
||||
showDropdown = false
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
onRefresh = {
|
||||
scope.launch { viewModel.fetchAppList() }
|
||||
},
|
||||
isRefreshing = viewModel.isRefreshing
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
) {
|
||||
items(viewModel.appList, key = { it.packageName + it.uid }) { app ->
|
||||
AppItem(app) {
|
||||
navigator.navigate(AppProfileScreenDestination(app))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun AppItem(
|
||||
app: SuperUserViewModel.AppInfo,
|
||||
onClickListener: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClickListener),
|
||||
headlineContent = { Text(app.label) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(app.packageName)
|
||||
FlowRow {
|
||||
if (app.allowSu) {
|
||||
LabelText(label = "ROOT")
|
||||
} else {
|
||||
if (Natives.uidShouldUmount(app.uid)) {
|
||||
LabelText(label = "UMOUNT")
|
||||
}
|
||||
}
|
||||
if (app.hasCustomProfile) {
|
||||
LabelText(label = "CUSTOM")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(app.packageInfo)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = app.label,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LabelText(label: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp, end = 4.dp)
|
||||
.background(
|
||||
Color.Black,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(vertical = 2.dp, horizontal = 5.dp),
|
||||
style = TextStyle(
|
||||
fontSize = 8.sp,
|
||||
color = Color.White,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.result.ResultRecipient
|
||||
import com.ramcosta.composedestinations.result.getOr
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.viewmodel.TemplateViewModel
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/10/20.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun AppProfileTemplateScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean>
|
||||
) {
|
||||
val viewModel = viewModel<TemplateViewModel>()
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (viewModel.templateList.isEmpty()) {
|
||||
viewModel.fetchTemplates()
|
||||
}
|
||||
}
|
||||
|
||||
// handle result from TemplateEditorScreen, refresh if needed
|
||||
resultRecipient.onNavResult { result ->
|
||||
if (result.getOr { false }) {
|
||||
scope.launch { viewModel.fetchTemplates() }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val showToast = fun(msg: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
TopBar(
|
||||
onBack = { navigator.popBackStack() },
|
||||
onSync = {
|
||||
scope.launch { viewModel.fetchTemplates(true) }
|
||||
},
|
||||
onImport = {
|
||||
clipboardManager.getText()?.text?.let {
|
||||
if (it.isEmpty()) {
|
||||
showToast(context.getString(R.string.app_profile_template_import_empty))
|
||||
return@let
|
||||
}
|
||||
scope.launch {
|
||||
viewModel.importTemplates(
|
||||
it, {
|
||||
showToast(context.getString(R.string.app_profile_template_import_success))
|
||||
viewModel.fetchTemplates(false)
|
||||
},
|
||||
showToast
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onExport = {
|
||||
scope.launch {
|
||||
viewModel.exportTemplates(
|
||||
{
|
||||
showToast(context.getString(R.string.app_profile_template_export_empty))
|
||||
}
|
||||
) {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
navigator.navigate(
|
||||
TemplateEditorScreenDestination(
|
||||
TemplateViewModel.TemplateInfo(),
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
icon = { Icon(Icons.Filled.Add, null) },
|
||||
text = { Text(stringResource(id = R.string.app_profile_template_create)) },
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
isRefreshing = viewModel.isRefreshing,
|
||||
onRefresh = {
|
||||
scope.launch { viewModel.fetchTemplates() }
|
||||
}
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
contentPadding = remember {
|
||||
PaddingValues(bottom = 16.dp + 56.dp + 16.dp /* Scaffold Fab Spacing + Fab container height */)
|
||||
}
|
||||
) {
|
||||
items(viewModel.templateList, key = { it.id }) { app ->
|
||||
TemplateItem(navigator, app)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TemplateItem(
|
||||
navigator: DestinationsNavigator,
|
||||
template: TemplateViewModel.TemplateInfo
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
navigator.navigate(TemplateEditorScreenDestination(template, !template.local))
|
||||
},
|
||||
headlineContent = { Text(template.name) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(
|
||||
text = "${template.id}${if (template.author.isEmpty()) "" else "@${template.author}"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
)
|
||||
Text(template.description)
|
||||
FlowRow {
|
||||
LabelText(label = "UID: ${template.uid}")
|
||||
LabelText(label = "GID: ${template.gid}")
|
||||
LabelText(label = template.context)
|
||||
if (template.local) {
|
||||
LabelText(label = "local")
|
||||
} else {
|
||||
LabelText(label = "remote")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
onBack: () -> Unit,
|
||||
onSync: () -> Unit = {},
|
||||
onImport: () -> Unit = {},
|
||||
onExport: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(stringResource(R.string.settings_profile_template))
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSync) {
|
||||
Icon(
|
||||
Icons.Filled.Sync,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_sync)
|
||||
)
|
||||
}
|
||||
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = {
|
||||
showDropdown = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ImportExport,
|
||||
contentDescription = stringResource(id = R.string.app_profile_import_export)
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
||||
showDropdown = false
|
||||
}) {
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id = R.string.app_profile_import_from_clipboard))
|
||||
}, onClick = {
|
||||
onImport()
|
||||
showDropdown = false
|
||||
})
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id = R.string.app_profile_export_to_clipboard))
|
||||
}, onClick = {
|
||||
onExport()
|
||||
showDropdown = false
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package com.rifsxd.ksunext.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.result.ResultBackNavigator
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.R
|
||||
import com.rifsxd.ksunext.ui.component.profile.RootProfileConfig
|
||||
import com.rifsxd.ksunext.ui.util.deleteAppProfileTemplate
|
||||
import com.rifsxd.ksunext.ui.util.getAppProfileTemplate
|
||||
import com.rifsxd.ksunext.ui.util.setAppProfileTemplate
|
||||
import com.rifsxd.ksunext.ui.viewmodel.TemplateViewModel
|
||||
import com.rifsxd.ksunext.ui.viewmodel.toJSON
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/10/20.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun TemplateEditorScreen(
|
||||
navigator: ResultBackNavigator<Boolean>,
|
||||
initialTemplate: TemplateViewModel.TemplateInfo,
|
||||
readOnly: Boolean = true,
|
||||
) {
|
||||
|
||||
val isCreation = initialTemplate.id.isBlank()
|
||||
val autoSave = !isCreation
|
||||
|
||||
var template by rememberSaveable {
|
||||
mutableStateOf(initialTemplate)
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
BackHandler {
|
||||
navigator.navigateBack(result = !readOnly)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val author =
|
||||
if (initialTemplate.author.isNotEmpty()) "@${initialTemplate.author}" else ""
|
||||
val readOnlyHint = if (readOnly) {
|
||||
" - ${stringResource(id = R.string.app_profile_template_readonly)}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val titleSummary = "${initialTemplate.id}$author$readOnlyHint"
|
||||
val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
|
||||
val context = LocalContext.current
|
||||
|
||||
TopBar(
|
||||
title = if (isCreation) {
|
||||
stringResource(R.string.app_profile_template_create)
|
||||
} else if (readOnly) {
|
||||
stringResource(R.string.app_profile_template_view)
|
||||
} else {
|
||||
stringResource(R.string.app_profile_template_edit)
|
||||
},
|
||||
readOnly = readOnly,
|
||||
summary = titleSummary,
|
||||
onBack = { navigator.navigateBack(result = !readOnly) },
|
||||
onDelete = {
|
||||
if (deleteAppProfileTemplate(template.id)) {
|
||||
navigator.navigateBack(result = true)
|
||||
}
|
||||
},
|
||||
onSave = {
|
||||
if (saveTemplate(template, isCreation)) {
|
||||
navigator.navigateBack(result = true)
|
||||
} else {
|
||||
Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.pointerInteropFilter {
|
||||
// disable click and ripple if readOnly
|
||||
readOnly
|
||||
}
|
||||
) {
|
||||
if (isCreation) {
|
||||
var errorHint by remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
|
||||
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_id),
|
||||
text = template.id,
|
||||
errorHint = errorHint,
|
||||
isError = errorHint.isNotEmpty()
|
||||
) { value ->
|
||||
errorHint = if (isTemplateExist(value)) {
|
||||
idConflictError
|
||||
} else if (!isValidTemplateId(value)) {
|
||||
idInvalidError
|
||||
} else {
|
||||
""
|
||||
}
|
||||
template = template.copy(id = value)
|
||||
}
|
||||
}
|
||||
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_name),
|
||||
text = template.name
|
||||
) { value ->
|
||||
template.copy(name = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_description),
|
||||
text = template.description
|
||||
) { value ->
|
||||
template.copy(description = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
|
||||
RootProfileConfig(fixedName = true,
|
||||
profile = toNativeProfile(template),
|
||||
onProfileChange = {
|
||||
template.copy(
|
||||
uid = it.uid,
|
||||
gid = it.gid,
|
||||
groups = it.groups,
|
||||
capabilities = it.capabilities,
|
||||
context = it.context,
|
||||
namespace = it.namespace,
|
||||
rules = it.rules.split("\n")
|
||||
).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile {
|
||||
return Natives.Profile().copy(rootTemplate = templateInfo.id,
|
||||
uid = templateInfo.uid,
|
||||
gid = templateInfo.gid,
|
||||
groups = templateInfo.groups,
|
||||
capabilities = templateInfo.capabilities,
|
||||
context = templateInfo.context,
|
||||
namespace = templateInfo.namespace,
|
||||
rules = templateInfo.rules.joinToString("\n").ifBlank { "" })
|
||||
}
|
||||
|
||||
fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean {
|
||||
if (template.id.isBlank()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isValidTemplateId(template.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean {
|
||||
if (!isTemplateValid(template)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isCreation && isTemplateExist(template.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val json = template.toJSON()
|
||||
json.put("local", true)
|
||||
return setAppProfileTemplate(template.id, json.toString())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
title: String,
|
||||
readOnly: Boolean,
|
||||
summary: String = "",
|
||||
onBack: () -> Unit,
|
||||
onDelete: () -> Unit = {},
|
||||
onSave: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(title)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}, navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
}, actions = {
|
||||
if (readOnly) {
|
||||
return@TopAppBar
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Filled.DeleteForever,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_delete)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onSave) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_save)
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextEdit(
|
||||
label: String,
|
||||
text: String,
|
||||
errorHint: String = "",
|
||||
isError: Boolean = false,
|
||||
onValueChange: (String) -> Unit = {}
|
||||
) {
|
||||
ListItem(headlineContent = {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
suffix = {
|
||||
if (errorHint.isNotBlank()) {
|
||||
Text(
|
||||
text = if (isError) errorHint else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
}),
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private fun isValidTemplateId(id: String): Boolean {
|
||||
return Regex("""^([A-Za-z][A-Za-z\d_]*\.)*[A-Za-z][A-Za-z\d_]*$""").matches(id)
|
||||
}
|
||||
|
||||
private fun isTemplateExist(id: String): Boolean {
|
||||
return getAppProfileTemplate(id).isNotBlank()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.rifsxd.ksunext.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val YELLOW = Color(0xFFeed502)
|
||||
val YELLOW_LIGHT = Color(0xFFffff52)
|
||||
val SECONDARY_LIGHT = Color(0xffa9817f)
|
||||
|
||||
val YELLOW_DARK = Color(0xFFb7a400)
|
||||
val SECONDARY_DARK = Color(0xFF4c2b2b)
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.rifsxd.ksunext.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = YELLOW,
|
||||
secondary = YELLOW_DARK,
|
||||
tertiary = SECONDARY_DARK
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = YELLOW,
|
||||
secondary = YELLOW_LIGHT,
|
||||
tertiary = SECONDARY_LIGHT
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun KernelSUTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.rifsxd.ksunext.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = androidx.compose.material3.Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.rifsxd.ksunext.ui.util
|
||||
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
val LocalSnackbarHost = compositionLocalOf<SnackbarHostState> {
|
||||
error("CompositionLocal LocalSnackbarController not present")
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.rifsxd.ksunext.ui.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.rifsxd.ksunext.ui.util.module.LatestVersionInfo
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/6/22.
|
||||
*/
|
||||
@SuppressLint("Range")
|
||||
fun download(
|
||||
context: Context,
|
||||
url: String,
|
||||
fileName: String,
|
||||
description: String,
|
||||
onDownloaded: (Uri) -> Unit = {},
|
||||
onDownloading: () -> Unit = {}
|
||||
) {
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
val query = DownloadManager.Query()
|
||||
query.setFilterByStatus(DownloadManager.STATUS_RUNNING or DownloadManager.STATUS_PAUSED or DownloadManager.STATUS_PENDING)
|
||||
downloadManager.query(query).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val uri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI))
|
||||
val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
|
||||
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
||||
val columnTitle = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE))
|
||||
if (url == uri || fileName == columnTitle) {
|
||||
if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
|
||||
onDownloading()
|
||||
return
|
||||
} else if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
onDownloaded(Uri.parse(localUri))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
fileName
|
||||
)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setMimeType("application/zip")
|
||||
.setTitle(fileName)
|
||||
.setDescription(description)
|
||||
|
||||
downloadManager.enqueue(request)
|
||||
}
|
||||
|
||||
fun checkNewVersion(): LatestVersionInfo {
|
||||
// Next version updates
|
||||
val url = "https://api.github.com/repos/rifsxd/KernelSU/releases/latest"
|
||||
// default null value if failed
|
||||
val defaultValue = LatestVersionInfo()
|
||||
runCatching {
|
||||
okhttp3.OkHttpClient().newCall(okhttp3.Request.Builder().url(url).build()).execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return defaultValue
|
||||
}
|
||||
val body = response.body?.string() ?: return defaultValue
|
||||
val json = org.json.JSONObject(body)
|
||||
val changelog = json.optString("body")
|
||||
|
||||
val assets = json.getJSONArray("assets")
|
||||
for (i in 0 until assets.length()) {
|
||||
val asset = assets.getJSONObject(i)
|
||||
val name = asset.getString("name")
|
||||
if (!name.endsWith(".apk")) {
|
||||
continue
|
||||
}
|
||||
|
||||
val regex = Regex("v(.+?)_(\\d+)-")
|
||||
val matchResult = regex.find(name) ?: continue
|
||||
val versionName = matchResult.groupValues[1]
|
||||
val versionCode = matchResult.groupValues[2].toInt()
|
||||
val downloadUrl = asset.getString("browser_download_url")
|
||||
|
||||
return LatestVersionInfo(
|
||||
versionCode,
|
||||
downloadUrl,
|
||||
changelog
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) {
|
||||
DisposableEffect(context) {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
@SuppressLint("Range")
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE) {
|
||||
val id = intent.getLongExtra(
|
||||
DownloadManager.EXTRA_DOWNLOAD_ID, -1
|
||||
)
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
val downloadManager =
|
||||
context?.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val cursor = downloadManager.query(query)
|
||||
if (cursor.moveToFirst()) {
|
||||
val status = cursor.getInt(
|
||||
cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
)
|
||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
val uri = cursor.getString(
|
||||
cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
||||
)
|
||||
onDownloaded(Uri.parse(uri))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
||||
ContextCompat.RECEIVER_EXPORTED
|
||||
)
|
||||
onDispose {
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package com.rifsxd.ksunext.ui.util;
|
||||
/*
|
||||
* Copyright (C) 2009 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import java.text.Collator;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* An object to convert Chinese character to its corresponding pinyin string. For characters with
|
||||
* multiple possible pinyin string, only one is selected according to collator. Polyphone is not
|
||||
* supported in this implementation. This class is implemented to achieve the best runtime
|
||||
* performance and minimum runtime resources with tolerable sacrifice of accuracy. This
|
||||
* implementation highly depends on zh_CN ICU collation data and must be always synchronized with
|
||||
* ICU.
|
||||
* <p>
|
||||
* Currently this file is aligned to zh.txt in ICU 4.6
|
||||
*/
|
||||
public class HanziToPinyin {
|
||||
private static final String TAG = "HanziToPinyin";
|
||||
|
||||
// Turn on this flag when we want to check internal data structure.
|
||||
private static final boolean DEBUG = false;
|
||||
|
||||
/**
|
||||
* Unihans array.
|
||||
* <p>
|
||||
* Each unihans is the first one within same pinyin when collator is zh_CN.
|
||||
*/
|
||||
public static final char[] UNIHANS = {
|
||||
'\u963f', '\u54ce', '\u5b89', '\u80ae', '\u51f9', '\u516b',
|
||||
'\u6300', '\u6273', '\u90a6', '\u52f9', '\u9642', '\u5954',
|
||||
'\u4f3b', '\u5c44', '\u8fb9', '\u706c', '\u618b', '\u6c43',
|
||||
'\u51ab', '\u7676', '\u5cec', '\u5693', '\u5072', '\u53c2',
|
||||
'\u4ed3', '\u64a1', '\u518a', '\u5d7e', '\u66fd', '\u66fe',
|
||||
'\u5c64', '\u53c9', '\u8286', '\u8fbf', '\u4f25', '\u6284',
|
||||
'\u8f66', '\u62bb', '\u6c88', '\u6c89', '\u9637', '\u5403',
|
||||
'\u5145', '\u62bd', '\u51fa', '\u6b3b', '\u63e3', '\u5ddb',
|
||||
'\u5205', '\u5439', '\u65fe', '\u9034', '\u5472', '\u5306',
|
||||
'\u51d1', '\u7c97', '\u6c46', '\u5d14', '\u90a8', '\u6413',
|
||||
'\u5491', '\u5446', '\u4e39', '\u5f53', '\u5200', '\u561a',
|
||||
'\u6265', '\u706f', '\u6c10', '\u55f2', '\u7538', '\u5201',
|
||||
'\u7239', '\u4e01', '\u4e1f', '\u4e1c', '\u543a', '\u53be',
|
||||
'\u8011', '\u8968', '\u5428', '\u591a', '\u59b8', '\u8bf6',
|
||||
'\u5940', '\u97a5', '\u513f', '\u53d1', '\u5e06', '\u531a',
|
||||
'\u98de', '\u5206', '\u4e30', '\u8985', '\u4ecf', '\u7d11',
|
||||
'\u4f15', '\u65ee', '\u4f85', '\u7518', '\u5188', '\u768b',
|
||||
'\u6208', '\u7ed9', '\u6839', '\u522f', '\u5de5', '\u52fe',
|
||||
'\u4f30', '\u74dc', '\u4e56', '\u5173', '\u5149', '\u5f52',
|
||||
'\u4e28', '\u5459', '\u54c8', '\u548d', '\u4f44', '\u592f',
|
||||
'\u8320', '\u8bc3', '\u9ed2', '\u62eb', '\u4ea8', '\u5677',
|
||||
'\u53ff', '\u9f41', '\u4e6f', '\u82b1', '\u6000', '\u72bf',
|
||||
'\u5ddf', '\u7070', '\u660f', '\u5419', '\u4e0c', '\u52a0',
|
||||
'\u620b', '\u6c5f', '\u827d', '\u9636', '\u5dfe', '\u5755',
|
||||
'\u5182', '\u4e29', '\u51e5', '\u59e2', '\u5658', '\u519b',
|
||||
'\u5494', '\u5f00', '\u520a', '\u5ffc', '\u5c3b', '\u533c',
|
||||
'\u808e', '\u52a5', '\u7a7a', '\u62a0', '\u625d', '\u5938',
|
||||
'\u84af', '\u5bbd', '\u5321', '\u4e8f', '\u5764', '\u6269',
|
||||
'\u5783', '\u6765', '\u5170', '\u5577', '\u635e', '\u808b',
|
||||
'\u52d2', '\u5d1a', '\u5215', '\u4fe9', '\u5941', '\u826f',
|
||||
'\u64a9', '\u5217', '\u62ce', '\u5222', '\u6e9c', '\u56d6',
|
||||
'\u9f99', '\u779c', '\u565c', '\u5a08', '\u7567', '\u62a1',
|
||||
'\u7f57', '\u5463', '\u5988', '\u57cb', '\u5ada', '\u7264',
|
||||
'\u732b', '\u4e48', '\u5445', '\u95e8', '\u753f', '\u54aa',
|
||||
'\u5b80', '\u55b5', '\u4e5c', '\u6c11', '\u540d', '\u8c2c',
|
||||
'\u6478', '\u54de', '\u6bea', '\u55ef', '\u62cf', '\u8149',
|
||||
'\u56e1', '\u56d4', '\u5b6c', '\u7592', '\u5a1e', '\u6041',
|
||||
'\u80fd', '\u59ae', '\u62c8', '\u5b22', '\u9e1f', '\u634f',
|
||||
'\u56dc', '\u5b81', '\u599e', '\u519c', '\u7fba', '\u5974',
|
||||
'\u597b', '\u759f', '\u9ec1', '\u90cd', '\u5594', '\u8bb4',
|
||||
'\u5991', '\u62cd', '\u7705', '\u4e53', '\u629b', '\u5478',
|
||||
'\u55b7', '\u5309', '\u4e15', '\u56e8', '\u527d', '\u6c15',
|
||||
'\u59d8', '\u4e52', '\u948b', '\u5256', '\u4ec6', '\u4e03',
|
||||
'\u6390', '\u5343', '\u545b', '\u6084', '\u767f', '\u4eb2',
|
||||
'\u72c5', '\u828e', '\u4e18', '\u533a', '\u5cd1', '\u7f3a',
|
||||
'\u590b', '\u5465', '\u7a63', '\u5a06', '\u60f9', '\u4eba',
|
||||
'\u6254', '\u65e5', '\u8338', '\u53b9', '\u909a', '\u633c',
|
||||
'\u5827', '\u5a51', '\u77a4', '\u637c', '\u4ee8', '\u6be2',
|
||||
'\u4e09', '\u6852', '\u63bb', '\u95aa', '\u68ee', '\u50e7',
|
||||
'\u6740', '\u7b5b', '\u5c71', '\u4f24', '\u5f30', '\u5962',
|
||||
'\u7533', '\u8398', '\u6552', '\u5347', '\u5c38', '\u53ce',
|
||||
'\u4e66', '\u5237', '\u8870', '\u95e9', '\u53cc', '\u8c01',
|
||||
'\u542e', '\u8bf4', '\u53b6', '\u5fea', '\u635c', '\u82cf',
|
||||
'\u72fb', '\u590a', '\u5b59', '\u5506', '\u4ed6', '\u56fc',
|
||||
'\u574d', '\u6c64', '\u5932', '\u5fd1', '\u71a5', '\u5254',
|
||||
'\u5929', '\u65eb', '\u5e16', '\u5385', '\u56f2', '\u5077',
|
||||
'\u51f8', '\u6e4d', '\u63a8', '\u541e', '\u4e47', '\u7a75',
|
||||
'\u6b6a', '\u5f2f', '\u5c23', '\u5371', '\u6637', '\u7fc1',
|
||||
'\u631d', '\u4e4c', '\u5915', '\u8672', '\u4eda', '\u4e61',
|
||||
'\u7071', '\u4e9b', '\u5fc3', '\u661f', '\u51f6', '\u4f11',
|
||||
'\u5401', '\u5405', '\u524a', '\u5743', '\u4e2b', '\u6079',
|
||||
'\u592e', '\u5e7a', '\u503b', '\u4e00', '\u56d9', '\u5e94',
|
||||
'\u54df', '\u4f63', '\u4f18', '\u625c', '\u56e6', '\u66f0',
|
||||
'\u6655', '\u7b60', '\u7b7c', '\u5e00', '\u707d', '\u5142',
|
||||
'\u5328', '\u50ae', '\u5219', '\u8d3c', '\u600e', '\u5897',
|
||||
'\u624e', '\u635a', '\u6cbe', '\u5f20', '\u957f', '\u9577',
|
||||
'\u4f4b', '\u8707', '\u8d1e', '\u4e89', '\u4e4b', '\u5cd9',
|
||||
'\u5ea2', '\u4e2d', '\u5dde', '\u6731', '\u6293', '\u62fd',
|
||||
'\u4e13', '\u5986', '\u96b9', '\u5b92', '\u5353', '\u4e72',
|
||||
'\u5b97', '\u90b9', '\u79df', '\u94bb', '\u539c', '\u5c0a',
|
||||
'\u6628', '\u5159', '\u9fc3', '\u9fc4',};
|
||||
|
||||
/**
|
||||
* Pinyin array.
|
||||
* <p>
|
||||
* Each pinyin is corresponding to unihans of same
|
||||
* offset in the unihans array.
|
||||
*/
|
||||
public static final byte[][] PINYINS = {
|
||||
{65, 0, 0, 0, 0, 0}, {65, 73, 0, 0, 0, 0},
|
||||
{65, 78, 0, 0, 0, 0}, {65, 78, 71, 0, 0, 0},
|
||||
{65, 79, 0, 0, 0, 0}, {66, 65, 0, 0, 0, 0},
|
||||
{66, 65, 73, 0, 0, 0}, {66, 65, 78, 0, 0, 0},
|
||||
{66, 65, 78, 71, 0, 0}, {66, 65, 79, 0, 0, 0},
|
||||
{66, 69, 73, 0, 0, 0}, {66, 69, 78, 0, 0, 0},
|
||||
{66, 69, 78, 71, 0, 0}, {66, 73, 0, 0, 0, 0},
|
||||
{66, 73, 65, 78, 0, 0}, {66, 73, 65, 79, 0, 0},
|
||||
{66, 73, 69, 0, 0, 0}, {66, 73, 78, 0, 0, 0},
|
||||
{66, 73, 78, 71, 0, 0}, {66, 79, 0, 0, 0, 0},
|
||||
{66, 85, 0, 0, 0, 0}, {67, 65, 0, 0, 0, 0},
|
||||
{67, 65, 73, 0, 0, 0}, {67, 65, 78, 0, 0, 0},
|
||||
{67, 65, 78, 71, 0, 0}, {67, 65, 79, 0, 0, 0},
|
||||
{67, 69, 0, 0, 0, 0}, {67, 69, 78, 0, 0, 0},
|
||||
{67, 69, 78, 71, 0, 0}, {90, 69, 78, 71, 0, 0},
|
||||
{67, 69, 78, 71, 0, 0}, {67, 72, 65, 0, 0, 0},
|
||||
{67, 72, 65, 73, 0, 0}, {67, 72, 65, 78, 0, 0},
|
||||
{67, 72, 65, 78, 71, 0}, {67, 72, 65, 79, 0, 0},
|
||||
{67, 72, 69, 0, 0, 0}, {67, 72, 69, 78, 0, 0},
|
||||
{83, 72, 69, 78, 0, 0}, {67, 72, 69, 78, 0, 0},
|
||||
{67, 72, 69, 78, 71, 0}, {67, 72, 73, 0, 0, 0},
|
||||
{67, 72, 79, 78, 71, 0}, {67, 72, 79, 85, 0, 0},
|
||||
{67, 72, 85, 0, 0, 0}, {67, 72, 85, 65, 0, 0},
|
||||
{67, 72, 85, 65, 73, 0}, {67, 72, 85, 65, 78, 0},
|
||||
{67, 72, 85, 65, 78, 71}, {67, 72, 85, 73, 0, 0},
|
||||
{67, 72, 85, 78, 0, 0}, {67, 72, 85, 79, 0, 0},
|
||||
{67, 73, 0, 0, 0, 0}, {67, 79, 78, 71, 0, 0},
|
||||
{67, 79, 85, 0, 0, 0}, {67, 85, 0, 0, 0, 0},
|
||||
{67, 85, 65, 78, 0, 0}, {67, 85, 73, 0, 0, 0},
|
||||
{67, 85, 78, 0, 0, 0}, {67, 85, 79, 0, 0, 0},
|
||||
{68, 65, 0, 0, 0, 0}, {68, 65, 73, 0, 0, 0},
|
||||
{68, 65, 78, 0, 0, 0}, {68, 65, 78, 71, 0, 0},
|
||||
{68, 65, 79, 0, 0, 0}, {68, 69, 0, 0, 0, 0},
|
||||
{68, 69, 78, 0, 0, 0}, {68, 69, 78, 71, 0, 0},
|
||||
{68, 73, 0, 0, 0, 0}, {68, 73, 65, 0, 0, 0},
|
||||
{68, 73, 65, 78, 0, 0}, {68, 73, 65, 79, 0, 0},
|
||||
{68, 73, 69, 0, 0, 0}, {68, 73, 78, 71, 0, 0},
|
||||
{68, 73, 85, 0, 0, 0}, {68, 79, 78, 71, 0, 0},
|
||||
{68, 79, 85, 0, 0, 0}, {68, 85, 0, 0, 0, 0},
|
||||
{68, 85, 65, 78, 0, 0}, {68, 85, 73, 0, 0, 0},
|
||||
{68, 85, 78, 0, 0, 0}, {68, 85, 79, 0, 0, 0},
|
||||
{69, 0, 0, 0, 0, 0}, {69, 73, 0, 0, 0, 0},
|
||||
{69, 78, 0, 0, 0, 0}, {69, 78, 71, 0, 0, 0},
|
||||
{69, 82, 0, 0, 0, 0}, {70, 65, 0, 0, 0, 0},
|
||||
{70, 65, 78, 0, 0, 0}, {70, 65, 78, 71, 0, 0},
|
||||
{70, 69, 73, 0, 0, 0}, {70, 69, 78, 0, 0, 0},
|
||||
{70, 69, 78, 71, 0, 0}, {70, 73, 65, 79, 0, 0},
|
||||
{70, 79, 0, 0, 0, 0}, {70, 79, 85, 0, 0, 0},
|
||||
{70, 85, 0, 0, 0, 0}, {71, 65, 0, 0, 0, 0},
|
||||
{71, 65, 73, 0, 0, 0}, {71, 65, 78, 0, 0, 0},
|
||||
{71, 65, 78, 71, 0, 0}, {71, 65, 79, 0, 0, 0},
|
||||
{71, 69, 0, 0, 0, 0}, {71, 69, 73, 0, 0, 0},
|
||||
{71, 69, 78, 0, 0, 0}, {71, 69, 78, 71, 0, 0},
|
||||
{71, 79, 78, 71, 0, 0}, {71, 79, 85, 0, 0, 0},
|
||||
{71, 85, 0, 0, 0, 0}, {71, 85, 65, 0, 0, 0},
|
||||
{71, 85, 65, 73, 0, 0}, {71, 85, 65, 78, 0, 0},
|
||||
{71, 85, 65, 78, 71, 0}, {71, 85, 73, 0, 0, 0},
|
||||
{71, 85, 78, 0, 0, 0}, {71, 85, 79, 0, 0, 0},
|
||||
{72, 65, 0, 0, 0, 0}, {72, 65, 73, 0, 0, 0},
|
||||
{72, 65, 78, 0, 0, 0}, {72, 65, 78, 71, 0, 0},
|
||||
{72, 65, 79, 0, 0, 0}, {72, 69, 0, 0, 0, 0},
|
||||
{72, 69, 73, 0, 0, 0}, {72, 69, 78, 0, 0, 0},
|
||||
{72, 69, 78, 71, 0, 0}, {72, 77, 0, 0, 0, 0},
|
||||
{72, 79, 78, 71, 0, 0}, {72, 79, 85, 0, 0, 0},
|
||||
{72, 85, 0, 0, 0, 0}, {72, 85, 65, 0, 0, 0},
|
||||
{72, 85, 65, 73, 0, 0}, {72, 85, 65, 78, 0, 0},
|
||||
{72, 85, 65, 78, 71, 0}, {72, 85, 73, 0, 0, 0},
|
||||
{72, 85, 78, 0, 0, 0}, {72, 85, 79, 0, 0, 0},
|
||||
{74, 73, 0, 0, 0, 0}, {74, 73, 65, 0, 0, 0},
|
||||
{74, 73, 65, 78, 0, 0}, {74, 73, 65, 78, 71, 0},
|
||||
{74, 73, 65, 79, 0, 0}, {74, 73, 69, 0, 0, 0},
|
||||
{74, 73, 78, 0, 0, 0}, {74, 73, 78, 71, 0, 0},
|
||||
{74, 73, 79, 78, 71, 0}, {74, 73, 85, 0, 0, 0},
|
||||
{74, 85, 0, 0, 0, 0}, {74, 85, 65, 78, 0, 0},
|
||||
{74, 85, 69, 0, 0, 0}, {74, 85, 78, 0, 0, 0},
|
||||
{75, 65, 0, 0, 0, 0}, {75, 65, 73, 0, 0, 0},
|
||||
{75, 65, 78, 0, 0, 0}, {75, 65, 78, 71, 0, 0},
|
||||
{75, 65, 79, 0, 0, 0}, {75, 69, 0, 0, 0, 0},
|
||||
{75, 69, 78, 0, 0, 0}, {75, 69, 78, 71, 0, 0},
|
||||
{75, 79, 78, 71, 0, 0}, {75, 79, 85, 0, 0, 0},
|
||||
{75, 85, 0, 0, 0, 0}, {75, 85, 65, 0, 0, 0},
|
||||
{75, 85, 65, 73, 0, 0}, {75, 85, 65, 78, 0, 0},
|
||||
{75, 85, 65, 78, 71, 0}, {75, 85, 73, 0, 0, 0},
|
||||
{75, 85, 78, 0, 0, 0}, {75, 85, 79, 0, 0, 0},
|
||||
{76, 65, 0, 0, 0, 0}, {76, 65, 73, 0, 0, 0},
|
||||
{76, 65, 78, 0, 0, 0}, {76, 65, 78, 71, 0, 0},
|
||||
{76, 65, 79, 0, 0, 0}, {76, 69, 0, 0, 0, 0},
|
||||
{76, 69, 73, 0, 0, 0}, {76, 69, 78, 71, 0, 0},
|
||||
{76, 73, 0, 0, 0, 0}, {76, 73, 65, 0, 0, 0},
|
||||
{76, 73, 65, 78, 0, 0}, {76, 73, 65, 78, 71, 0},
|
||||
{76, 73, 65, 79, 0, 0}, {76, 73, 69, 0, 0, 0},
|
||||
{76, 73, 78, 0, 0, 0}, {76, 73, 78, 71, 0, 0},
|
||||
{76, 73, 85, 0, 0, 0}, {76, 79, 0, 0, 0, 0},
|
||||
{76, 79, 78, 71, 0, 0}, {76, 79, 85, 0, 0, 0},
|
||||
{76, 85, 0, 0, 0, 0}, {76, 85, 65, 78, 0, 0},
|
||||
{76, 85, 69, 0, 0, 0}, {76, 85, 78, 0, 0, 0},
|
||||
{76, 85, 79, 0, 0, 0}, {77, 0, 0, 0, 0, 0},
|
||||
{77, 65, 0, 0, 0, 0}, {77, 65, 73, 0, 0, 0},
|
||||
{77, 65, 78, 0, 0, 0}, {77, 65, 78, 71, 0, 0},
|
||||
{77, 65, 79, 0, 0, 0}, {77, 69, 0, 0, 0, 0},
|
||||
{77, 69, 73, 0, 0, 0}, {77, 69, 78, 0, 0, 0},
|
||||
{77, 69, 78, 71, 0, 0}, {77, 73, 0, 0, 0, 0},
|
||||
{77, 73, 65, 78, 0, 0}, {77, 73, 65, 79, 0, 0},
|
||||
{77, 73, 69, 0, 0, 0}, {77, 73, 78, 0, 0, 0},
|
||||
{77, 73, 78, 71, 0, 0}, {77, 73, 85, 0, 0, 0},
|
||||
{77, 79, 0, 0, 0, 0}, {77, 79, 85, 0, 0, 0},
|
||||
{77, 85, 0, 0, 0, 0}, {78, 0, 0, 0, 0, 0},
|
||||
{78, 65, 0, 0, 0, 0}, {78, 65, 73, 0, 0, 0},
|
||||
{78, 65, 78, 0, 0, 0}, {78, 65, 78, 71, 0, 0},
|
||||
{78, 65, 79, 0, 0, 0}, {78, 69, 0, 0, 0, 0},
|
||||
{78, 69, 73, 0, 0, 0}, {78, 69, 78, 0, 0, 0},
|
||||
{78, 69, 78, 71, 0, 0}, {78, 73, 0, 0, 0, 0},
|
||||
{78, 73, 65, 78, 0, 0}, {78, 73, 65, 78, 71, 0},
|
||||
{78, 73, 65, 79, 0, 0}, {78, 73, 69, 0, 0, 0},
|
||||
{78, 73, 78, 0, 0, 0}, {78, 73, 78, 71, 0, 0},
|
||||
{78, 73, 85, 0, 0, 0}, {78, 79, 78, 71, 0, 0},
|
||||
{78, 79, 85, 0, 0, 0}, {78, 85, 0, 0, 0, 0},
|
||||
{78, 85, 65, 78, 0, 0}, {78, 85, 69, 0, 0, 0},
|
||||
{78, 85, 78, 0, 0, 0}, {78, 85, 79, 0, 0, 0},
|
||||
{79, 0, 0, 0, 0, 0}, {79, 85, 0, 0, 0, 0},
|
||||
{80, 65, 0, 0, 0, 0}, {80, 65, 73, 0, 0, 0},
|
||||
{80, 65, 78, 0, 0, 0}, {80, 65, 78, 71, 0, 0},
|
||||
{80, 65, 79, 0, 0, 0}, {80, 69, 73, 0, 0, 0},
|
||||
{80, 69, 78, 0, 0, 0}, {80, 69, 78, 71, 0, 0},
|
||||
{80, 73, 0, 0, 0, 0}, {80, 73, 65, 78, 0, 0},
|
||||
{80, 73, 65, 79, 0, 0}, {80, 73, 69, 0, 0, 0},
|
||||
{80, 73, 78, 0, 0, 0}, {80, 73, 78, 71, 0, 0},
|
||||
{80, 79, 0, 0, 0, 0}, {80, 79, 85, 0, 0, 0},
|
||||
{80, 85, 0, 0, 0, 0}, {81, 73, 0, 0, 0, 0},
|
||||
{81, 73, 65, 0, 0, 0}, {81, 73, 65, 78, 0, 0},
|
||||
{81, 73, 65, 78, 71, 0}, {81, 73, 65, 79, 0, 0},
|
||||
{81, 73, 69, 0, 0, 0}, {81, 73, 78, 0, 0, 0},
|
||||
{81, 73, 78, 71, 0, 0}, {81, 73, 79, 78, 71, 0},
|
||||
{81, 73, 85, 0, 0, 0}, {81, 85, 0, 0, 0, 0},
|
||||
{81, 85, 65, 78, 0, 0}, {81, 85, 69, 0, 0, 0},
|
||||
{81, 85, 78, 0, 0, 0}, {82, 65, 78, 0, 0, 0},
|
||||
{82, 65, 78, 71, 0, 0}, {82, 65, 79, 0, 0, 0},
|
||||
{82, 69, 0, 0, 0, 0}, {82, 69, 78, 0, 0, 0},
|
||||
{82, 69, 78, 71, 0, 0}, {82, 73, 0, 0, 0, 0},
|
||||
{82, 79, 78, 71, 0, 0}, {82, 79, 85, 0, 0, 0},
|
||||
{82, 85, 0, 0, 0, 0}, {82, 85, 65, 0, 0, 0},
|
||||
{82, 85, 65, 78, 0, 0}, {82, 85, 73, 0, 0, 0},
|
||||
{82, 85, 78, 0, 0, 0}, {82, 85, 79, 0, 0, 0},
|
||||
{83, 65, 0, 0, 0, 0}, {83, 65, 73, 0, 0, 0},
|
||||
{83, 65, 78, 0, 0, 0}, {83, 65, 78, 71, 0, 0},
|
||||
{83, 65, 79, 0, 0, 0}, {83, 69, 0, 0, 0, 0},
|
||||
{83, 69, 78, 0, 0, 0}, {83, 69, 78, 71, 0, 0},
|
||||
{83, 72, 65, 0, 0, 0}, {83, 72, 65, 73, 0, 0},
|
||||
{83, 72, 65, 78, 0, 0}, {83, 72, 65, 78, 71, 0},
|
||||
{83, 72, 65, 79, 0, 0}, {83, 72, 69, 0, 0, 0},
|
||||
{83, 72, 69, 78, 0, 0}, {88, 73, 78, 0, 0, 0},
|
||||
{83, 72, 69, 78, 0, 0}, {83, 72, 69, 78, 71, 0},
|
||||
{83, 72, 73, 0, 0, 0}, {83, 72, 79, 85, 0, 0},
|
||||
{83, 72, 85, 0, 0, 0}, {83, 72, 85, 65, 0, 0},
|
||||
{83, 72, 85, 65, 73, 0}, {83, 72, 85, 65, 78, 0},
|
||||
{83, 72, 85, 65, 78, 71}, {83, 72, 85, 73, 0, 0},
|
||||
{83, 72, 85, 78, 0, 0}, {83, 72, 85, 79, 0, 0},
|
||||
{83, 73, 0, 0, 0, 0}, {83, 79, 78, 71, 0, 0},
|
||||
{83, 79, 85, 0, 0, 0}, {83, 85, 0, 0, 0, 0},
|
||||
{83, 85, 65, 78, 0, 0}, {83, 85, 73, 0, 0, 0},
|
||||
{83, 85, 78, 0, 0, 0}, {83, 85, 79, 0, 0, 0},
|
||||
{84, 65, 0, 0, 0, 0}, {84, 65, 73, 0, 0, 0},
|
||||
{84, 65, 78, 0, 0, 0}, {84, 65, 78, 71, 0, 0},
|
||||
{84, 65, 79, 0, 0, 0}, {84, 69, 0, 0, 0, 0},
|
||||
{84, 69, 78, 71, 0, 0}, {84, 73, 0, 0, 0, 0},
|
||||
{84, 73, 65, 78, 0, 0}, {84, 73, 65, 79, 0, 0},
|
||||
{84, 73, 69, 0, 0, 0}, {84, 73, 78, 71, 0, 0},
|
||||
{84, 79, 78, 71, 0, 0}, {84, 79, 85, 0, 0, 0},
|
||||
{84, 85, 0, 0, 0, 0}, {84, 85, 65, 78, 0, 0},
|
||||
{84, 85, 73, 0, 0, 0}, {84, 85, 78, 0, 0, 0},
|
||||
{84, 85, 79, 0, 0, 0}, {87, 65, 0, 0, 0, 0},
|
||||
{87, 65, 73, 0, 0, 0}, {87, 65, 78, 0, 0, 0},
|
||||
{87, 65, 78, 71, 0, 0}, {87, 69, 73, 0, 0, 0},
|
||||
{87, 69, 78, 0, 0, 0}, {87, 69, 78, 71, 0, 0},
|
||||
{87, 79, 0, 0, 0, 0}, {87, 85, 0, 0, 0, 0},
|
||||
{88, 73, 0, 0, 0, 0}, {88, 73, 65, 0, 0, 0},
|
||||
{88, 73, 65, 78, 0, 0}, {88, 73, 65, 78, 71, 0},
|
||||
{88, 73, 65, 79, 0, 0}, {88, 73, 69, 0, 0, 0},
|
||||
{88, 73, 78, 0, 0, 0}, {88, 73, 78, 71, 0, 0},
|
||||
{88, 73, 79, 78, 71, 0}, {88, 73, 85, 0, 0, 0},
|
||||
{88, 85, 0, 0, 0, 0}, {88, 85, 65, 78, 0, 0},
|
||||
{88, 85, 69, 0, 0, 0}, {88, 85, 78, 0, 0, 0},
|
||||
{89, 65, 0, 0, 0, 0}, {89, 65, 78, 0, 0, 0},
|
||||
{89, 65, 78, 71, 0, 0}, {89, 65, 79, 0, 0, 0},
|
||||
{89, 69, 0, 0, 0, 0}, {89, 73, 0, 0, 0, 0},
|
||||
{89, 73, 78, 0, 0, 0}, {89, 73, 78, 71, 0, 0},
|
||||
{89, 79, 0, 0, 0, 0}, {89, 79, 78, 71, 0, 0},
|
||||
{89, 79, 85, 0, 0, 0}, {89, 85, 0, 0, 0, 0},
|
||||
{89, 85, 65, 78, 0, 0}, {89, 85, 69, 0, 0, 0},
|
||||
{89, 85, 78, 0, 0, 0}, {74, 85, 78, 0, 0, 0},
|
||||
{89, 85, 78, 0, 0, 0}, {90, 65, 0, 0, 0, 0},
|
||||
{90, 65, 73, 0, 0, 0}, {90, 65, 78, 0, 0, 0},
|
||||
{90, 65, 78, 71, 0, 0}, {90, 65, 79, 0, 0, 0},
|
||||
{90, 69, 0, 0, 0, 0}, {90, 69, 73, 0, 0, 0},
|
||||
{90, 69, 78, 0, 0, 0}, {90, 69, 78, 71, 0, 0},
|
||||
{90, 72, 65, 0, 0, 0}, {90, 72, 65, 73, 0, 0},
|
||||
{90, 72, 65, 78, 0, 0}, {90, 72, 65, 78, 71, 0},
|
||||
{67, 72, 65, 78, 71, 0}, {90, 72, 65, 78, 71, 0},
|
||||
{90, 72, 65, 79, 0, 0}, {90, 72, 69, 0, 0, 0},
|
||||
{90, 72, 69, 78, 0, 0}, {90, 72, 69, 78, 71, 0},
|
||||
{90, 72, 73, 0, 0, 0}, {83, 72, 73, 0, 0, 0},
|
||||
{90, 72, 73, 0, 0, 0}, {90, 72, 79, 78, 71, 0},
|
||||
{90, 72, 79, 85, 0, 0}, {90, 72, 85, 0, 0, 0},
|
||||
{90, 72, 85, 65, 0, 0}, {90, 72, 85, 65, 73, 0},
|
||||
{90, 72, 85, 65, 78, 0}, {90, 72, 85, 65, 78, 71},
|
||||
{90, 72, 85, 73, 0, 0}, {90, 72, 85, 78, 0, 0},
|
||||
{90, 72, 85, 79, 0, 0}, {90, 73, 0, 0, 0, 0},
|
||||
{90, 79, 78, 71, 0, 0}, {90, 79, 85, 0, 0, 0},
|
||||
{90, 85, 0, 0, 0, 0}, {90, 85, 65, 78, 0, 0},
|
||||
{90, 85, 73, 0, 0, 0}, {90, 85, 78, 0, 0, 0},
|
||||
{90, 85, 79, 0, 0, 0}, {0, 0, 0, 0, 0, 0},
|
||||
{83, 72, 65, 78, 0, 0}, {0, 0, 0, 0, 0, 0},};
|
||||
|
||||
/**
|
||||
* First and last Chinese character with known Pinyin according to zh collation
|
||||
*/
|
||||
private static final String FIRST_PINYIN_UNIHAN = "\u963F";
|
||||
private static final String LAST_PINYIN_UNIHAN = "\u9FFF";
|
||||
|
||||
private static final Collator COLLATOR = Collator.getInstance(Locale.CHINA);
|
||||
|
||||
private static HanziToPinyin sInstance;
|
||||
private final boolean mHasChinaCollator;
|
||||
|
||||
public static class Token {
|
||||
/**
|
||||
* Separator between target string for each source char
|
||||
*/
|
||||
public static final String SEPARATOR = " ";
|
||||
|
||||
public static final int LATIN = 1;
|
||||
public static final int PINYIN = 2;
|
||||
public static final int UNKNOWN = 3;
|
||||
|
||||
public Token() {
|
||||
}
|
||||
|
||||
public Token(int type, String source, String target) {
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of this token, ASCII, PINYIN or UNKNOWN.
|
||||
*/
|
||||
public int type;
|
||||
/**
|
||||
* Original string before translation.
|
||||
*/
|
||||
public String source;
|
||||
/**
|
||||
* Translated string of source. For Han, target is corresponding Pinyin. Otherwise target is
|
||||
* original string in source.
|
||||
*/
|
||||
public String target;
|
||||
}
|
||||
|
||||
protected HanziToPinyin(boolean hasChinaCollator) {
|
||||
mHasChinaCollator = hasChinaCollator;
|
||||
}
|
||||
|
||||
public static HanziToPinyin getInstance() {
|
||||
synchronized (HanziToPinyin.class) {
|
||||
if (sInstance != null) {
|
||||
return sInstance;
|
||||
}
|
||||
// Check if zh_CN collation data is available
|
||||
final Locale[] locale = Collator.getAvailableLocales();
|
||||
for (Locale value : locale) {
|
||||
if (value.equals(Locale.CHINA) || value.getLanguage().contains("zh")) {
|
||||
// Do self validation just once.
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Self validation. Result: " + doSelfValidation());
|
||||
}
|
||||
sInstance = new HanziToPinyin(true);
|
||||
return sInstance;
|
||||
}
|
||||
}
|
||||
if (sInstance == null){//这个判断是用于处理国产ROM的兼容性问题
|
||||
if (Locale.CHINA.equals(Locale.getDefault())){
|
||||
sInstance = new HanziToPinyin(true);
|
||||
return sInstance;
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "There is no Chinese collator, HanziToPinyin is disabled");
|
||||
sInstance = new HanziToPinyin(false);
|
||||
return sInstance;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if our internal table has some wrong value.
|
||||
*
|
||||
* @return true when the table looks correct.
|
||||
*/
|
||||
private static boolean doSelfValidation() {
|
||||
char lastChar = UNIHANS[0];
|
||||
String lastString = Character.toString(lastChar);
|
||||
for (char c : UNIHANS) {
|
||||
if (lastChar == c) {
|
||||
continue;
|
||||
}
|
||||
final String curString = Character.toString(c);
|
||||
int cmp = COLLATOR.compare(lastString, curString);
|
||||
if (cmp >= 0) {
|
||||
Log.e(TAG, "Internal error in Unihan table. " + "The last string \"" + lastString
|
||||
+ "\" is greater than current string \"" + curString + "\".");
|
||||
return false;
|
||||
}
|
||||
lastString = curString;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private Token getToken(char character) {
|
||||
Token token = new Token();
|
||||
final String letter = Character.toString(character);
|
||||
token.source = letter;
|
||||
int offset = -1;
|
||||
int cmp;
|
||||
if (character < 256) {
|
||||
token.type = Token.LATIN;
|
||||
token.target = letter;
|
||||
return token;
|
||||
} else {
|
||||
cmp = COLLATOR.compare(letter, FIRST_PINYIN_UNIHAN);
|
||||
if (cmp < 0) {
|
||||
token.type = Token.UNKNOWN;
|
||||
token.target = letter;
|
||||
return token;
|
||||
} else if (cmp == 0) {
|
||||
token.type = Token.PINYIN;
|
||||
offset = 0;
|
||||
} else {
|
||||
cmp = COLLATOR.compare(letter, LAST_PINYIN_UNIHAN);
|
||||
if (cmp > 0) {
|
||||
token.type = Token.UNKNOWN;
|
||||
token.target = letter;
|
||||
return token;
|
||||
} else if (cmp == 0) {
|
||||
token.type = Token.PINYIN;
|
||||
offset = UNIHANS.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
token.type = Token.PINYIN;
|
||||
if (offset < 0) {
|
||||
int begin = 0;
|
||||
int end = UNIHANS.length - 1;
|
||||
while (begin <= end) {
|
||||
offset = (begin + end) / 2;
|
||||
final String unihan = Character.toString(UNIHANS[offset]);
|
||||
cmp = COLLATOR.compare(letter, unihan);
|
||||
if (cmp == 0) {
|
||||
break;
|
||||
} else if (cmp > 0) {
|
||||
begin = offset + 1;
|
||||
} else {
|
||||
end = offset - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cmp < 0) {
|
||||
offset--;
|
||||
}
|
||||
StringBuilder pinyin = new StringBuilder();
|
||||
for (int j = 0; j < PINYINS[offset].length && PINYINS[offset][j] != 0; j++) {
|
||||
pinyin.append((char) PINYINS[offset][j]);
|
||||
}
|
||||
token.target = pinyin.toString();
|
||||
if (TextUtils.isEmpty(token.target)) {
|
||||
token.type = Token.UNKNOWN;
|
||||
token.target = token.source;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the input to a array of tokens. The sequence of ASCII or Unknown characters without
|
||||
* space will be put into a Token, One Hanzi character which has pinyin will be treated as a
|
||||
* Token. If these is no China collator, the empty token array is returned.
|
||||
*/
|
||||
public ArrayList<Token> get(final String input) {
|
||||
ArrayList<Token> tokens = new ArrayList<>();
|
||||
if (!mHasChinaCollator || TextUtils.isEmpty(input)) {
|
||||
// return empty tokens.
|
||||
return tokens;
|
||||
}
|
||||
final int inputLength = input.length();
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
int tokenType = Token.LATIN;
|
||||
// Go through the input, create a new token when
|
||||
// a. Token type changed
|
||||
// b. Get the Pinyin of current charater.
|
||||
// c. current character is space.
|
||||
for (int i = 0; i < inputLength; i++) {
|
||||
final char character = input.charAt(i);
|
||||
if (character == ' ') {
|
||||
if (sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
} else if (character < 256) {
|
||||
if (tokenType != Token.LATIN && sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
tokenType = Token.LATIN;
|
||||
sb.append(character);
|
||||
} else {
|
||||
Token t = getToken(character);
|
||||
if (t.type == Token.PINYIN) {
|
||||
if (sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
tokens.add(t);
|
||||
tokenType = Token.PINYIN;
|
||||
} else {
|
||||
if (tokenType != t.type && sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
tokenType = t.type;
|
||||
sb.append(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private void addToken(
|
||||
final StringBuilder sb, final ArrayList<Token> tokens, final int tokenType) {
|
||||
String str = sb.toString();
|
||||
tokens.add(new Token(tokenType, str, str));
|
||||
sb.setLength(0);
|
||||
}
|
||||
|
||||
public String toPinyinString(String string) {
|
||||
if (string == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
ArrayList<Token> tokens = get(string);
|
||||
for (Token token : tokens) {
|
||||
sb.append(token.target);
|
||||
}
|
||||
return sb.toString().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.rifsxd.ksunext.ui.util
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Composable
|
||||
fun LinkifyText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val layoutResult = remember {
|
||||
mutableStateOf<TextLayoutResult?>(null)
|
||||
}
|
||||
val linksList = extractUrls(text)
|
||||
val annotatedString = buildAnnotatedString {
|
||||
append(text)
|
||||
linksList.forEach {
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
start = it.start,
|
||||
end = it.end
|
||||
)
|
||||
addStringAnnotation(
|
||||
tag = "URL",
|
||||
annotation = it.url,
|
||||
start = it.start,
|
||||
end = it.end
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotatedString,
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
detectTapGestures { offsetPosition ->
|
||||
layoutResult.value?.let {
|
||||
val position = it.getOffsetForPosition(offsetPosition)
|
||||
annotatedString.getStringAnnotations(position, position).firstOrNull()
|
||||
?.let { result ->
|
||||
if (result.tag == "URL") {
|
||||
uriHandler.openUri(result.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onTextLayout = { layoutResult.value = it }
|
||||
)
|
||||
}
|
||||
|
||||
private val urlPattern: Pattern = Pattern.compile(
|
||||
"(?:^|[\\W])((ht|f)tp(s?):\\/\\/|www\\.)"
|
||||
+ "(([\\w\\-]+\\.){1,}?([\\w\\-.~]+\\/?)*"
|
||||
+ "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]\\*$~@!:/{};']*)",
|
||||
Pattern.CASE_INSENSITIVE or Pattern.MULTILINE or Pattern.DOTALL
|
||||
)
|
||||
|
||||
private data class LinkInfo(
|
||||
val url: String,
|
||||
val start: Int,
|
||||
val end: Int
|
||||
)
|
||||
|
||||
private fun extractUrls(text: String): List<LinkInfo> = buildList {
|
||||
val matcher = urlPattern.matcher(text)
|
||||
while (matcher.find()) {
|
||||
val matchStart = matcher.start(1)
|
||||
val matchEnd = matcher.end()
|
||||
val url = text.substring(matchStart, matchEnd).replaceFirst("http://", "https://")
|
||||
add(LinkInfo(url, matchStart, matchEnd))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package com.rifsxd.ksunext.ui.util
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.Parcelable
|
||||
import android.os.SystemClock
|
||||
import android.provider.OpenableColumns
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.topjohnwu.superuser.CallbackList
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.rifsxd.ksunext.BuildConfig
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.ksuApp
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/1/1.
|
||||
*/
|
||||
private const val TAG = "KsuCli"
|
||||
|
||||
private fun getKsuDaemonPath(): String {
|
||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksud.so"
|
||||
}
|
||||
|
||||
object KsuCli {
|
||||
val SHELL: Shell = createRootShell()
|
||||
val GLOBAL_MNT_SHELL: Shell = createRootShell(true)
|
||||
}
|
||||
|
||||
fun getRootShell(globalMnt: Boolean = false): Shell {
|
||||
return if (globalMnt) KsuCli.GLOBAL_MNT_SHELL else {
|
||||
KsuCli.SHELL
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> withNewRootShell(
|
||||
globalMnt: Boolean = false,
|
||||
block: Shell.() -> T
|
||||
): T {
|
||||
return createRootShell(globalMnt).use(block)
|
||||
}
|
||||
|
||||
fun Uri.getFileName(context: Context): String? {
|
||||
var fileName: String? = null
|
||||
val contentResolver: ContentResolver = context.contentResolver
|
||||
val cursor: Cursor? = contentResolver.query(this, null, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
fileName = it.getString(it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||
}
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
fun createRootShell(globalMnt: Boolean = false): Shell {
|
||||
Shell.enableVerboseLogging = BuildConfig.DEBUG
|
||||
val builder = Shell.Builder.create()
|
||||
return try {
|
||||
if (globalMnt) {
|
||||
builder.build(getKsuDaemonPath(), "debug", "su", "-g")
|
||||
} else {
|
||||
builder.build(getKsuDaemonPath(), "debug", "su")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "ksu failed: ", e)
|
||||
try {
|
||||
if (globalMnt) {
|
||||
builder.build("su")
|
||||
} else {
|
||||
builder.build("su", "-mm")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "su failed: ", e)
|
||||
builder.build("sh")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun execKsud(args: String, newShell: Boolean = false): Boolean {
|
||||
return if (newShell) {
|
||||
withNewRootShell {
|
||||
ShellUtils.fastCmdResult(this, "${getKsuDaemonPath()} $args")
|
||||
}
|
||||
} else {
|
||||
ShellUtils.fastCmdResult(getRootShell(), "${getKsuDaemonPath()} $args")
|
||||
}
|
||||
}
|
||||
|
||||
fun install() {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so").absolutePath
|
||||
val result = execKsud("install --magiskboot $magiskboot", true)
|
||||
Log.w(TAG, "install result: $result, cost: ${SystemClock.elapsedRealtime() - start}ms")
|
||||
}
|
||||
|
||||
fun listModules(): String {
|
||||
val shell = getRootShell()
|
||||
|
||||
val out =
|
||||
shell.newJob().add("${getKsuDaemonPath()} module list").to(ArrayList(), null).exec().out
|
||||
return out.joinToString("\n").ifBlank { "[]" }
|
||||
}
|
||||
|
||||
fun getModuleCount(): Int {
|
||||
val result = listModules()
|
||||
runCatching {
|
||||
val array = JSONArray(result)
|
||||
return array.length()
|
||||
}.getOrElse { return 0 }
|
||||
}
|
||||
|
||||
fun getSuperuserCount(): Int {
|
||||
return Natives.allowList.size
|
||||
}
|
||||
|
||||
fun toggleModule(id: String, enable: Boolean): Boolean {
|
||||
val cmd = if (enable) {
|
||||
"module enable $id"
|
||||
} else {
|
||||
"module disable $id"
|
||||
}
|
||||
val result = execKsud(cmd, true)
|
||||
Log.i(TAG, "$cmd result: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun uninstallModule(id: String): Boolean {
|
||||
val cmd = "module uninstall $id"
|
||||
val result = execKsud(cmd, true)
|
||||
Log.i(TAG, "uninstall module $id result: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
private fun flashWithIO(
|
||||
cmd: String,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
): Shell.Result {
|
||||
|
||||
val stdoutCallback: CallbackList<String?> = object : CallbackList<String?>() {
|
||||
override fun onAddElement(s: String?) {
|
||||
onStdout(s ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
val stderrCallback: CallbackList<String?> = object : CallbackList<String?>() {
|
||||
override fun onAddElement(s: String?) {
|
||||
onStderr(s ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
return withNewRootShell {
|
||||
newJob().add(cmd).to(stdoutCallback, stderrCallback).exec()
|
||||
}
|
||||
}
|
||||
|
||||
fun flashModule(
|
||||
uri: Uri,
|
||||
onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
): Boolean {
|
||||
val resolver = ksuApp.contentResolver
|
||||
with(resolver.openInputStream(uri)) {
|
||||
val file = File(ksuApp.cacheDir, "module.zip")
|
||||
file.outputStream().use { output ->
|
||||
this?.copyTo(output)
|
||||
}
|
||||
val cmd = "module install ${file.absolutePath}"
|
||||
val result = flashWithIO("${getKsuDaemonPath()} $cmd", onStdout, onStderr)
|
||||
Log.i("KernelSU", "install module $uri result: $result")
|
||||
|
||||
file.delete()
|
||||
|
||||
onFinish(result.isSuccess, result.code)
|
||||
return result.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
fun runModuleAction(
|
||||
moduleId: String, onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
||||
): Boolean {
|
||||
val shell = getRootShell()
|
||||
|
||||
val stdoutCallback: CallbackList<String?> = object : CallbackList<String?>() {
|
||||
override fun onAddElement(s: String?) {
|
||||
onStdout(s ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
val stderrCallback: CallbackList<String?> = object : CallbackList<String?>() {
|
||||
override fun onAddElement(s: String?) {
|
||||
onStderr(s ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
val result = shell.newJob().add("${getKsuDaemonPath()} module action $moduleId")
|
||||
.to(stdoutCallback, stderrCallback).exec()
|
||||
Log.i("KernelSU", "Module runAction result: $result")
|
||||
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
fun restoreBoot(
|
||||
onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
||||
): Boolean {
|
||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so")
|
||||
val result = flashWithIO("${getKsuDaemonPath()} boot-restore -f --magiskboot $magiskboot", onStdout, onStderr)
|
||||
onFinish(result.isSuccess, result.code)
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
fun uninstallPermanently(
|
||||
onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
||||
): Boolean {
|
||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so")
|
||||
val result = flashWithIO("${getKsuDaemonPath()} uninstall --magiskboot $magiskboot", onStdout, onStderr)
|
||||
onFinish(result.isSuccess, result.code)
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed class LkmSelection : Parcelable {
|
||||
data class LkmUri(val uri: Uri) : LkmSelection()
|
||||
data class KmiString(val value: String) : LkmSelection()
|
||||
data object KmiNone : LkmSelection()
|
||||
}
|
||||
|
||||
fun installBoot(
|
||||
bootUri: Uri?,
|
||||
lkm: LkmSelection,
|
||||
ota: Boolean,
|
||||
onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit,
|
||||
): Boolean {
|
||||
val resolver = ksuApp.contentResolver
|
||||
|
||||
val bootFile = bootUri?.let { uri ->
|
||||
with(resolver.openInputStream(uri)) {
|
||||
val bootFile = File(ksuApp.cacheDir, "boot.img")
|
||||
bootFile.outputStream().use { output ->
|
||||
this?.copyTo(output)
|
||||
}
|
||||
|
||||
bootFile
|
||||
}
|
||||
}
|
||||
|
||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so")
|
||||
var cmd = "boot-patch --magiskboot ${magiskboot.absolutePath}"
|
||||
|
||||
cmd += if (bootFile == null) {
|
||||
// no boot.img, use -f to force install
|
||||
" -f"
|
||||
} else {
|
||||
" -b ${bootFile.absolutePath}"
|
||||
}
|
||||
|
||||
if (ota) {
|
||||
cmd += " -u"
|
||||
}
|
||||
|
||||
var lkmFile: File? = null
|
||||
when (lkm) {
|
||||
is LkmSelection.LkmUri -> {
|
||||
lkmFile = with(resolver.openInputStream(lkm.uri)) {
|
||||
val file = File(ksuApp.cacheDir, "kernelsu-tmp-lkm.ko")
|
||||
file.outputStream().use { output ->
|
||||
this?.copyTo(output)
|
||||
}
|
||||
|
||||
file
|
||||
}
|
||||
cmd += " -m ${lkmFile.absolutePath}"
|
||||
}
|
||||
|
||||
is LkmSelection.KmiString -> {
|
||||
cmd += " --kmi ${lkm.value}"
|
||||
}
|
||||
|
||||
LkmSelection.KmiNone -> {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// output dir
|
||||
val downloadsDir =
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
cmd += " -o $downloadsDir"
|
||||
|
||||
val result = flashWithIO("${getKsuDaemonPath()} $cmd", onStdout, onStderr)
|
||||
Log.i("KernelSU", "install boot result: ${result.isSuccess}")
|
||||
|
||||
bootFile?.delete()
|
||||
lkmFile?.delete()
|
||||
|
||||
// if boot uri is empty, it is direct install, when success, we should show reboot button
|
||||
onFinish(bootUri == null && result.isSuccess, result.code)
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
fun reboot(reason: String = "") {
|
||||
val shell = getRootShell()
|
||||
if (reason == "recovery") {
|
||||
// KEYCODE_POWER = 26, hide incorrect "Factory data reset" message
|
||||
ShellUtils.fastCmd(shell, "/system/bin/input keyevent 26")
|
||||
}
|
||||
ShellUtils.fastCmd(shell, "/system/bin/svc power reboot $reason || /system/bin/reboot $reason")
|
||||
}
|
||||
|
||||
fun rootAvailable(): Boolean {
|
||||
val shell = getRootShell()
|
||||
return shell.isRoot
|
||||
}
|
||||
|
||||
fun isAbDevice(): Boolean {
|
||||
val shell = getRootShell()
|
||||
return ShellUtils.fastCmd(shell, "getprop ro.build.ab_update").trim().toBoolean()
|
||||
}
|
||||
|
||||
fun isInitBoot(): Boolean {
|
||||
return !Os.uname().release.contains("android12-")
|
||||
}
|
||||
|
||||
suspend fun getCurrentKmi(): String = withContext(Dispatchers.IO) {
|
||||
val shell = getRootShell()
|
||||
val cmd = "boot-info current-kmi"
|
||||
ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd")
|
||||
}
|
||||
|
||||
suspend fun getSupportedKmis(): List<String> = withContext(Dispatchers.IO) {
|
||||
val shell = getRootShell()
|
||||
val cmd = "boot-info supported-kmi"
|
||||
val out = shell.newJob().add("${getKsuDaemonPath()} $cmd").to(ArrayList(), null).exec().out
|
||||
out.filter { it.isNotBlank() }.map { it.trim() }
|
||||
}
|
||||
|
||||
fun hasMagisk(): Boolean {
|
||||
val shell = getRootShell(true)
|
||||
val result = shell.newJob().add("which magisk").exec()
|
||||
Log.i(TAG, "has magisk: ${result.isSuccess}")
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
fun isSepolicyValid(rules: String?): Boolean {
|
||||
if (rules == null) {
|
||||
return true
|
||||
}
|
||||
val shell = getRootShell()
|
||||
val result =
|
||||
shell.newJob().add("${getKsuDaemonPath()} sepolicy check '$rules'").to(ArrayList(), null)
|
||||
.exec()
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
fun getSepolicy(pkg: String): String {
|
||||
val shell = getRootShell()
|
||||
val result =
|
||||
shell.newJob().add("${getKsuDaemonPath()} profile get-sepolicy $pkg").to(ArrayList(), null)
|
||||
.exec()
|
||||
Log.i(TAG, "code: ${result.code}, out: ${result.out}, err: ${result.err}")
|
||||
return result.out.joinToString("\n")
|
||||
}
|
||||
|
||||
fun setSepolicy(pkg: String, rules: String): Boolean {
|
||||
val shell = getRootShell()
|
||||
val result = shell.newJob().add("${getKsuDaemonPath()} profile set-sepolicy $pkg '$rules'")
|
||||
.to(ArrayList(), null).exec()
|
||||
Log.i(TAG, "set sepolicy result: ${result.code}")
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
fun listAppProfileTemplates(): List<String> {
|
||||
val shell = getRootShell()
|
||||
return shell.newJob().add("${getKsuDaemonPath()} profile list-templates").to(ArrayList(), null)
|
||||
.exec().out
|
||||
}
|
||||
|
||||
fun getAppProfileTemplate(id: String): String {
|
||||
val shell = getRootShell()
|
||||
return shell.newJob().add("${getKsuDaemonPath()} profile get-template '${id}'")
|
||||
.to(ArrayList(), null).exec().out.joinToString("\n")
|
||||
}
|
||||
|
||||
fun setAppProfileTemplate(id: String, template: String): Boolean {
|
||||
val shell = getRootShell()
|
||||
val escapedTemplate = template.replace("\"", "\\\"")
|
||||
val cmd = """${getKsuDaemonPath()} profile set-template "$id" "$escapedTemplate'""""
|
||||
return shell.newJob().add(cmd)
|
||||
.to(ArrayList(), null).exec().isSuccess
|
||||
}
|
||||
|
||||
fun deleteAppProfileTemplate(id: String): Boolean {
|
||||
val shell = getRootShell()
|
||||
return shell.newJob().add("${getKsuDaemonPath()} profile delete-template '${id}'")
|
||||
.to(ArrayList(), null).exec().isSuccess
|
||||
}
|
||||
|
||||
fun forceStopApp(packageName: String) {
|
||||
val shell = getRootShell()
|
||||
val result = shell.newJob().add("am force-stop $packageName").exec()
|
||||
Log.i(TAG, "force stop $packageName result: $result")
|
||||
}
|
||||
|
||||
fun launchApp(packageName: String) {
|
||||
|
||||
val shell = getRootShell()
|
||||
val result =
|
||||
shell.newJob()
|
||||
.add("cmd package resolve-activity --brief $packageName | tail -n 1 | xargs cmd activity start-activity -n")
|
||||
.exec()
|
||||
Log.i(TAG, "launch $packageName result: $result")
|
||||
}
|
||||
|
||||
fun restartApp(packageName: String) {
|
||||
forceStopApp(packageName)
|
||||
launchApp(packageName)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.rifsxd.ksunext.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.system.Os
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.ui.screen.getManagerVersion
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.PrintWriter
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
fun getBugreportFile(context: Context): File {
|
||||
|
||||
val bugreportDir = File(context.cacheDir, "bugreport")
|
||||
bugreportDir.mkdirs()
|
||||
|
||||
val dmesgFile = File(bugreportDir, "dmesg.txt")
|
||||
val logcatFile = File(bugreportDir, "logcat.txt")
|
||||
val tombstonesFile = File(bugreportDir, "tombstones.tar.gz")
|
||||
val dropboxFile = File(bugreportDir, "dropbox.tar.gz")
|
||||
val pstoreFile = File(bugreportDir, "pstore.tar.gz")
|
||||
// Xiaomi/Readmi devices have diag in /data/vendor/diag
|
||||
val diagFile = File(bugreportDir, "diag.tar.gz")
|
||||
val opulsFile = File(bugreportDir, "opuls.tar.gz")
|
||||
val bootlogFile = File(bugreportDir, "bootlog.tar.gz")
|
||||
val mountsFile = File(bugreportDir, "mounts.txt")
|
||||
val fileSystemsFile = File(bugreportDir, "filesystems.txt")
|
||||
val adbFileTree = File(bugreportDir, "adb_tree.txt")
|
||||
val adbFileDetails = File(bugreportDir, "adb_details.txt")
|
||||
val ksuFileSize = File(bugreportDir, "ksu_size.txt")
|
||||
val appListFile = File(bugreportDir, "packages.txt")
|
||||
val propFile = File(bugreportDir, "props.txt")
|
||||
val allowListFile = File(bugreportDir, "allowlist.bin")
|
||||
val procModules = File(bugreportDir, "proc_modules.txt")
|
||||
val bootConfig = File(bugreportDir, "boot_config.txt")
|
||||
val kernelConfig = File(bugreportDir, "defconfig.gz")
|
||||
|
||||
val shell = getRootShell(true)
|
||||
|
||||
shell.newJob().add("dmesg > ${dmesgFile.absolutePath}").exec()
|
||||
shell.newJob().add("logcat -d > ${logcatFile.absolutePath}").exec()
|
||||
shell.newJob().add("tar -czf ${tombstonesFile.absolutePath} -C /data/tombstones .").exec()
|
||||
shell.newJob().add("tar -czf ${dropboxFile.absolutePath} -C /data/system/dropbox .").exec()
|
||||
shell.newJob().add("tar -czf ${pstoreFile.absolutePath} -C /sys/fs/pstore .").exec()
|
||||
shell.newJob().add("tar -czf ${diagFile.absolutePath} -C /data/vendor/diag . --exclude=./minidump.gz").exec()
|
||||
shell.newJob().add("tar -czf ${opulsFile.absolutePath} -C /mnt/oplus/op2/media/log/boot_log/ .").exec()
|
||||
shell.newJob().add("tar -czf ${bootlogFile.absolutePath} -C /data/adb/ksu/log .").exec()
|
||||
|
||||
shell.newJob().add("cat /proc/1/mountinfo > ${mountsFile.absolutePath}").exec()
|
||||
shell.newJob().add("cat /proc/filesystems > ${fileSystemsFile.absolutePath}").exec()
|
||||
shell.newJob().add("busybox tree /data/adb > ${adbFileTree.absolutePath}").exec()
|
||||
shell.newJob().add("ls -alRZ /data/adb > ${adbFileDetails.absolutePath}").exec()
|
||||
shell.newJob().add("du -sh /data/adb/ksu/* > ${ksuFileSize.absolutePath}").exec()
|
||||
shell.newJob().add("cp /data/system/packages.list ${appListFile.absolutePath}").exec()
|
||||
shell.newJob().add("getprop > ${propFile.absolutePath}").exec()
|
||||
shell.newJob().add("cp /data/adb/ksu/.allowlist ${allowListFile.absolutePath}").exec()
|
||||
shell.newJob().add("cp /proc/modules ${procModules.absolutePath}").exec()
|
||||
shell.newJob().add("cp /proc/bootconfig ${bootConfig.absolutePath}").exec()
|
||||
shell.newJob().add("cp /proc/config.gz ${kernelConfig.absolutePath}").exec()
|
||||
|
||||
val selinux = ShellUtils.fastCmd(shell, "getenforce")
|
||||
|
||||
// basic information
|
||||
val buildInfo = File(bugreportDir, "basic.txt")
|
||||
PrintWriter(FileWriter(buildInfo)).use { pw ->
|
||||
pw.println("Kernel: ${System.getProperty("os.version")}")
|
||||
pw.println("BRAND: " + Build.BRAND)
|
||||
pw.println("MODEL: " + Build.MODEL)
|
||||
pw.println("PRODUCT: " + Build.PRODUCT)
|
||||
pw.println("MANUFACTURER: " + Build.MANUFACTURER)
|
||||
pw.println("ANDROID: " + Build.VERSION.RELEASE)
|
||||
pw.println("SDK: " + Build.VERSION.SDK_INT)
|
||||
pw.println("PREVIEW_SDK: " + Build.VERSION.PREVIEW_SDK_INT)
|
||||
pw.println("FINGERPRINT: " + Build.FINGERPRINT)
|
||||
pw.println("DEVICE: " + Build.DEVICE)
|
||||
pw.println("Manager: " + getManagerVersion(context))
|
||||
pw.println("SELinux: $selinux")
|
||||
|
||||
val uname = Os.uname()
|
||||
pw.println("KernelRelease: ${uname.release}")
|
||||
pw.println("KernelVersion: ${uname.version}")
|
||||
pw.println("Machine: ${uname.machine}")
|
||||
pw.println("Nodename: ${uname.nodename}")
|
||||
pw.println("Sysname: ${uname.sysname}")
|
||||
|
||||
val ksuKernel = Natives.version
|
||||
pw.println("KernelSU: $ksuKernel")
|
||||
val safeMode = Natives.isSafeMode
|
||||
pw.println("SafeMode: $safeMode")
|
||||
val lkmMode = Natives.isLkmMode
|
||||
pw.println("LKM: $lkmMode")
|
||||
}
|
||||
|
||||
// modules
|
||||
val modulesFile = File(bugreportDir, "modules.json")
|
||||
modulesFile.writeText(listModules())
|
||||
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm")
|
||||
val current = LocalDateTime.now().format(formatter)
|
||||
|
||||
val targetFile = File(context.cacheDir, "KernelSU_bugreport_${current}.tar.gz")
|
||||
|
||||
shell.newJob().add("tar czf ${targetFile.absolutePath} -C ${bugreportDir.absolutePath} .").exec()
|
||||
shell.newJob().add("rm -rf ${bugreportDir.absolutePath}").exec()
|
||||
shell.newJob().add("chmod 0644 ${targetFile.absolutePath}").exec()
|
||||
|
||||
return targetFile
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.rifsxd.ksunext.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.rifsxd.ksunext.R
|
||||
|
||||
@Composable
|
||||
fun getSELinuxStatus(): String {
|
||||
val shell = Shell.Builder.create()
|
||||
.setFlags(Shell.FLAG_REDIRECT_STDERR)
|
||||
.build("sh")
|
||||
|
||||
val list = ArrayList<String>()
|
||||
val result = shell.use {
|
||||
it.newJob().add("getenforce").to(list, list).exec()
|
||||
}
|
||||
val output = result.out.joinToString("\n").trim()
|
||||
|
||||
if (result.isSuccess) {
|
||||
return when (output) {
|
||||
"Enforcing" -> stringResource(R.string.selinux_status_enforcing)
|
||||
"Permissive" -> stringResource(R.string.selinux_status_permissive)
|
||||
"Disabled" -> stringResource(R.string.selinux_status_disabled)
|
||||
else -> stringResource(R.string.selinux_status_unknown)
|
||||
}
|
||||
}
|
||||
|
||||
return if (output.endsWith("Permission denied")) {
|
||||
stringResource(R.string.selinux_status_enforcing)
|
||||
} else {
|
||||
stringResource(R.string.selinux_status_unknown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.rifsxd.ksunext.ui.util.module
|
||||
|
||||
data class LatestVersionInfo(
|
||||
val versionCode : Int = 0,
|
||||
val downloadUrl : String = "",
|
||||
val changelog : String = ""
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.rifsxd.ksunext.ui.viewmodel
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import com.rifsxd.ksunext.ui.util.listModules
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
|
||||
class ModuleViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ModuleViewModel"
|
||||
private var modules by mutableStateOf<List<ModuleInfo>>(emptyList())
|
||||
}
|
||||
|
||||
class ModuleInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val author: String,
|
||||
val version: String,
|
||||
val versionCode: Int,
|
||||
val description: String,
|
||||
val enabled: Boolean,
|
||||
val update: Boolean,
|
||||
val remove: Boolean,
|
||||
val updateJson: String,
|
||||
val hasWebUi: Boolean,
|
||||
val hasActionScript: Boolean,
|
||||
)
|
||||
|
||||
data class ModuleUpdateInfo(
|
||||
val version: String,
|
||||
val versionCode: Int,
|
||||
val zipUrl: String,
|
||||
val changelog: String,
|
||||
)
|
||||
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val moduleList by derivedStateOf {
|
||||
val comparator = compareBy(Collator.getInstance(Locale.getDefault()), ModuleInfo::id)
|
||||
modules.sortedWith(comparator).also {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
var isNeedRefresh by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun markNeedRefresh() {
|
||||
isNeedRefresh = true
|
||||
}
|
||||
|
||||
fun fetchModuleList() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
isRefreshing = true
|
||||
|
||||
val oldModuleList = modules
|
||||
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
|
||||
kotlin.runCatching {
|
||||
val result = listModules()
|
||||
|
||||
Log.i(TAG, "result: $result")
|
||||
|
||||
val array = JSONArray(result)
|
||||
modules = (0 until array.length())
|
||||
.asSequence()
|
||||
.map { array.getJSONObject(it) }
|
||||
.map { obj ->
|
||||
ModuleInfo(
|
||||
obj.getString("id"),
|
||||
obj.optString("name"),
|
||||
obj.optString("author", "Unknown"),
|
||||
obj.optString("version", "Unknown"),
|
||||
obj.optInt("versionCode", 0),
|
||||
obj.optString("description"),
|
||||
obj.getBoolean("enabled"),
|
||||
obj.getBoolean("update"),
|
||||
obj.getBoolean("remove"),
|
||||
obj.optString("updateJson"),
|
||||
obj.optBoolean("web"),
|
||||
obj.optBoolean("action")
|
||||
)
|
||||
}.toList()
|
||||
isNeedRefresh = false
|
||||
}.onFailure { e ->
|
||||
Log.e(TAG, "fetchModuleList: ", e)
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
// when both old and new is kotlin.collections.EmptyList
|
||||
// moduleList update will don't trigger
|
||||
if (oldModuleList === modules) {
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}, modules: $modules")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdate(m: ModuleInfo): Triple<String, String, String> {
|
||||
val empty = Triple("", "", "")
|
||||
if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) {
|
||||
return empty
|
||||
}
|
||||
// download updateJson
|
||||
val result = kotlin.runCatching {
|
||||
val url = m.updateJson
|
||||
Log.i(TAG, "checkUpdate url: $url")
|
||||
val response = okhttp3.OkHttpClient()
|
||||
.newCall(
|
||||
okhttp3.Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
).execute()
|
||||
Log.d(TAG, "checkUpdate code: ${response.code}")
|
||||
if (response.isSuccessful) {
|
||||
response.body?.string() ?: ""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}.getOrDefault("")
|
||||
Log.i(TAG, "checkUpdate result: $result")
|
||||
|
||||
if (result.isEmpty()) {
|
||||
return empty
|
||||
}
|
||||
|
||||
val updateJson = kotlin.runCatching {
|
||||
JSONObject(result)
|
||||
}.getOrNull() ?: return empty
|
||||
|
||||
val version = updateJson.optString("version", "")
|
||||
val versionCode = updateJson.optInt("versionCode", 0)
|
||||
val zipUrl = updateJson.optString("zipUrl", "")
|
||||
val changelog = updateJson.optString("changelog", "")
|
||||
if (versionCode <= m.versionCode || zipUrl.isEmpty()) {
|
||||
return empty
|
||||
}
|
||||
|
||||
return Triple(zipUrl, version, changelog)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.rifsxd.ksunext.ui.viewmodel
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.rifsxd.ksunext.IKsuInterface
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.ksuApp
|
||||
import com.rifsxd.ksunext.ui.KsuService
|
||||
import com.rifsxd.ksunext.ui.util.HanziToPinyin
|
||||
import com.rifsxd.ksunext.ui.util.KsuCli
|
||||
import java.text.Collator
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class SuperUserViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SuperUserViewModel"
|
||||
private var apps by mutableStateOf<List<AppInfo>>(emptyList())
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class AppInfo(
|
||||
val label: String,
|
||||
val packageInfo: PackageInfo,
|
||||
val profile: Natives.Profile?,
|
||||
) : Parcelable {
|
||||
val packageName: String
|
||||
get() = packageInfo.packageName
|
||||
val uid: Int
|
||||
get() = packageInfo.applicationInfo!!.uid
|
||||
|
||||
val allowSu: Boolean
|
||||
get() = profile != null && profile.allowSu
|
||||
val hasCustomProfile: Boolean
|
||||
get() {
|
||||
if (profile == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return if (profile.allowSu) {
|
||||
!profile.rootUseDefault
|
||||
} else {
|
||||
!profile.nonRootUseDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var search by mutableStateOf("")
|
||||
var showSystemApps by mutableStateOf(false)
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private val sortedList by derivedStateOf {
|
||||
val comparator = compareBy<AppInfo> {
|
||||
when {
|
||||
it.allowSu -> 0
|
||||
it.hasCustomProfile -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.then(compareBy(Collator.getInstance(Locale.getDefault()), AppInfo::label))
|
||||
apps.sortedWith(comparator).also {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
val appList by derivedStateOf {
|
||||
sortedList.filter {
|
||||
it.label.contains(search, true) || it.packageName.contains(
|
||||
search,
|
||||
true
|
||||
) || HanziToPinyin.getInstance()
|
||||
.toPinyinString(it.label).contains(search, true)
|
||||
}.filter {
|
||||
it.uid == 2000 // Always show shell
|
||||
|| showSystemApps || it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun connectKsuService(
|
||||
crossinline onDisconnect: () -> Unit = {}
|
||||
): Pair<IBinder, ServiceConnection> = suspendCoroutine {
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
onDisconnect()
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
it.resume(binder as IBinder to this)
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(ksuApp, KsuService::class.java)
|
||||
|
||||
val task = KsuService.bindOrTask(
|
||||
intent,
|
||||
Shell.EXECUTOR,
|
||||
connection,
|
||||
)
|
||||
val shell = KsuCli.SHELL
|
||||
task?.let { it1 -> shell.execTask(it1) }
|
||||
}
|
||||
|
||||
private fun stopKsuService() {
|
||||
val intent = Intent(ksuApp, KsuService::class.java)
|
||||
KsuService.stop(intent)
|
||||
}
|
||||
|
||||
suspend fun fetchAppList() {
|
||||
|
||||
isRefreshing = true
|
||||
|
||||
val result = connectKsuService {
|
||||
Log.w(TAG, "KsuService disconnected")
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val pm = ksuApp.packageManager
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
|
||||
val binder = result.first
|
||||
val allPackages = IKsuInterface.Stub.asInterface(binder).getPackages(0)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
stopKsuService()
|
||||
}
|
||||
|
||||
val packages = allPackages.list
|
||||
|
||||
apps = packages.map {
|
||||
val appInfo = it.applicationInfo
|
||||
val uid = appInfo!!.uid
|
||||
val profile = Natives.getAppProfile(it.packageName, uid)
|
||||
AppInfo(
|
||||
label = appInfo.loadLabel(pm).toString(),
|
||||
packageInfo = it,
|
||||
profile = profile,
|
||||
)
|
||||
}.filter { it.packageName != ksuApp.packageName }
|
||||
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.rifsxd.ksunext.ui.viewmodel
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.rifsxd.ksunext.Natives
|
||||
import com.rifsxd.ksunext.profile.Capabilities
|
||||
import com.rifsxd.ksunext.profile.Groups
|
||||
import com.rifsxd.ksunext.ui.util.getAppProfileTemplate
|
||||
import com.rifsxd.ksunext.ui.util.listAppProfileTemplates
|
||||
import com.rifsxd.ksunext.ui.util.setAppProfileTemplate
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/10/20.
|
||||
*/
|
||||
const val TEMPLATE_INDEX_URL = "https://kernelsu.org/templates/index.json"
|
||||
const val TEMPLATE_URL = "https://kernelsu.org/templates/%s"
|
||||
|
||||
const val TAG = "TemplateViewModel"
|
||||
|
||||
class TemplateViewModel : ViewModel() {
|
||||
companion object {
|
||||
|
||||
private var templates by mutableStateOf<List<TemplateInfo>>(emptyList())
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class TemplateInfo(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val description: String = "",
|
||||
val author: String = "",
|
||||
val local: Boolean = true,
|
||||
|
||||
val namespace: Int = Natives.Profile.Namespace.INHERITED.ordinal,
|
||||
val uid: Int = Natives.ROOT_UID,
|
||||
val gid: Int = Natives.ROOT_GID,
|
||||
val groups: List<Int> = mutableListOf(),
|
||||
val capabilities: List<Int> = mutableListOf(),
|
||||
val context: String = Natives.KERNEL_SU_DOMAIN,
|
||||
val rules: List<String> = mutableListOf(),
|
||||
) : Parcelable
|
||||
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val templateList by derivedStateOf {
|
||||
val comparator = compareBy(TemplateInfo::local).reversed().then(
|
||||
compareBy(
|
||||
Collator.getInstance(Locale.getDefault()), TemplateInfo::id
|
||||
)
|
||||
)
|
||||
templates.sortedWith(comparator).apply {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchTemplates(sync: Boolean = false) {
|
||||
isRefreshing = true
|
||||
withContext(Dispatchers.IO) {
|
||||
val localTemplateIds = listAppProfileTemplates()
|
||||
Log.i(TAG, "localTemplateIds: $localTemplateIds")
|
||||
if (localTemplateIds.isEmpty() || sync) {
|
||||
// if no templates, fetch remote templates
|
||||
fetchRemoteTemplates()
|
||||
}
|
||||
|
||||
// fetch templates again
|
||||
templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById)
|
||||
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importTemplates(
|
||||
templates: String,
|
||||
onSuccess: suspend () -> Unit,
|
||||
onFailure: suspend (String) -> Unit
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
JSONArray(templates)
|
||||
}.getOrElse {
|
||||
runCatching {
|
||||
val json = JSONObject(templates)
|
||||
JSONArray().apply { put(json) }
|
||||
}.getOrElse {
|
||||
onFailure("invalid templates: $templates")
|
||||
return@withContext
|
||||
}
|
||||
}.let {
|
||||
0.until(it.length()).forEach { i ->
|
||||
runCatching {
|
||||
val template = it.getJSONObject(i)
|
||||
val id = template.getString("id")
|
||||
template.put("local", true)
|
||||
setAppProfileTemplate(id, template.toString())
|
||||
}.onFailure { e ->
|
||||
Log.e(TAG, "ignore invalid template: $it", e)
|
||||
}
|
||||
}
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportTemplates(onTemplateEmpty: () -> Unit, callback: (String) -> Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById).filter {
|
||||
it.local
|
||||
}
|
||||
templates.ifEmpty {
|
||||
onTemplateEmpty()
|
||||
return@withContext
|
||||
}
|
||||
JSONArray(templates.map {
|
||||
it.toJSON()
|
||||
}).toString().let(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchRemoteTemplates() {
|
||||
runCatching {
|
||||
val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
client.newCall(
|
||||
Request.Builder().url(TEMPLATE_INDEX_URL).build()
|
||||
).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return
|
||||
}
|
||||
val remoteTemplateIds = JSONArray(response.body!!.string())
|
||||
Log.i(TAG, "fetchRemoteTemplates: $remoteTemplateIds")
|
||||
0.until(remoteTemplateIds.length()).forEach { i ->
|
||||
val id = remoteTemplateIds.getString(i)
|
||||
Log.i(TAG, "fetch template: $id")
|
||||
val templateJson = client.newCall(
|
||||
Request.Builder().url(TEMPLATE_URL.format(id)).build()
|
||||
).runCatching {
|
||||
execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@forEach
|
||||
}
|
||||
response.body!!.string()
|
||||
}
|
||||
}.getOrNull() ?: return@forEach
|
||||
Log.i(TAG, "template: $templateJson")
|
||||
|
||||
// validate remote template
|
||||
runCatching {
|
||||
val json = JSONObject(templateJson)
|
||||
fromJSON(json)?.let {
|
||||
// force local template
|
||||
json.put("local", false)
|
||||
setAppProfileTemplate(id, json.toString())
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, "ignore invalid template: $it", it)
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.e(TAG, "fetchRemoteTemplates: $it", it) }
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T, R> JSONArray.mapCatching(
|
||||
transform: (T) -> R, onFail: (Throwable) -> Unit
|
||||
): List<R> {
|
||||
return List(length()) { i -> get(i) as T }.mapNotNull { element ->
|
||||
runCatching {
|
||||
transform(element)
|
||||
}.onFailure(onFail).getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> getEnumOrdinals(
|
||||
jsonArray: JSONArray?, enumClass: Class<T>
|
||||
): List<T> {
|
||||
return jsonArray?.mapCatching<String, T>({ name ->
|
||||
enumValueOf(name.uppercase())
|
||||
}, {
|
||||
Log.e(TAG, "ignore invalid enum ${enumClass.simpleName}: $it", it)
|
||||
}).orEmpty()
|
||||
}
|
||||
|
||||
fun getTemplateInfoById(id: String): TemplateViewModel.TemplateInfo? {
|
||||
return runCatching {
|
||||
fromJSON(JSONObject(getAppProfileTemplate(id)))
|
||||
}.onFailure {
|
||||
Log.e(TAG, "ignore invalid template: $it", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun getLocaleString(json: JSONObject, key: String): String {
|
||||
val fallback = json.getString(key)
|
||||
val locale = Locale.getDefault()
|
||||
val localeKey = "${locale.language}_${locale.country}"
|
||||
json.optJSONObject("locales")?.let {
|
||||
// check locale first
|
||||
it.optJSONObject(localeKey)?.let { json->
|
||||
return json.optString(key, fallback)
|
||||
}
|
||||
// fallback to language
|
||||
it.optJSONObject(locale.language)?.let { json->
|
||||
return json.optString(key, fallback)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private fun fromJSON(templateJson: JSONObject): TemplateViewModel.TemplateInfo? {
|
||||
return runCatching {
|
||||
val groupsJsonArray = templateJson.optJSONArray("groups")
|
||||
val capabilitiesJsonArray = templateJson.optJSONArray("capabilities")
|
||||
val context = templateJson.optString("context").takeIf { it.isNotEmpty() }
|
||||
?: Natives.KERNEL_SU_DOMAIN
|
||||
val namespace = templateJson.optString("namespace").takeIf { it.isNotEmpty() }
|
||||
?: Natives.Profile.Namespace.INHERITED.name
|
||||
|
||||
val rulesJsonArray = templateJson.optJSONArray("rules")
|
||||
val templateInfo = TemplateViewModel.TemplateInfo(
|
||||
id = templateJson.getString("id"),
|
||||
name = getLocaleString(templateJson, "name"),
|
||||
description = getLocaleString(templateJson, "description"),
|
||||
author = templateJson.optString("author"),
|
||||
local = templateJson.optBoolean("local"),
|
||||
namespace = Natives.Profile.Namespace.valueOf(
|
||||
namespace.uppercase()
|
||||
).ordinal,
|
||||
uid = templateJson.optInt("uid", Natives.ROOT_UID),
|
||||
gid = templateJson.optInt("gid", Natives.ROOT_GID),
|
||||
groups = getEnumOrdinals(groupsJsonArray, Groups::class.java).map { it.gid },
|
||||
capabilities = getEnumOrdinals(
|
||||
capabilitiesJsonArray, Capabilities::class.java
|
||||
).map { it.cap },
|
||||
context = context,
|
||||
rules = rulesJsonArray?.mapCatching<String, String>({ it }, {
|
||||
Log.e(TAG, "ignore invalid rule: $it", it)
|
||||
}).orEmpty()
|
||||
)
|
||||
templateInfo
|
||||
}.onFailure {
|
||||
Log.e(TAG, "ignore invalid template: $it", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun TemplateViewModel.TemplateInfo.toJSON(): JSONObject {
|
||||
val template = this
|
||||
return JSONObject().apply {
|
||||
|
||||
put("id", template.id)
|
||||
put("name", template.name.ifBlank { template.id })
|
||||
put("description", template.description.ifBlank { template.id })
|
||||
if (template.author.isNotEmpty()) {
|
||||
put("author", template.author)
|
||||
}
|
||||
put("namespace", Natives.Profile.Namespace.entries[template.namespace].name)
|
||||
put("uid", template.uid)
|
||||
put("gid", template.gid)
|
||||
|
||||
if (template.groups.isNotEmpty()) {
|
||||
put("groups", JSONArray(
|
||||
Groups.entries.filter {
|
||||
template.groups.contains(it.gid)
|
||||
}.map {
|
||||
it.name
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
if (template.capabilities.isNotEmpty()) {
|
||||
put("capabilities", JSONArray(
|
||||
Capabilities.entries.filter {
|
||||
template.capabilities.contains(it.cap)
|
||||
}.map {
|
||||
it.name
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
if (template.context.isNotEmpty()) {
|
||||
put("context", template.context)
|
||||
}
|
||||
|
||||
if (template.rules.isNotEmpty()) {
|
||||
put("rules", JSONArray(template.rules))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun generateTemplates() {
|
||||
val templateJson = JSONObject()
|
||||
templateJson.put("id", "com.example")
|
||||
templateJson.put("name", "Example")
|
||||
templateJson.put("description", "This is an example template")
|
||||
templateJson.put("local", true)
|
||||
templateJson.put("namespace", Natives.Profile.Namespace.INHERITED.name)
|
||||
templateJson.put("uid", 0)
|
||||
templateJson.put("gid", 0)
|
||||
|
||||
templateJson.put("groups", JSONArray().apply { put(Groups.INET.name) })
|
||||
templateJson.put("capabilities", JSONArray().apply { put(Capabilities.CAP_NET_RAW.name) })
|
||||
templateJson.put("context", "u:r:su:s0")
|
||||
Log.i(TAG, "$templateJson")
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.rifsxd.ksunext.ui.webui;
|
||||
|
||||
import java.net.URLConnection;
|
||||
|
||||
class MimeUtil {
|
||||
|
||||
public static String getMimeFromFileName(String fileName) {
|
||||
if (fileName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copying the logic and mapping that Chromium follows.
|
||||
// First we check against the OS (this is a limited list by default)
|
||||
// but app developers can extend this.
|
||||
// We then check against a list of hardcoded mime types above if the
|
||||
// OS didn't provide a result.
|
||||
String mimeType = URLConnection.guessContentTypeFromName(fileName);
|
||||
|
||||
if (mimeType != null) {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
return guessHardcodedMime(fileName);
|
||||
}
|
||||
|
||||
// We should keep this map in sync with the lists under
|
||||
// //net/base/mime_util.cc in Chromium.
|
||||
// A bunch of the mime types don't really apply to Android land
|
||||
// like word docs so feel free to filter out where necessary.
|
||||
private static String guessHardcodedMime(String fileName) {
|
||||
int finalFullStop = fileName.lastIndexOf('.');
|
||||
if (finalFullStop == -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String extension = fileName.substring(finalFullStop + 1).toLowerCase();
|
||||
|
||||
return switch (extension) {
|
||||
case "webm" -> "video/webm";
|
||||
case "mpeg", "mpg" -> "video/mpeg";
|
||||
case "mp3" -> "audio/mpeg";
|
||||
case "wasm" -> "application/wasm";
|
||||
case "xhtml", "xht", "xhtm" -> "application/xhtml+xml";
|
||||
case "flac" -> "audio/flac";
|
||||
case "ogg", "oga", "opus" -> "audio/ogg";
|
||||
case "wav" -> "audio/wav";
|
||||
case "m4a" -> "audio/x-m4a";
|
||||
case "gif" -> "image/gif";
|
||||
case "jpeg", "jpg", "jfif", "pjpeg", "pjp" -> "image/jpeg";
|
||||
case "png" -> "image/png";
|
||||
case "apng" -> "image/apng";
|
||||
case "svg", "svgz" -> "image/svg+xml";
|
||||
case "webp" -> "image/webp";
|
||||
case "mht", "mhtml" -> "multipart/related";
|
||||
case "css" -> "text/css";
|
||||
case "html", "htm", "shtml", "shtm", "ehtml" -> "text/html";
|
||||
case "js", "mjs" -> "application/javascript";
|
||||
case "xml" -> "text/xml";
|
||||
case "mp4", "m4v" -> "video/mp4";
|
||||
case "ogv", "ogm" -> "video/ogg";
|
||||
case "ico" -> "image/x-icon";
|
||||
case "woff" -> "application/font-woff";
|
||||
case "gz", "tgz" -> "application/gzip";
|
||||
case "json" -> "application/json";
|
||||
case "pdf" -> "application/pdf";
|
||||
case "zip" -> "application/zip";
|
||||
case "bmp" -> "image/bmp";
|
||||
case "tiff", "tif" -> "image/tiff";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.rifsxd.ksunext.ui.webui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import android.webkit.WebResourceResponse;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.webkit.WebViewAssetLoader;
|
||||
|
||||
import com.topjohnwu.superuser.Shell;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
import com.topjohnwu.superuser.io.SuFileInputStream;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/**
|
||||
* Handler class to open files from file system by root access
|
||||
* For more information about android storage please refer to
|
||||
* <a href="https://developer.android.com/guide/topics/data/data-storage">Android Developers
|
||||
* Docs: Data and file storage overview</a>.
|
||||
* <p class="note">
|
||||
* To avoid leaking user or app data to the web, make sure to choose {@code directory}
|
||||
* carefully, and assume any file under this directory could be accessed by any web page subject
|
||||
* to same-origin rules.
|
||||
* <p>
|
||||
* A typical usage would be like:
|
||||
* <pre class="prettyprint">
|
||||
* File publicDir = new File(context.getFilesDir(), "public");
|
||||
* // Host "files/public/" in app's data directory under:
|
||||
* // http://appassets.androidplatform.net/public/...
|
||||
* WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
|
||||
* .addPathHandler("/public/", new InternalStoragePathHandler(context, publicDir))
|
||||
* .build();
|
||||
* </pre>
|
||||
*/
|
||||
public final class SuFilePathHandler implements WebViewAssetLoader.PathHandler {
|
||||
private static final String TAG = "SuFilePathHandler";
|
||||
|
||||
/**
|
||||
* Default value to be used as MIME type if guessing MIME type failed.
|
||||
*/
|
||||
public static final String DEFAULT_MIME_TYPE = "text/plain";
|
||||
|
||||
/**
|
||||
* Forbidden subdirectories of {@link Context#getDataDir} that cannot be exposed by this
|
||||
* handler. They are forbidden as they often contain sensitive information.
|
||||
* <p class="note">
|
||||
* Note: Any future addition to this list will be considered breaking changes to the API.
|
||||
*/
|
||||
private static final String[] FORBIDDEN_DATA_DIRS =
|
||||
new String[] {"/data/data", "/data/system"};
|
||||
|
||||
@NonNull
|
||||
private final File mDirectory;
|
||||
|
||||
private final Shell mShell;
|
||||
|
||||
/**
|
||||
* Creates PathHandler for app's internal storage.
|
||||
* The directory to be exposed must be inside either the application's internal data
|
||||
* directory {@link Context#getDataDir} or cache directory {@link Context#getCacheDir}.
|
||||
* External storage is not supported for security reasons, as other apps with
|
||||
* {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} may be able to modify the
|
||||
* files.
|
||||
* <p>
|
||||
* Exposing the entire data or cache directory is not permitted, to avoid accidentally
|
||||
* exposing sensitive application files to the web. Certain existing subdirectories of
|
||||
* {@link Context#getDataDir} are also not permitted as they are often sensitive.
|
||||
* These files are ({@code "app_webview/"}, {@code "databases/"}, {@code "lib/"},
|
||||
* {@code "shared_prefs/"} and {@code "code_cache/"}).
|
||||
* <p>
|
||||
* The application should typically use a dedicated subdirectory for the files it intends to
|
||||
* expose and keep them separate from other files.
|
||||
*
|
||||
* @param context {@link Context} that is used to access app's internal storage.
|
||||
* @param directory the absolute path of the exposed app internal storage directory from
|
||||
* which files can be loaded.
|
||||
* @throws IllegalArgumentException if the directory is not allowed.
|
||||
*/
|
||||
public SuFilePathHandler(@NonNull Context context, @NonNull File directory, Shell rootShell) {
|
||||
try {
|
||||
mDirectory = new File(getCanonicalDirPath(directory));
|
||||
if (!isAllowedInternalStorageDir(context)) {
|
||||
throw new IllegalArgumentException("The given directory \"" + directory
|
||||
+ "\" doesn't exist under an allowed app internal storage directory");
|
||||
}
|
||||
mShell = rootShell;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Failed to resolve the canonical path for the given directory: "
|
||||
+ directory.getPath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAllowedInternalStorageDir(@NonNull Context context) throws IOException {
|
||||
String dir = getCanonicalDirPath(mDirectory);
|
||||
|
||||
for (String forbiddenPath : FORBIDDEN_DATA_DIRS) {
|
||||
if (dir.startsWith(forbiddenPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the requested file from the exposed data directory.
|
||||
* <p>
|
||||
* The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
|
||||
* requested file cannot be found or is outside the mounted directory a
|
||||
* {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be
|
||||
* returned instead of {@code null}. This saves the time of falling back to network and
|
||||
* trying to resolve a path that doesn't exist. A {@link WebResourceResponse} with
|
||||
* {@code null} {@link InputStream} will be received as an HTTP response with status code
|
||||
* {@code 404} and no body.
|
||||
* <p class="note">
|
||||
* The MIME type for the file will be determined from the file's extension using
|
||||
* {@link java.net.URLConnection#guessContentTypeFromName}. Developers should ensure that
|
||||
* files are named using standard file extensions. If the file does not have a
|
||||
* recognised extension, {@code "text/plain"} will be used by default.
|
||||
*
|
||||
* @param path the suffix path to be handled.
|
||||
* @return {@link WebResourceResponse} for the requested file.
|
||||
*/
|
||||
@Override
|
||||
@WorkerThread
|
||||
@NonNull
|
||||
public WebResourceResponse handle(@NonNull String path) {
|
||||
try {
|
||||
File file = getCanonicalFileIfChild(mDirectory, path);
|
||||
if (file != null) {
|
||||
InputStream is = openFile(file, mShell);
|
||||
String mimeType = guessMimeType(path);
|
||||
return new WebResourceResponse(mimeType, null, is);
|
||||
} else {
|
||||
Log.e(TAG, String.format(
|
||||
"The requested file: %s is outside the mounted directory: %s", path,
|
||||
mDirectory));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error opening the requested path: " + path, e);
|
||||
}
|
||||
return new WebResourceResponse(null, null, null);
|
||||
}
|
||||
|
||||
public static String getCanonicalDirPath(@NonNull File file) throws IOException {
|
||||
String canonicalPath = file.getCanonicalPath();
|
||||
if (!canonicalPath.endsWith("/")) canonicalPath += "/";
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
public static File getCanonicalFileIfChild(@NonNull File parent, @NonNull String child)
|
||||
throws IOException {
|
||||
String parentCanonicalPath = getCanonicalDirPath(parent);
|
||||
String childCanonicalPath = new File(parent, child).getCanonicalPath();
|
||||
if (childCanonicalPath.startsWith(parentCanonicalPath)) {
|
||||
return new File(childCanonicalPath);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static InputStream handleSvgzStream(@NonNull String path,
|
||||
@NonNull InputStream stream) throws IOException {
|
||||
return path.endsWith(".svgz") ? new GZIPInputStream(stream) : stream;
|
||||
}
|
||||
|
||||
public static InputStream openFile(@NonNull File file, @NonNull Shell shell) throws IOException {
|
||||
SuFile suFile = new SuFile(file.getAbsolutePath());
|
||||
suFile.setShell(shell);
|
||||
InputStream fis = SuFileInputStream.open(suFile);
|
||||
return handleSvgzStream(file.getPath(), fis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use {@link MimeUtil#getMimeFromFileName} to guess MIME type or return the
|
||||
* {@link #DEFAULT_MIME_TYPE} if it can't guess.
|
||||
*
|
||||
* @param filePath path of the file to guess its MIME type.
|
||||
* @return MIME type guessed from file extension or {@link #DEFAULT_MIME_TYPE}.
|
||||
*/
|
||||
@NonNull
|
||||
public static String guessMimeType(@NonNull String filePath) {
|
||||
String mimeType = MimeUtil.getMimeFromFileName(filePath);
|
||||
return mimeType == null ? DEFAULT_MIME_TYPE : mimeType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.rifsxd.ksunext.ui.webui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.ActivityManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.rifsxd.ksunext.ui.util.createRootShell
|
||||
import java.io.File
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
class WebUIActivity : ComponentActivity() {
|
||||
private lateinit var webviewInterface: WebViewInterface
|
||||
|
||||
private var rootShell: Shell? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
// Enable edge to edge
|
||||
enableEdgeToEdge()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val moduleId = intent.getStringExtra("id")!!
|
||||
val name = intent.getStringExtra("name")!!
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
@Suppress("DEPRECATION")
|
||||
setTaskDescription(ActivityManager.TaskDescription("KernelSU - $name"))
|
||||
} else {
|
||||
val taskDescription = ActivityManager.TaskDescription.Builder().setLabel("KernelSU - $name").build()
|
||||
setTaskDescription(taskDescription)
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
WebView.setWebContentsDebuggingEnabled(prefs.getBoolean("enable_web_debugging", false))
|
||||
|
||||
val moduleDir = "/data/adb/modules/${moduleId}"
|
||||
val webRoot = File("${moduleDir}/webroot")
|
||||
val rootShell = createRootShell(true).also { this.rootShell = it }
|
||||
val webViewAssetLoader = WebViewAssetLoader.Builder()
|
||||
.setDomain("mui.kernelsu.org")
|
||||
.addPathHandler(
|
||||
"/",
|
||||
SuFilePathHandler(this, webRoot, rootShell)
|
||||
)
|
||||
.build()
|
||||
|
||||
val webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
return webViewAssetLoader.shouldInterceptRequest(request.url)
|
||||
}
|
||||
}
|
||||
|
||||
val webView = WebView(this).apply {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
|
||||
val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.updateLayoutParams<MarginLayoutParams> {
|
||||
leftMargin = inset.left
|
||||
rightMargin = inset.right
|
||||
topMargin = inset.top
|
||||
bottomMargin = inset.bottom
|
||||
}
|
||||
return@setOnApplyWindowInsetsListener insets
|
||||
}
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.allowFileAccess = false
|
||||
webviewInterface = WebViewInterface(this@WebUIActivity, this, moduleDir)
|
||||
addJavascriptInterface(webviewInterface, "ksu")
|
||||
setWebViewClient(webViewClient)
|
||||
loadUrl("https://mui.kernelsu.org/index.html")
|
||||
}
|
||||
|
||||
setContentView(webView)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
runCatching { rootShell?.close() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.rifsxd.ksunext.ui.webui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import android.view.Window
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.topjohnwu.superuser.CallbackList
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
import com.topjohnwu.superuser.internal.UiThreadHandler
|
||||
import com.rifsxd.ksunext.ui.util.createRootShell
|
||||
import com.rifsxd.ksunext.ui.util.listModules
|
||||
import com.rifsxd.ksunext.ui.util.withNewRootShell
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
class WebViewInterface(
|
||||
val context: Context,
|
||||
private val webView: WebView,
|
||||
private val modDir: String
|
||||
) {
|
||||
|
||||
@JavascriptInterface
|
||||
fun exec(cmd: String): String {
|
||||
return withNewRootShell(true) { ShellUtils.fastCmd(this, cmd) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun exec(cmd: String, callbackFunc: String) {
|
||||
exec(cmd, null, callbackFunc)
|
||||
}
|
||||
|
||||
private fun processOptions(sb: StringBuilder, options: String?) {
|
||||
val opts = if (options == null) JSONObject() else {
|
||||
JSONObject(options)
|
||||
}
|
||||
|
||||
val cwd = opts.optString("cwd")
|
||||
if (!TextUtils.isEmpty(cwd)) {
|
||||
sb.append("cd ${cwd};")
|
||||
}
|
||||
|
||||
opts.optJSONObject("env")?.let { env ->
|
||||
env.keys().forEach { key ->
|
||||
sb.append("export ${key}=${env.getString(key)};")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun exec(
|
||||
cmd: String,
|
||||
options: String?,
|
||||
callbackFunc: String
|
||||
) {
|
||||
val finalCommand = StringBuilder()
|
||||
processOptions(finalCommand, options)
|
||||
finalCommand.append(cmd)
|
||||
|
||||
val result = withNewRootShell(true) {
|
||||
newJob().add(finalCommand.toString()).to(ArrayList(), ArrayList()).exec()
|
||||
}
|
||||
val stdout = result.out.joinToString(separator = "\n")
|
||||
val stderr = result.err.joinToString(separator = "\n")
|
||||
|
||||
val jsCode =
|
||||
"javascript: (function() { try { ${callbackFunc}(${result.code}, ${
|
||||
JSONObject.quote(
|
||||
stdout
|
||||
)
|
||||
}, ${JSONObject.quote(stderr)}); } catch(e) { console.error(e); } })();"
|
||||
webView.post {
|
||||
webView.loadUrl(jsCode)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun spawn(command: String, args: String, options: String?, callbackFunc: String) {
|
||||
val finalCommand = StringBuilder()
|
||||
|
||||
processOptions(finalCommand, options)
|
||||
|
||||
if (!TextUtils.isEmpty(args)) {
|
||||
finalCommand.append(command).append(" ")
|
||||
JSONArray(args).let { argsArray ->
|
||||
for (i in 0 until argsArray.length()) {
|
||||
finalCommand.append(argsArray.getString(i))
|
||||
finalCommand.append(" ")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
finalCommand.append(command)
|
||||
}
|
||||
|
||||
val shell = createRootShell(true)
|
||||
|
||||
val emitData = fun(name: String, data: String) {
|
||||
val jsCode =
|
||||
"javascript: (function() { try { ${callbackFunc}.${name}.emit('data', ${
|
||||
JSONObject.quote(
|
||||
data
|
||||
)
|
||||
}); } catch(e) { console.error('emitData', e); } })();"
|
||||
webView.post {
|
||||
webView.loadUrl(jsCode)
|
||||
}
|
||||
}
|
||||
|
||||
val stdout = object : CallbackList<String>(UiThreadHandler::runAndWait) {
|
||||
override fun onAddElement(s: String) {
|
||||
emitData("stdout", s)
|
||||
}
|
||||
}
|
||||
|
||||
val stderr = object : CallbackList<String>(UiThreadHandler::runAndWait) {
|
||||
override fun onAddElement(s: String) {
|
||||
emitData("stderr", s)
|
||||
}
|
||||
}
|
||||
|
||||
val future = shell.newJob().add(finalCommand.toString()).to(stdout, stderr).enqueue()
|
||||
val completableFuture = CompletableFuture.supplyAsync {
|
||||
future.get()
|
||||
}
|
||||
|
||||
completableFuture.thenAccept { result ->
|
||||
val emitExitCode =
|
||||
"javascript: (function() { try { ${callbackFunc}.emit('exit', ${result.code}); } catch(e) { console.error(`emitExit error: \${e}`); } })();"
|
||||
webView.post {
|
||||
webView.loadUrl(emitExitCode)
|
||||
}
|
||||
|
||||
if (result.code != 0) {
|
||||
val emitErrCode =
|
||||
"javascript: (function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${
|
||||
JSONObject.quote(
|
||||
result.err.joinToString(
|
||||
"\n"
|
||||
)
|
||||
)
|
||||
};${callbackFunc}.emit('error', err); } catch(e) { console.error('emitErr', e); } })();"
|
||||
webView.post {
|
||||
webView.loadUrl(emitErrCode)
|
||||
}
|
||||
}
|
||||
}.whenComplete { _, _ ->
|
||||
runCatching { shell.close() }
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun toast(msg: String) {
|
||||
webView.post {
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun fullScreen(enable: Boolean) {
|
||||
if (context is Activity) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (enable) {
|
||||
hideSystemUI(context.window)
|
||||
} else {
|
||||
showSystemUI(context.window)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun moduleInfo(): String {
|
||||
val moduleInfos = JSONArray(listModules())
|
||||
var currentModuleInfo = JSONObject()
|
||||
currentModuleInfo.put("moduleDir", modDir)
|
||||
val moduleId = File(modDir).getName()
|
||||
for (i in 0 until moduleInfos.length()) {
|
||||
val currentInfo = moduleInfos.getJSONObject(i)
|
||||
|
||||
if (currentInfo.getString("id") != moduleId) {
|
||||
continue
|
||||
}
|
||||
|
||||
var keys = currentInfo.keys()
|
||||
for (key in keys) {
|
||||
currentModuleInfo.put(key, currentInfo.get(key))
|
||||
}
|
||||
break
|
||||
}
|
||||
return currentModuleInfo.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun hideSystemUI(window: Window) =
|
||||
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
|
||||
fun showSystemUI(window: Window) =
|
||||
WindowInsetsControllerCompat(window, window.decorView).show(WindowInsetsCompat.Type.systemBars())
|
||||
Reference in New Issue
Block a user