manager: introduce webui package manager api (#2928)

this is a squash of:

* manager: introduce app package info API for webui-next
(KernelSU-Next/KernelSU-Next@58167a4)

* manager: sort a-z order for webui-next list packages api
(KernelSU-Next/KernelSU-Next@4a9733c)

* manager: implement getPackagesIcons and cacheAllPackageIcons api to
webui-next (KernelSU-Next/KernelSU-Next@a361fa3)

* manager/webui: let getPackagesIcons generate icon and store in cache
as well when called (KernelSU-Next/KernelSU-Next@6afa86d)

* POC: load icon app via ksu://icon/[packageName] (KernelSU-Next#674)
(KernelSU-Next/KernelSU-Next@bc9927b)

* manager: refine webui package manager (KOWX712/KernelSU@0400c42)

Co-Authored-By: Rifat Azad <33044977+rifsxd@users.noreply.github.com>
Co-Authored-By: Fahrez256Bit
<167403685+fahrez256@users.noreply.github.com>
Signed-off-by: KOWX712 <leecc0503@gmail.com>

---------

Signed-off-by: KOWX712 <leecc0503@gmail.com>
Co-authored-by: Rifat Azad <33044977+rifsxd@users.noreply.github.com>
Co-authored-by: Fahrez256Bit <167403685+fahrez256@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
KOWX712
2025-11-14 09:17:28 +08:00
committed by ShirkNeko
parent 7e7713ee4a
commit fa57ccccf4
5 changed files with 156 additions and 6 deletions

View File

@@ -2,6 +2,13 @@ package com.sukisu.ultra
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 com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import coil.Coil
import coil.ImageLoader
import com.dergoogler.mmrl.platform.Platform
@@ -14,14 +21,21 @@ 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()
}
Platform.setHiddenApiExemptions()
val context = this
@@ -53,4 +67,6 @@ class KernelSUApplication : Application() {
)
}.build()
}
override val viewModelStore: ViewModelStore
get() = appViewModelStore
}

View File

@@ -3,14 +3,13 @@ package com.sukisu.ultra.ui.viewmodel
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.graphics.drawable.Drawable
import android.os.*
import android.util.Log
import androidx.compose.runtime.*
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import java.io.*
import coil.ImageLoader
import coil.disk.DiskCache
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ksuApp
import com.sukisu.ultra.ui.KsuService
@@ -66,7 +65,15 @@ enum class SortType(val displayNameRes: Int, val persistKey: String) {
class SuperUserViewModel : ViewModel() {
companion object {
private const val TAG = "SuperUserViewModel"
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)
}
private const val PREFS_NAME = "settings"
private const val KEY_SHOW_SYSTEM_APPS = "show_system_apps"
private const val KEY_SELECTED_CATEGORY = "selected_category"
@@ -421,7 +428,7 @@ class SuperUserViewModel : ViewModel() {
}
private fun stopKsuService() {
serviceConnection?.let { connection ->
serviceConnection?.let { _ ->
try {
val intent = Intent(ksuApp, KsuService::class.java)
com.topjohnwu.superuser.ipc.RootService.stop(intent)
@@ -466,6 +473,10 @@ class SuperUserViewModel : ViewModel() {
loadingProgress = start.toFloat() / total
}
synchronized(appsLock) {
apps
}
stopKsuService()
appListMutex.withLock {

View File

@@ -0,0 +1,47 @@
package com.sukisu.ultra.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 com.sukisu.ultra.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;
}
}

View File

@@ -11,18 +11,23 @@ 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 com.dergoogler.mmrl.platform.model.ModId
import com.dergoogler.mmrl.webui.interfaces.WXOptions
import com.sukisu.ultra.ui.util.createRootShell
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import kotlinx.coroutines.launch
import java.io.File
@SuppressLint("SetJavaScriptEnabled")
class WebUIActivity : ComponentActivity() {
private val rootShell by lazy { createRootShell(true) }
private val superUserViewModel: SuperUserViewModel by viewModels()
private var webView = null as WebView?
override fun onCreate(savedInstanceState: Bundle?) {
@@ -35,6 +40,10 @@ class WebUIActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
superUserViewModel.fetchAppList()
}
val moduleId = intent.getStringExtra("id") ?: finishAndRemoveTask().let { return }
val name = intent.getStringExtra("name") ?: finishAndRemoveTask().let { return }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@@ -64,7 +73,21 @@ 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)
}
}

View File

@@ -1,17 +1,20 @@
package com.sukisu.ultra.ui.webui
import android.app.Activity
import android.content.pm.ApplicationInfo
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.view.Window
import android.webkit.JavascriptInterface
import android.widget.Toast
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.dergoogler.mmrl.webui.interfaces.WXInterface
import com.dergoogler.mmrl.webui.interfaces.WXOptions
import com.dergoogler.mmrl.webui.model.JavaScriptInterface
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import com.sukisu.ultra.ui.util.*
import com.topjohnwu.superuser.CallbackList
import com.topjohnwu.superuser.ShellUtils
@@ -138,7 +141,7 @@ class WebViewInterface(
completableFuture.thenAccept { result ->
val emitExitCode =
"(function() { try { ${callbackFunc}.emit('exit', ${result.code}); } catch(e) { console.error(`emitExit error: \${e}`); } })();"
$$"(function() { try { $${callbackFunc}.emit('exit', $${result.code}); } catch(e) { console.error(`emitExit error: ${e}`); } })();"
webView.post {
webView.evaluateJavascript(emitExitCode, null)
}
@@ -203,6 +206,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()
}
// =================== KPM支持 =============================
@JavascriptInterface