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:
@@ -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("}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user