manager: common detectable package id replaced - me.weishu.kernelsu -> com.rifsxd.ksunext

This commit is contained in:
Rifat Azad
2024-12-18 15:35:41 +06:00
parent 57bffb5e7c
commit 21bf2d5639
47 changed files with 186 additions and 186 deletions
@@ -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", "Dont 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 callers 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 systems range of legal group IDs"),
CAP_SETPCAP(8, "SETPCAP", "If file capabilities are supported: grant or remove any capability in the callers 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
}
}
@@ -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())