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.app.Application
import android.system.Os 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.Coil
import coil.ImageLoader import coil.ImageLoader
import com.dergoogler.mmrl.platform.Platform import com.dergoogler.mmrl.platform.Platform
@@ -14,14 +21,21 @@ import java.util.Locale
lateinit var ksuApp: KernelSUApplication lateinit var ksuApp: KernelSUApplication
class KernelSUApplication : Application() { class KernelSUApplication : Application(), ViewModelStoreOwner {
lateinit var okhttpClient: OkHttpClient lateinit var okhttpClient: OkHttpClient
private val appViewModelStore by lazy { ViewModelStore() }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ksuApp = this 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() Platform.setHiddenApiExemptions()
val context = this val context = this
@@ -53,4 +67,6 @@ class KernelSUApplication : Application() {
) )
}.build() }.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.*
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.graphics.drawable.Drawable
import android.os.* import android.os.*
import android.util.Log import android.util.Log
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import java.io.* import java.io.*
import coil.ImageLoader
import coil.disk.DiskCache
import com.sukisu.ultra.Natives import com.sukisu.ultra.Natives
import com.sukisu.ultra.ksuApp import com.sukisu.ultra.ksuApp
import com.sukisu.ultra.ui.KsuService import com.sukisu.ultra.ui.KsuService
@@ -66,7 +65,15 @@ enum class SortType(val displayNameRes: Int, val persistKey: String) {
class SuperUserViewModel : ViewModel() { class SuperUserViewModel : ViewModel() {
companion object { companion object {
private const val TAG = "SuperUserViewModel" private const val TAG = "SuperUserViewModel"
private val appsLock = Any()
var apps by mutableStateOf<List<AppInfo>>(emptyList()) 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 PREFS_NAME = "settings"
private const val KEY_SHOW_SYSTEM_APPS = "show_system_apps" private const val KEY_SHOW_SYSTEM_APPS = "show_system_apps"
private const val KEY_SELECTED_CATEGORY = "selected_category" private const val KEY_SELECTED_CATEGORY = "selected_category"
@@ -421,7 +428,7 @@ class SuperUserViewModel : ViewModel() {
} }
private fun stopKsuService() { private fun stopKsuService() {
serviceConnection?.let { connection -> serviceConnection?.let { _ ->
try { try {
val intent = Intent(ksuApp, KsuService::class.java) val intent = Intent(ksuApp, KsuService::class.java)
com.topjohnwu.superuser.ipc.RootService.stop(intent) com.topjohnwu.superuser.ipc.RootService.stop(intent)
@@ -466,6 +473,10 @@ class SuperUserViewModel : ViewModel() {
loadingProgress = start.toFloat() / total loadingProgress = start.toFloat() / total
} }
synchronized(appsLock) {
apps
}
stopKsuService() stopKsuService()
appListMutex.withLock { 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 android.webkit.WebViewClient
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader
import com.dergoogler.mmrl.platform.model.ModId import com.dergoogler.mmrl.platform.model.ModId
import com.dergoogler.mmrl.webui.interfaces.WXOptions import com.dergoogler.mmrl.webui.interfaces.WXOptions
import com.sukisu.ultra.ui.util.createRootShell import com.sukisu.ultra.ui.util.createRootShell
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import kotlinx.coroutines.launch
import java.io.File import java.io.File
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
class WebUIActivity : ComponentActivity() { class WebUIActivity : ComponentActivity() {
private val rootShell by lazy { createRootShell(true) } private val rootShell by lazy { createRootShell(true) }
private val superUserViewModel: SuperUserViewModel by viewModels()
private var webView = null as WebView? private var webView = null as WebView?
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -35,6 +40,10 @@ class WebUIActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lifecycleScope.launch {
superUserViewModel.fetchAppList()
}
val moduleId = intent.getStringExtra("id") ?: finishAndRemoveTask().let { return } val moduleId = intent.getStringExtra("id") ?: finishAndRemoveTask().let { return }
val name = intent.getStringExtra("name") ?: finishAndRemoveTask().let { return } val name = intent.getStringExtra("name") ?: finishAndRemoveTask().let { return }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@@ -64,7 +73,21 @@ class WebUIActivity : ComponentActivity() {
view: WebView, view: WebView,
request: WebResourceRequest request: WebResourceRequest
): WebResourceResponse? { ): 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 package com.sukisu.ultra.ui.webui
import android.app.Activity import android.app.Activity
import android.content.pm.ApplicationInfo
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.text.TextUtils import android.text.TextUtils
import android.view.Window import android.view.Window
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.widget.Toast import android.widget.Toast
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.dergoogler.mmrl.webui.interfaces.WXInterface import com.dergoogler.mmrl.webui.interfaces.WXInterface
import com.dergoogler.mmrl.webui.interfaces.WXOptions import com.dergoogler.mmrl.webui.interfaces.WXOptions
import com.dergoogler.mmrl.webui.model.JavaScriptInterface import com.dergoogler.mmrl.webui.model.JavaScriptInterface
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import com.sukisu.ultra.ui.util.* import com.sukisu.ultra.ui.util.*
import com.topjohnwu.superuser.CallbackList import com.topjohnwu.superuser.CallbackList
import com.topjohnwu.superuser.ShellUtils import com.topjohnwu.superuser.ShellUtils
@@ -138,7 +141,7 @@ class WebViewInterface(
completableFuture.thenAccept { result -> completableFuture.thenAccept { result ->
val emitExitCode = 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.post {
webView.evaluateJavascript(emitExitCode, null) webView.evaluateJavascript(emitExitCode, null)
} }
@@ -203,6 +206,56 @@ class WebViewInterface(
return currentModuleInfo.toString() 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支持 ============================= // =================== KPM支持 =============================
@JavascriptInterface @JavascriptInterface