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
import android.content.Context
import android.util.Log
import android.webkit.WebResourceResponse
import androidx.annotation.WorkerThread
@@ -7,21 +8,43 @@ import androidx.webkit.WebViewAssetLoader
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.io.SuFile
import com.topjohnwu.superuser.io.SuFileInputStream
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.charset.StandardCharsets
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(
directory: File,
private val mShell: Shell
private val shell: Shell,
private val insetsSupplier: InsetsSupplier
) : WebViewAssetLoader.PathHandler {
private val mDirectory: File
private val directory: File
init {
try {
mDirectory = File(getCanonicalDirPath(directory))
this.directory = File(getCanonicalDirPath(directory))
if (!isAllowedInternalStorageDir()) {
throw IllegalArgumentException(
"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 {
return try {
val dir = getCanonicalDirPath(mDirectory)
val dir = getCanonicalDirPath(directory)
FORBIDDEN_DATA_DIRS.none { dir.startsWith(it) }
} catch (_: IOException) {
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
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 {
val file = getCanonicalFileIfChild(mDirectory, path)
val file = getCanonicalFileIfChild(directory, path)
if (file != null) {
val inputStream = openFile(file, mShell)
val inputStream = openFile(file, shell)
val mimeType = guessMimeType(path)
return WebResourceResponse(mimeType, null, inputStream)
} else {
Log.e(
TAG,
"The requested file: $path is outside the mounted directory: $mDirectory"
"The requested file: $path is outside the mounted directory: $directory"
)
}
} catch (e: IOException) {
Log.e(TAG, "Error opening the requested path: $path", e)
}
return WebResourceResponse(null, null, null)
}
companion object {
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"
/**
* 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")
@JvmStatic
@Throws(IOException::class)
fun getCanonicalDirPath(file: File): String {
val canonicalPath = file.canonicalPath
return if (!canonicalPath.endsWith("/")) "$canonicalPath/" else canonicalPath
var canonicalPath = file.canonicalPath
if (!canonicalPath.endsWith("/")) {
canonicalPath += "/"
}
return canonicalPath
}
@JvmStatic
@Throws(IOException::class)
fun getCanonicalFileIfChild(parent: File, child: String): File? {
return try {
val parentCanonicalPath = getCanonicalDirPath(parent)
val childCanonicalPath = File(parent, child).canonicalPath
if (childCanonicalPath.startsWith(parentCanonicalPath)) {
return if (childCanonicalPath.startsWith(parentCanonicalPath)) {
File(childCanonicalPath)
} else {
null
}
} catch (_: IOException) {
null
}
}
@Throws(IOException::class)
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 {
val suFile = SuFile(file.absolutePath).apply {
setShell(shell)
@@ -101,6 +177,14 @@ class SuFilePathHandler(
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 {
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.app.ActivityManager
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.ViewGroup.MarginLayoutParams
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
@@ -12,7 +12,6 @@ import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
@@ -20,7 +19,6 @@ 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 com.dergoogler.mmrl.platform.model.ModId
@@ -34,6 +32,8 @@ import java.io.File
@SuppressLint("SetJavaScriptEnabled")
class WebUIActivity : ComponentActivity() {
private val rootShell by lazy { createRootShell(true) }
private lateinit var insets: Insets
private var webView = null as WebView?
override fun onCreate(savedInstanceState: Bundle?) {
@@ -77,11 +77,12 @@ class WebUIActivity : ComponentActivity() {
val moduleDir = "/data/adb/modules/${moduleId}"
val webRoot = File("${moduleDir}/webroot")
insets = Insets(0, 0, 0, 0)
val webViewAssetLoader = WebViewAssetLoader.Builder()
.setDomain("mui.kernelsu.org")
.addPathHandler(
"/",
SuFilePathHandler(webRoot, rootShell)
SuFilePathHandler(webRoot, rootShell) { insets }
)
.build()
@@ -111,15 +112,18 @@ class WebUIActivity : ComponentActivity() {
val webView = WebView(this).apply {
webView = this
ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updateLayoutParams<MarginLayoutParams> {
leftMargin = inset.left
rightMargin = inset.right
topMargin = inset.top
bottomMargin = inset.bottom
}
return@setOnApplyWindowInsetsListener insets
setBackgroundColor(Color.TRANSPARENT)
val density = resources.displayMetrics.density
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets ->
val inset = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
insets = Insets(
top = (inset.top / density).toInt(),
bottom = (inset.bottom / density).toInt(),
left = (inset.left / density).toInt(),
right = (inset.right / density).toInt()
)
WindowInsetsCompat.CONSUMED
}
settings.javaScriptEnabled = true
settings.domStorageEnabled = true