Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,35 @@ package me.weishu.kernelsu

import android.app.Application
import android.system.Os
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.weishu.kernelsu.ui.viewmodel.SuperUserViewModel
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.util.Locale

lateinit var ksuApp: KernelSUApplication

class KernelSUApplication : Application() {
class KernelSUApplication : Application(), ViewModelStoreOwner {

lateinit var okhttpClient: OkHttpClient
private val appViewModelStore by lazy { ViewModelStore() }

override fun onCreate() {
super.onCreate()
ksuApp = this

// For faster response when first entering superuser or webui activity
val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java]
CoroutineScope(Dispatchers.Main).launch {
superUserViewModel.fetchAppList()
}

val webroot = File(dataDir, "webroot")
if (!webroot.exists()) {
webroot.mkdir()
Expand All @@ -35,4 +49,7 @@ class KernelSUApplication : Application() {
)
}.build()
}

override val viewModelStore: ViewModelStore
get() = appViewModelStore
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package me.weishu.kernelsu.ui.viewmodel

import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.graphics.drawable.Drawable
import android.os.IBinder
import android.os.Parcelable
import android.os.SystemClock
Expand Down Expand Up @@ -34,7 +36,15 @@ class SuperUserViewModel : ViewModel() {

companion object {
private const val TAG = "SuperUserViewModel"
private var apps by mutableStateOf<List<AppInfo>>(emptyList())
private val appsLock = Any()
var apps by mutableStateOf<List<AppInfo>>(emptyList())

@JvmStatic
fun getAppIconDrawable(context: Context, packageName: String): Drawable? {
val appList = synchronized(appsLock) { apps }
val appDetail = appList.find { it.packageName == packageName }
return appDetail?.packageInfo?.applicationInfo?.loadIcon(context.packageManager)
}
}


Expand Down Expand Up @@ -162,7 +172,7 @@ class SuperUserViewModel : ViewModel() {

val packages = allPackages.list

apps = packages.map {
val newApps = packages.map {
val appInfo = it.applicationInfo
val uid = appInfo!!.uid
val profile = Natives.getAppProfile(it.packageName, uid)
Expand All @@ -173,14 +183,18 @@ class SuperUserViewModel : ViewModel() {
)
}.filter { it.packageName != ksuApp.packageName }

synchronized(appsLock) {
apps = newApps
}

val comparator = compareBy<AppInfo> {
when {
it.allowSu -> 0
it.hasCustomProfile -> 1
else -> 2
}
}.then(compareBy(Collator.getInstance(Locale.getDefault()), AppInfo::label))
_appList.value = apps.sortedWith(comparator).also {
_appList.value = newApps.sortedWith(comparator).also {
isRefreshing = false
}.filter {
it.uid == 2000 // Always show shell
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package me.weishu.kernelsu.ui.webui;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.util.LruCache;
import me.weishu.kernelsu.ui.viewmodel.SuperUserViewModel;

public class AppIconUtil {
// Limit cache size to 200 icons
private static final int CACHE_SIZE = 200;
private static final LruCache<String, Bitmap> iconCache = new LruCache<>(CACHE_SIZE);

public static synchronized Bitmap loadAppIconSync(Context context, String packageName, int sizePx) {
Bitmap cached = iconCache.get(packageName);
if (cached != null) return cached;

try {
Drawable drawable = SuperUserViewModel.getAppIconDrawable(context, packageName);
if (drawable == null) {
return null;
}
Bitmap raw = drawableToBitmap(drawable, sizePx);
Bitmap icon = Bitmap.createScaledBitmap(raw, sizePx, sizePx, true);
if (raw != icon) raw.recycle();
iconCache.put(packageName, icon);
return icon;
} catch (Exception e) {
return null;
}
}

private static Bitmap drawableToBitmap(Drawable drawable, int size) {
if (drawable instanceof BitmapDrawable) return ((BitmapDrawable) drawable).getBitmap();

int width = drawable.getIntrinsicWidth() > 0 ? drawable.getIntrinsicWidth() : size;
int height = drawable.getIntrinsicHeight() > 0 ? drawable.getIntrinsicHeight() : size;

Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bmp);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
return bmp;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,24 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebViewAssetLoader
import kotlinx.coroutines.launch
import com.topjohnwu.superuser.Shell
import me.weishu.kernelsu.ui.util.createRootShell
import me.weishu.kernelsu.ui.viewmodel.SuperUserViewModel
import java.io.File

@SuppressLint("SetJavaScriptEnabled")
class WebUIActivity : ComponentActivity() {
private lateinit var webviewInterface: WebViewInterface

private var rootShell: Shell? = null
private val superUserViewModel: SuperUserViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {

Expand All @@ -35,6 +40,10 @@ class WebUIActivity : ComponentActivity() {

super.onCreate(savedInstanceState)

lifecycleScope.launch {
superUserViewModel.fetchAppList()
}

val moduleId = intent.getStringExtra("id")!!
val name = intent.getStringExtra("name")!!
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Expand Down Expand Up @@ -64,7 +73,23 @@ class WebUIActivity : ComponentActivity() {
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
return webViewAssetLoader.shouldInterceptRequest(request.url)
val url = request.url

// Handle ksu://icon/[packageName] to serve app icon via WebView
if (url.scheme.equals("ksu", ignoreCase = true) && url.host.equals("icon", ignoreCase = true)) {
val packageName = url.path?.substring(1)
if (!packageName.isNullOrEmpty()) {
val icon = AppIconUtil.loadAppIconSync(this@WebUIActivity, packageName, 512)
if (icon != null) {
val stream = java.io.ByteArrayOutputStream()
icon.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream)
val inputStream = java.io.ByteArrayInputStream(stream.toByteArray())
return WebResourceResponse("image/png", null, inputStream)
}
}
}

return webViewAssetLoader.shouldInterceptRequest(url)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
package me.weishu.kernelsu.ui.webui

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.Base64
import android.app.Activity
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build
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.annotation.RequiresApi
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.topjohnwu.superuser.CallbackList
Expand All @@ -17,6 +26,7 @@ import com.topjohnwu.superuser.internal.UiThreadHandler
import me.weishu.kernelsu.ui.util.createRootShell
import me.weishu.kernelsu.ui.util.listModules
import me.weishu.kernelsu.ui.util.withNewRootShell
import me.weishu.kernelsu.ui.viewmodel.SuperUserViewModel
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
Expand Down Expand Up @@ -197,6 +207,56 @@ class WebViewInterface(
}
return currentModuleInfo.toString()
}

@JavascriptInterface
fun listPackages(type: String): String {
val packageNames = SuperUserViewModel.apps
.filter { appInfo ->
val flags = appInfo.packageInfo.applicationInfo?.flags ?: 0
when (type.lowercase()) {
"system" -> (flags and ApplicationInfo.FLAG_SYSTEM) != 0
"user" -> (flags and ApplicationInfo.FLAG_SYSTEM) == 0
else -> true
}
}
.map { it.packageName }
.sorted()

val jsonArray = JSONArray()
for (pkgName in packageNames) {
jsonArray.put(pkgName)
}
return jsonArray.toString()
}

@JavascriptInterface
fun getPackagesInfo(packageNamesJson: String): String {
val packageNames = JSONArray(packageNamesJson)
val jsonArray = JSONArray()
val appMap = SuperUserViewModel.apps.associateBy { it.packageName }
for (i in 0 until packageNames.length()) {
val pkgName = packageNames.getString(i)
val appInfo = appMap[pkgName]
if (appInfo != null) {
val pkg = appInfo.packageInfo
val app = pkg.applicationInfo
val obj = JSONObject()
obj.put("packageName", pkg.packageName)
obj.put("versionName", pkg.versionName ?: "")
obj.put("versionCode", PackageInfoCompat.getLongVersionCode(pkg))
obj.put("appLabel", appInfo.label)
obj.put("isSystem", if (app != null) ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) else JSONObject.NULL)
obj.put("uid", app?.uid ?: JSONObject.NULL)
jsonArray.put(obj)
} else {
val obj = JSONObject()
obj.put("packageName", pkgName)
obj.put("error", "Package not found or inaccessible")
jsonArray.put(obj)
}
}
return jsonArray.toString()
}
}

fun hideSystemUI(window: Window) =
Expand Down