manager: add inset support to webui (#2952)

ref:
https://github.com/MMRLApp/WebUI-X-Portable/blob/master/webui/src/main/kotlin/com/dergoogler/mmrl/webui/model/Insets.kt

Co-Authored-By: Der_Googler
<54764558+dergoogler@users.noreply.github.com>
Signed-off-by: KOWX712 <leecc0503@gmail.com>

---------

Co-authored-by: KOWX712 <leecc0503@gmail.com>
Co-authored-by: Der_Googler <54764558+dergoogler@users.noreply.github.com>
Co-authored-by: Light_summer <93428659+lightsummer233@users.noreply.github.com>
This commit is contained in:
ShirkNeko
2025-11-19 14:23:49 +08:00
parent 429874b4d6
commit 2ea748dac1
3 changed files with 160 additions and 32 deletions

View File

@@ -0,0 +1,40 @@
package com.sukisu.ultra.ui.webui
/**
* Insets data class from GitHub@MMRLApp/WebUI-X-Portable
*
* Data class representing insets (top, bottom, left, right) for a view.
*
* This class provides methods to generate CSS code that can be injected into a WebView
* to apply these insets as CSS variables. This is useful for adapting web content
* to the safe areas of a device screen, considering notches, status bars, and navigation bars.
*
* @property top The top inset value in pixels.
* @property bottom The bottom inset value in pixels.
* @property left The left inset value in pixels.
* @property right The right inset value in pixels.
*/
data class Insets(
val top: Int,
val bottom: Int,
val left: Int,
val right: Int,
) {
val css
get() = buildString {
appendLine(":root {")
appendLine("\t--safe-area-inset-top: ${top}px;")
appendLine("\t--safe-area-inset-right: ${right}px;")
appendLine("\t--safe-area-inset-bottom: ${bottom}px;")
appendLine("\t--safe-area-inset-left: ${left}px;")
appendLine("\t--window-inset-top: var(--safe-area-inset-top, 0px);")
appendLine("\t--window-inset-bottom: var(--safe-area-inset-bottom, 0px);")
appendLine("\t--window-inset-left: var(--safe-area-inset-left, 0px);")
appendLine("\t--window-inset-right: var(--safe-area-inset-right, 0px);")
appendLine("\t--f7-safe-area-top: var(--window-inset-top, 0px) !important;")
appendLine("\t--f7-safe-area-bottom: var(--window-inset-bottom, 0px) !important;")
appendLine("\t--f7-safe-area-left: var(--window-inset-left, 0px) !important;")
appendLine("\t--f7-safe-area-right: var(--window-inset-right, 0px) !important;")
append("}")
}
}

View File

@@ -1,5 +1,6 @@
package com.sukisu.ultra.ui.webui package com.sukisu.ultra.ui.webui
import android.content.Context
import android.util.Log import android.util.Log
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
@@ -7,21 +8,43 @@ import androidx.webkit.WebViewAssetLoader
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.io.SuFile import com.topjohnwu.superuser.io.SuFile
import com.topjohnwu.superuser.io.SuFileInputStream import com.topjohnwu.superuser.io.SuFileInputStream
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.nio.charset.StandardCharsets
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
/**
* Handler class to open files from file system by root access
* For more information about android storage please refer to
* [Android Developers Docs: Data and file storage overview](https://developer.android.com/guide/topics/data/data-storage).
*
* To avoid leaking user or app data to the web, make sure to choose [directory]
* carefully, and assume any file under this directory could be accessed by any web page subject
* to same-origin rules.
*
* A typical usage would be like:
* ```
* val publicDir = File(context.filesDir, "public")
* // Host "files/public/" in app's data directory under:
* // http://appassets.androidplatform.net/public/...
* val assetLoader = WebViewAssetLoader.Builder()
* .addPathHandler("/public/", SuFilePathHandler(context, publicDir, shell, insetsSupplier))
* .build()
* ```
*/
class SuFilePathHandler( class SuFilePathHandler(
directory: File, directory: File,
private val mShell: Shell private val shell: Shell,
private val insetsSupplier: InsetsSupplier
) : WebViewAssetLoader.PathHandler { ) : WebViewAssetLoader.PathHandler {
private val mDirectory: File private val directory: File
init { init {
try { try {
mDirectory = File(getCanonicalDirPath(directory)) this.directory = File(getCanonicalDirPath(directory))
if (!isAllowedInternalStorageDir()) { if (!isAllowedInternalStorageDir()) {
throw IllegalArgumentException( throw IllegalArgumentException(
"The given directory \"$directory\" doesn't exist under an allowed app internal storage directory" "The given directory \"$directory\" doesn't exist under an allowed app internal storage directory"
@@ -35,64 +58,117 @@ class SuFilePathHandler(
} }
} }
fun interface InsetsSupplier {
fun get(): Insets
}
private fun isAllowedInternalStorageDir(): Boolean { private fun isAllowedInternalStorageDir(): Boolean {
return try { return try {
val dir = getCanonicalDirPath(mDirectory) val dir = getCanonicalDirPath(directory)
FORBIDDEN_DATA_DIRS.none { dir.startsWith(it) } FORBIDDEN_DATA_DIRS.none { dir.startsWith(it) }
} catch (_: IOException) { } catch (_: IOException) {
false false
} }
} }
/**
* Opens the requested file from the exposed data directory.
*
* The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
* requested file cannot be found or is outside the mounted directory a
* [WebResourceResponse] object with a `null` [InputStream] will be
* returned instead of `null`. This saves the time of falling back to network and
* trying to resolve a path that doesn't exist. A [WebResourceResponse] with
* `null` [InputStream] will be received as an HTTP response with status code
* `404` and no body.
*
* The MIME type for the file will be determined from the file's extension using
* [java.net.URLConnection.guessContentTypeFromName]. Developers should ensure that
* files are named using standard file extensions. If the file does not have a
* recognised extension, `"text/plain"` will be used by default.
*
* @param path the suffix path to be handled.
* @return [WebResourceResponse] for the requested file.
*/
@WorkerThread @WorkerThread
override fun handle(path: String): WebResourceResponse { override fun handle(path: String): WebResourceResponse {
if (path == "internal/insets.css") {
val css = insetsSupplier.get().css
return WebResourceResponse(
"text/css",
"utf-8",
ByteArrayInputStream(css.toByteArray(StandardCharsets.UTF_8))
)
}
try { try {
val file = getCanonicalFileIfChild(mDirectory, path) val file = getCanonicalFileIfChild(directory, path)
if (file != null) { if (file != null) {
val inputStream = openFile(file, mShell) val inputStream = openFile(file, shell)
val mimeType = guessMimeType(path) val mimeType = guessMimeType(path)
return WebResourceResponse(mimeType, null, inputStream) return WebResourceResponse(mimeType, null, inputStream)
} else { } else {
Log.e( Log.e(
TAG, TAG,
"The requested file: $path is outside the mounted directory: $mDirectory" "The requested file: $path is outside the mounted directory: $directory"
) )
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error opening the requested path: $path", e) Log.e(TAG, "Error opening the requested path: $path", e)
} }
return WebResourceResponse(null, null, null) return WebResourceResponse(null, null, null)
} }
companion object { companion object {
private const val TAG = "SuFilePathHandler" private const val TAG = "SuFilePathHandler"
/**
* Default value to be used as MIME type if guessing MIME type failed.
*/
const val DEFAULT_MIME_TYPE = "text/plain" const val DEFAULT_MIME_TYPE = "text/plain"
/**
* Forbidden subdirectories of [Context.getDataDir] that cannot be exposed by this
* handler. They are forbidden as they often contain sensitive information.
*
* Note: Any future addition to this list will be considered breaking changes to the API.
*/
private val FORBIDDEN_DATA_DIRS = arrayOf("/data/data", "/data/system") private val FORBIDDEN_DATA_DIRS = arrayOf("/data/data", "/data/system")
@JvmStatic
@Throws(IOException::class)
fun getCanonicalDirPath(file: File): String { fun getCanonicalDirPath(file: File): String {
val canonicalPath = file.canonicalPath var canonicalPath = file.canonicalPath
return if (!canonicalPath.endsWith("/")) "$canonicalPath/" else canonicalPath if (!canonicalPath.endsWith("/")) {
canonicalPath += "/"
}
return canonicalPath
} }
@JvmStatic
@Throws(IOException::class)
fun getCanonicalFileIfChild(parent: File, child: String): File? { fun getCanonicalFileIfChild(parent: File, child: String): File? {
return try { val parentCanonicalPath = getCanonicalDirPath(parent)
val parentCanonicalPath = getCanonicalDirPath(parent) val childCanonicalPath = File(parent, child).canonicalPath
val childCanonicalPath = File(parent, child).canonicalPath return if (childCanonicalPath.startsWith(parentCanonicalPath)) {
if (childCanonicalPath.startsWith(parentCanonicalPath)) { File(childCanonicalPath)
File(childCanonicalPath) } else {
} else {
null
}
} catch (_: IOException) {
null null
} }
} }
@Throws(IOException::class)
private fun handleSvgzStream(path: String, stream: InputStream): InputStream { private fun handleSvgzStream(path: String, stream: InputStream): InputStream {
return if (path.endsWith(".svgz")) GZIPInputStream(stream) else stream return if (path.endsWith(".svgz")) {
GZIPInputStream(stream)
} else {
stream
}
} }
@JvmStatic
@Throws(IOException::class)
fun openFile(file: File, shell: Shell): InputStream { fun openFile(file: File, shell: Shell): InputStream {
val suFile = SuFile(file.absolutePath).apply { val suFile = SuFile(file.absolutePath).apply {
setShell(shell) setShell(shell)
@@ -101,6 +177,14 @@ class SuFilePathHandler(
return handleSvgzStream(file.path, fis) return handleSvgzStream(file.path, fis)
} }
/**
* Use [MimeUtil.getMimeFromFileName] to guess MIME type or return the
* [DEFAULT_MIME_TYPE] if it can't guess.
*
* @param filePath path of the file to guess its MIME type.
* @return MIME type guessed from file extension or [DEFAULT_MIME_TYPE].
*/
@JvmStatic
fun guessMimeType(filePath: String): String { fun guessMimeType(filePath: String): String {
return MimeUtil.getMimeFromFileName(filePath) ?: DEFAULT_MIME_TYPE return MimeUtil.getMimeFromFileName(filePath) ?: DEFAULT_MIME_TYPE
} }

View File

@@ -2,9 +2,9 @@ package com.sukisu.ultra.ui.webui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.ActivityManager import android.app.ActivityManager
import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup.MarginLayoutParams
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
@@ -12,7 +12,6 @@ import android.webkit.WebViewClient
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -20,7 +19,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.lifecycle.lifecycleScope 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
@@ -34,6 +32,8 @@ 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 lateinit var insets: Insets
private var webView = null as WebView? private var webView = null as WebView?
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -77,11 +77,12 @@ class WebUIActivity : ComponentActivity() {
val moduleDir = "/data/adb/modules/${moduleId}" val moduleDir = "/data/adb/modules/${moduleId}"
val webRoot = File("${moduleDir}/webroot") val webRoot = File("${moduleDir}/webroot")
insets = Insets(0, 0, 0, 0)
val webViewAssetLoader = WebViewAssetLoader.Builder() val webViewAssetLoader = WebViewAssetLoader.Builder()
.setDomain("mui.kernelsu.org") .setDomain("mui.kernelsu.org")
.addPathHandler( .addPathHandler(
"/", "/",
SuFilePathHandler(webRoot, rootShell) SuFilePathHandler(webRoot, rootShell) { insets }
) )
.build() .build()
@@ -111,15 +112,18 @@ class WebUIActivity : ComponentActivity() {
val webView = WebView(this).apply { val webView = WebView(this).apply {
webView = this webView = this
ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> setBackgroundColor(Color.TRANSPARENT)
val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val density = resources.displayMetrics.density
view.updateLayoutParams<MarginLayoutParams> {
leftMargin = inset.left ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets ->
rightMargin = inset.right val inset = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
topMargin = inset.top insets = Insets(
bottomMargin = inset.bottom top = (inset.top / density).toInt(),
} bottom = (inset.bottom / density).toInt(),
return@setOnApplyWindowInsetsListener insets left = (inset.left / density).toInt(),
right = (inset.right / density).toInt()
)
WindowInsetsCompat.CONSUMED
} }
settings.javaScriptEnabled = true settings.javaScriptEnabled = true
settings.domStorageEnabled = true settings.domStorageEnabled = true