diff --git a/manager/app/src/main/java/com/sukisu/ultra/KernelSUApplication.kt b/manager/app/src/main/java/com/sukisu/ultra/KernelSUApplication.kt index 007c5b00..2f587d13 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/KernelSUApplication.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/KernelSUApplication.kt @@ -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 } \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt index 88629a06..f0f98101 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt @@ -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>(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 { diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/AppIconUtil.java b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/AppIconUtil.java new file mode 100644 index 00000000..495be9f2 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/AppIconUtil.java @@ -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 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; + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIActivity.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIActivity.kt index f1b77734..8a924df3 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIActivity.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIActivity.kt @@ -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) } } diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebViewInterface.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebViewInterface.kt index 23351042..1e271046 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebViewInterface.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebViewInterface.kt @@ -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