Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src/main/java/me/bmax/apatch/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
Expand All @@ -48,6 +49,7 @@ import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import me.bmax.apatch.APApplication
import me.bmax.apatch.ui.screen.BottomBarDestination
import me.bmax.apatch.ui.theme.APatchTheme
import me.bmax.apatch.ui.viewmodel.SuperUserViewModel
import me.bmax.apatch.util.ui.LocalSnackbarHost
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
Expand Down Expand Up @@ -76,6 +78,12 @@ class MainActivity : AppCompatActivity() {
BottomBarDestination.entries.map { it.direction.route }.toSet()
}

LaunchedEffect(Unit) {
if (SuperUserViewModel.apps.isEmpty()) {
SuperUserViewModel().fetchAppList()
}
}

Scaffold(
bottomBar = { BottomBar(navController) }
) { _ ->
Expand Down
51 changes: 50 additions & 1 deletion app/src/main/java/me/bmax/apatch/ui/WebUIActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,25 @@ import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 me.bmax.apatch.APApplication
import me.bmax.apatch.ui.theme.APatchTheme
import me.bmax.apatch.ui.viewmodel.SuperUserViewModel
import me.bmax.apatch.ui.webui.AppIconUtil
import me.bmax.apatch.ui.webui.SuFilePathHandler
import me.bmax.apatch.ui.webui.WebViewInterface
import java.io.File
Expand All @@ -33,6 +46,26 @@ class WebUIActivity : ComponentActivity() {

super.onCreate(savedInstanceState)

setContent {
APatchTheme {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}

lifecycleScope.launch {
if (SuperUserViewModel.apps.isEmpty()) {
SuperUserViewModel().fetchAppList()
}
setupWebView()
}
}

private fun setupWebView() {
val moduleId = intent.getStringExtra("id")!!
val name = intent.getStringExtra("name")!!
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Expand Down Expand Up @@ -60,7 +93,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
3 changes: 2 additions & 1 deletion app/src/main/java/me/bmax/apatch/ui/screen/SuperUser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import kotlinx.coroutines.launch
import me.bmax.apatch.APApplication
import me.bmax.apatch.Natives
import me.bmax.apatch.R
import me.bmax.apatch.apApp
import me.bmax.apatch.ui.component.ProvideMenuShape
import me.bmax.apatch.ui.component.SearchAppBar
import me.bmax.apatch.ui.component.SwitchItem
Expand Down Expand Up @@ -130,7 +131,7 @@ fun SuperUserScreen() {
isRefreshing = viewModel.isRefreshing
) {
LazyColumn(Modifier.fillMaxSize()) {
items(viewModel.appList, key = { it.packageName + it.uid }) { app ->
items(viewModel.appList.filter { it.packageName != apApp.packageName }, key = { it.packageName + it.uid }) { app ->
AppItem(app)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package me.bmax.apatch.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.util.Log
Expand Down Expand Up @@ -34,7 +36,14 @@ import kotlin.coroutines.suspendCoroutine
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())

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)
}
}

@Parcelize
Expand Down Expand Up @@ -128,7 +137,7 @@ class SuperUserViewModel : ViewModel() {

Log.d(TAG, "all configs: $configs")

apps = allPackages.list.map {
val newApps = allPackages.list.map {
val appInfo = it.applicationInfo
val uid = appInfo!!.uid
val actProfile = if (uids.contains(uid)) Natives.suProfile(uid) else null
Expand All @@ -147,7 +156,11 @@ class SuperUserViewModel : ViewModel() {
packageInfo = it,
config = config
)
}.filter { it.packageName != apApp.packageName }
}

synchronized(appsLock) {
apps = newApps
}
}
}
}
46 changes: 46 additions & 0 deletions app/src/main/java/me/bmax/apatch/ui/webui/AppIconUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package me.bmax.apatch.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 androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import me.bmax.apatch.ui.viewmodel.SuperUserViewModel.Companion.getAppIconDrawable

object AppIconUtil {
// Limit cache size to 200 icons
private const val CACHE_SIZE = 200
private val iconCache = LruCache<String?, Bitmap?>(CACHE_SIZE)

@Synchronized
fun loadAppIconSync(context: Context, packageName: String, sizePx: Int): Bitmap? {
val cached = iconCache.get(packageName)
if (cached != null) return cached

try {
val drawable = getAppIconDrawable(context, packageName) ?: return null
val raw = drawableToBitmap(drawable, sizePx)
val icon = raw.scale(sizePx, sizePx)
iconCache.put(packageName, icon)
return icon
} catch (_: Exception) {
return null
}
}

private fun drawableToBitmap(drawable: Drawable, size: Int): Bitmap {
if (drawable is BitmapDrawable) return drawable.bitmap

val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else size
val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else size

val bmp = createBitmap(width, height)
val canvas = Canvas(bmp)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bmp
}
}
52 changes: 52 additions & 0 deletions app/src/main/java/me/bmax/apatch/ui/webui/WebViewInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ package me.bmax.apatch.ui.webui

import android.app.Activity
import android.content.Context
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.webkit.WebView
import android.widget.Toast
import androidx.core.content.pm.PackageInfoCompat
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 me.bmax.apatch.ui.viewmodel.SuperUserViewModel
import me.bmax.apatch.util.createRootShell
import org.json.JSONArray
import org.json.JSONObject
Expand Down Expand Up @@ -162,6 +165,55 @@ class WebViewInterface(val context: Context, private val webView: WebView) {
}
}

@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