diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/Insets.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/Insets.kt new file mode 100644 index 00000000..aabdbe2a --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/Insets.kt @@ -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("}") + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuFilePathHandler.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuFilePathHandler.kt index ab97e636..c0f79308 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuFilePathHandler.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuFilePathHandler.kt @@ -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)) { - File(childCanonicalPath) - } else { - null - } - } catch (_: IOException) { + val parentCanonicalPath = getCanonicalDirPath(parent) + val childCanonicalPath = File(parent, child).canonicalPath + return if (childCanonicalPath.startsWith(parentCanonicalPath)) { + File(childCanonicalPath) + } else { 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 } 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 1abe65fb..91ecd6c2 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 @@ -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 { - 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