From 9635a00036145fb1b4fb1051f0e5d0693de19f0a Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 23 Feb 2024 18:03:36 +0800 Subject: [PATCH] manager: use SuFile to load webview assets to avoid spoofing manager's mount namespace --- manager/app/build.gradle.kts | 1 + .../me/weishu/kernelsu/ui/screen/WebScreen.kt | 203 +----------------- .../java/me/weishu/kernelsu/ui/util/KsuCli.kt | 17 +- .../me/weishu/kernelsu/ui/webui/MimeUtil.java | 139 ++++++++++++ .../kernelsu/ui/webui/SuFilePathHandler.java | 195 +++++++++++++++++ .../kernelsu/ui/webui/WebViewInterface.kt | 192 +++++++++++++++++ manager/gradle/libs.versions.toml | 1 + 7 files changed, 541 insertions(+), 207 deletions(-) create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/webui/MimeUtil.java create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/webui/SuFilePathHandler.java create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/webui/WebViewInterface.kt diff --git a/manager/app/build.gradle.kts b/manager/app/build.gradle.kts index 5ec95f12..bc6c6b5d 100644 --- a/manager/app/build.gradle.kts +++ b/manager/app/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { implementation(libs.com.github.topjohnwu.libsu.core) implementation(libs.com.github.topjohnwu.libsu.service) + implementation(libs.com.github.topjohnwu.libsu.io) implementation(libs.dev.rikka.rikkax.parcelablelist) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/WebScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/WebScreen.kt index 05d72f30..b735b02e 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/WebScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/WebScreen.kt @@ -2,51 +2,32 @@ package me.weishu.kernelsu.ui.screen import android.annotation.SuppressLint import android.app.Activity -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.text.TextUtils -import android.view.Window -import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView -import android.widget.Toast import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import androidx.webkit.WebViewAssetLoader import com.google.accompanist.web.AccompanistWebViewClient import com.google.accompanist.web.WebView import com.google.accompanist.web.rememberWebViewState import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.topjohnwu.superuser.CallbackList -import com.topjohnwu.superuser.ShellUtils -import me.weishu.kernelsu.ui.util.createRootShell -import me.weishu.kernelsu.ui.util.serveModule -import org.json.JSONArray -import org.json.JSONObject +import me.weishu.kernelsu.ui.webui.SuFilePathHandler +import me.weishu.kernelsu.ui.webui.WebViewInterface +import me.weishu.kernelsu.ui.webui.showSystemUI import java.io.File -import java.util.concurrent.CompletableFuture @SuppressLint("SetJavaScriptEnabled") @Destination @Composable fun WebScreen(navigator: DestinationsNavigator, moduleId: String, moduleName: String) { - LaunchedEffect(Unit) { - serveModule(moduleId) - } - val context = LocalContext.current DisposableEffect(Unit) { @@ -58,9 +39,11 @@ fun WebScreen(navigator: DestinationsNavigator, moduleId: String, moduleName: St } Scaffold { innerPadding -> - val webRoot = File(context.dataDir, "webroot") + val webRoot = File("/data/adb/modules/${moduleId}/webroot") val webViewAssetLoader = WebViewAssetLoader.Builder() - .addPathHandler("/", WebViewAssetLoader.InternalStoragePathHandler(context, webRoot)) + .addPathHandler("/", + SuFilePathHandler(context, webRoot) + ) .build() val webViewClient = object : AccompanistWebViewClient() { @@ -86,176 +69,4 @@ fun WebScreen(navigator: DestinationsNavigator, moduleId: String, moduleName: St } }) } -} - -class WebViewInterface(val context: Context, private val webView: WebView) { - - companion object { - var isHideSystemUI: Boolean = false - } - - @JavascriptInterface - fun exec(cmd: String): String { - val shell = createRootShell() - return ShellUtils.fastCmd(shell, cmd) - } - - @JavascriptInterface - fun exec(cmd: String, callbackFunc: String) { - exec(cmd, null, callbackFunc) - } - - private fun processOptions(sb: StringBuilder, options: String?) { - val opts = if (options == null) JSONObject() else { - JSONObject(options) - } - - val cwd = opts.optString("cwd") - if (!TextUtils.isEmpty(cwd)) { - sb.append("cd ${cwd};") - } - - opts.optJSONObject("env")?.let { env -> - env.keys().forEach { key -> - sb.append("export ${key}=${env.getString(key)};") - } - } - } - - @JavascriptInterface - fun exec( - cmd: String, - options: String?, - callbackFunc: String - ) { - val finalCommand = StringBuilder() - processOptions(finalCommand, options) - finalCommand.append(cmd) - - val shell = createRootShell() - val result = shell.newJob().add(finalCommand.toString()).to(ArrayList(), ArrayList()).exec() - val stdout = result.out.joinToString(separator = "\n") - val stderr = result.err.joinToString(separator = "\n") - - val jsCode = - "javascript: (function() { try { ${callbackFunc}(${result.code}, ${ - JSONObject.quote( - stdout - ) - }, ${JSONObject.quote(stderr)}); } catch(e) { console.error(e); } })();" - webView.post { - webView.loadUrl(jsCode) - } - } - - @JavascriptInterface - fun spawn(command: String, args: String, options: String?, callbackFunc: String) { - val finalCommand = StringBuilder() - - processOptions(finalCommand, options) - - if (!TextUtils.isEmpty(args)) { - finalCommand.append(command).append(" ") - JSONArray(args).let { argsArray -> - for (i in 0 until argsArray.length()) { - finalCommand.append(argsArray.getString(i)) - finalCommand.append(" ") - } - } - } else { - finalCommand.append(command) - } - - val shell = createRootShell() - - val emitData = fun(name: String, data: String) { - val jsCode = - "javascript: (function() { try { ${callbackFunc}.${name}.emit('data', ${ - JSONObject.quote( - data - ) - }); } catch(e) { console.error('emitData', e); } })();" - webView.post { - webView.loadUrl(jsCode) - } - } - - val stdout = object : CallbackList() { - override fun onAddElement(s: String) { - emitData("stdout", s) - } - } - - val stderr = object : CallbackList() { - override fun onAddElement(s: String) { - emitData("stderr", s) - } - } - - val future = shell.newJob().add(finalCommand.toString()).to(stdout, stderr).enqueue() - val completableFuture = CompletableFuture.supplyAsync { - future.get() - } - - completableFuture.thenAccept { result -> - val emitExitCode = - "javascript: (function() { try { ${callbackFunc}.emit('exit', ${result.code}); } catch(e) { console.error(`emitExit error: \${e}`); } })();" - webView.post { - webView.loadUrl(emitExitCode) - } - - if (result.code != 0) { - val emitErrCode = - "javascript: (function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${ - JSONObject.quote( - result.err.joinToString( - "\n" - ) - ) - };${callbackFunc}.emit('error', err); } catch(e) { console.error('emitErr', e); } })();" - webView.post { - webView.loadUrl(emitErrCode) - } - } - } - } - - @JavascriptInterface - fun toast(msg: String) { - webView.post { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - } - - @JavascriptInterface - fun fullScreen(enable: Boolean) { - if (context is Activity) { - Handler(Looper.getMainLooper()).post { - if (enable) { - hideSystemUI(context.window) - } else { - showSystemUI(context.window) - } - isHideSystemUI = enable - } - } - } - -} - -private fun hideSystemUI(window: Window) { - WindowCompat.setDecorFitsSystemWindows(window, false) - WindowInsetsControllerCompat(window, window.decorView).let { controller -> - controller.hide(WindowInsetsCompat.Type.systemBars()) - controller.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } -} - -private fun showSystemUI(window: Window) { - WindowCompat.setDecorFitsSystemWindows(window, true) - WindowInsetsControllerCompat( - window, - window.decorView - ).show(WindowInsetsCompat.Type.systemBars()) } \ No newline at end of file diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt index 087f246d..d97f0f94 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt @@ -31,11 +31,15 @@ fun getRootShell(): Shell { return KsuCli.SHELL } -fun createRootShell(): Shell { +fun createRootShell(globalMnt: Boolean = false): Shell { Shell.enableVerboseLogging = BuildConfig.DEBUG val builder = Shell.Builder.create() return try { - builder.build(getKsuDaemonPath(), "debug", "su") + if (globalMnt) { + builder.build(getKsuDaemonPath(), "debug", "su", "-g") + } else { + builder.build(getKsuDaemonPath(), "debug", "su") + } } catch (e: Throwable) { Log.e(TAG, "su failed: ", e) builder.build("sh") @@ -131,15 +135,6 @@ fun installModule( } } -fun serveModule(id: String): Boolean { - // we should use a new root shell to avoid blocking the global shell - val shell = createRootShell() - return ShellUtils.fastCmdResult( - shell, - "${getKsuDaemonPath()} module link-manager $id ${android.os.Process.myPid()} ${BuildConfig.APPLICATION_ID}" - ) -} - fun reboot(reason: String = "") { val shell = getRootShell() if (reason == "recovery") { diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/MimeUtil.java b/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/MimeUtil.java new file mode 100644 index 00000000..79ba2ff6 --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/MimeUtil.java @@ -0,0 +1,139 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.weishu.kernelsu.ui.webui; + +import java.net.URLConnection; + +class MimeUtil { + + public static String getMimeFromFileName(String fileName) { + if (fileName == null) { + return null; + } + + // Copying the logic and mapping that Chromium follows. + // First we check against the OS (this is a limited list by default) + // but app developers can extend this. + // We then check against a list of hardcoded mime types above if the + // OS didn't provide a result. + String mimeType = URLConnection.guessContentTypeFromName(fileName); + + if (mimeType != null) { + return mimeType; + } + + return guessHardcodedMime(fileName); + } + + // We should keep this map in sync with the lists under + // //net/base/mime_util.cc in Chromium. + // A bunch of the mime types don't really apply to Android land + // like word docs so feel free to filter out where necessary. + private static String guessHardcodedMime(String fileName) { + int finalFullStop = fileName.lastIndexOf('.'); + if (finalFullStop == -1) { + return null; + } + + final String extension = fileName.substring(finalFullStop + 1).toLowerCase(); + + switch (extension) { + case "webm": + return "video/webm"; + case "mpeg": + case "mpg": + return "video/mpeg"; + case "mp3": + return "audio/mpeg"; + case "wasm": + return "application/wasm"; + case "xhtml": + case "xht": + case "xhtm": + return "application/xhtml+xml"; + case "flac": + return "audio/flac"; + case "ogg": + case "oga": + case "opus": + return "audio/ogg"; + case "wav": + return "audio/wav"; + case "m4a": + return "audio/x-m4a"; + case "gif": + return "image/gif"; + case "jpeg": + case "jpg": + case "jfif": + case "pjpeg": + case "pjp": + return "image/jpeg"; + case "png": + return "image/png"; + case "apng": + return "image/apng"; + case "svg": + case "svgz": + return "image/svg+xml"; + case "webp": + return "image/webp"; + case "mht": + case "mhtml": + return "multipart/related"; + case "css": + return "text/css"; + case "html": + case "htm": + case "shtml": + case "shtm": + case "ehtml": + return "text/html"; + case "js": + case "mjs": + return "application/javascript"; + case "xml": + return "text/xml"; + case "mp4": + case "m4v": + return "video/mp4"; + case "ogv": + case "ogm": + return "video/ogg"; + case "ico": + return "image/x-icon"; + case "woff": + return "application/font-woff"; + case "gz": + case "tgz": + return "application/gzip"; + case "json": + return "application/json"; + case "pdf": + return "application/pdf"; + case "zip": + return "application/zip"; + case "bmp": + return "image/bmp"; + case "tiff": + case "tif": + return "image/tiff"; + default: + return null; + } + } +} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/SuFilePathHandler.java b/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/SuFilePathHandler.java new file mode 100644 index 00000000..824cac1f --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/SuFilePathHandler.java @@ -0,0 +1,195 @@ +package me.weishu.kernelsu.ui.webui; + +import android.content.Context; +import android.util.Log; +import android.webkit.WebResourceResponse; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.webkit.WebViewAssetLoader; + +import com.topjohnwu.superuser.Shell; +import com.topjohnwu.superuser.io.SuFile; +import com.topjohnwu.superuser.io.SuFileInputStream; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPInputStream; + +import me.weishu.kernelsu.ui.util.KsuCliKt; + +/** + * 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. + *

+ * To avoid leaking user or app data to the web, make sure to choose {@code 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: + *

+ * File publicDir = new File(context.getFilesDir(), "public");
+ * // Host "files/public/" in app's data directory under:
+ * // http://appassets.androidplatform.net/public/...
+ * WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
+ *          .addPathHandler("/public/", new InternalStoragePathHandler(context, publicDir))
+ *          .build();
+ * 
+ */ +public final class SuFilePathHandler implements WebViewAssetLoader.PathHandler { + private static final String TAG = "SuFilePathHandler"; + + /** + * Default value to be used as MIME type if guessing MIME type failed. + */ + public static final String DEFAULT_MIME_TYPE = "text/plain"; + + /** + * Forbidden subdirectories of {@link 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 static final String[] FORBIDDEN_DATA_DIRS = + new String[] {"/data/data", "/data/system"}; + + @NonNull + private final File mDirectory; + + private final Shell mShell; + + /** + * Creates PathHandler for app's internal storage. + * The directory to be exposed must be inside either the application's internal data + * directory {@link Context#getDataDir} or cache directory {@link Context#getCacheDir}. + * External storage is not supported for security reasons, as other apps with + * {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} may be able to modify the + * files. + *

+ * Exposing the entire data or cache directory is not permitted, to avoid accidentally + * exposing sensitive application files to the web. Certain existing subdirectories of + * {@link Context#getDataDir} are also not permitted as they are often sensitive. + * These files are ({@code "app_webview/"}, {@code "databases/"}, {@code "lib/"}, + * {@code "shared_prefs/"} and {@code "code_cache/"}). + *

+ * The application should typically use a dedicated subdirectory for the files it intends to + * expose and keep them separate from other files. + * + * @param context {@link Context} that is used to access app's internal storage. + * @param directory the absolute path of the exposed app internal storage directory from + * which files can be loaded. + * @throws IllegalArgumentException if the directory is not allowed. + */ + public SuFilePathHandler(@NonNull Context context, @NonNull File directory) { + try { + mDirectory = new File(getCanonicalDirPath(directory)); + if (!isAllowedInternalStorageDir(context)) { + throw new IllegalArgumentException("The given directory \"" + directory + + "\" doesn't exist under an allowed app internal storage directory"); + } + mShell = KsuCliKt.createRootShell(true); + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to resolve the canonical path for the given directory: " + + directory.getPath(), e); + } + } + + private boolean isAllowedInternalStorageDir(@NonNull Context context) throws IOException { + String dir = getCanonicalDirPath(mDirectory); + + for (String forbiddenPath : FORBIDDEN_DATA_DIRS) { + if (dir.startsWith(forbiddenPath)) { + return false; + } + } + return true; + } + + /** + * 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 + * {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be + * returned instead of {@code null}. This saves the time of falling back to network and + * trying to resolve a path that doesn't exist. A {@link WebResourceResponse} with + * {@code null} {@link InputStream} will be received as an HTTP response with status code + * {@code 404} and no body. + *

+ * The MIME type for the file will be determined from the file's extension using + * {@link java.net.URLConnection#guessContentTypeFromName}. Developers should ensure that + * files are named using standard file extensions. If the file does not have a + * recognised extension, {@code "text/plain"} will be used by default. + * + * @param path the suffix path to be handled. + * @return {@link WebResourceResponse} for the requested file. + */ + @Override + @WorkerThread + @NonNull + public WebResourceResponse handle(@NonNull String path) { + try { + File file = getCanonicalFileIfChild(mDirectory, path); + if (file != null) { + InputStream is = openFile(file, mShell); + String mimeType = guessMimeType(path); + return new WebResourceResponse(mimeType, null, is); + } else { + Log.e(TAG, String.format( + "The requested file: %s is outside the mounted directory: %s", path, + mDirectory)); + } + } catch (IOException e) { + Log.e(TAG, "Error opening the requested path: " + path, e); + } + return new WebResourceResponse(null, null, null); + } + + public static String getCanonicalDirPath(@NonNull File file) throws IOException { + String canonicalPath = file.getCanonicalPath(); + if (!canonicalPath.endsWith("/")) canonicalPath += "/"; + return canonicalPath; + } + + public static File getCanonicalFileIfChild(@NonNull File parent, @NonNull String child) + throws IOException { + String parentCanonicalPath = getCanonicalDirPath(parent); + String childCanonicalPath = new File(parent, child).getCanonicalPath(); + if (childCanonicalPath.startsWith(parentCanonicalPath)) { + return new File(childCanonicalPath); + } + return null; + } + + @NonNull + private static InputStream handleSvgzStream(@NonNull String path, + @NonNull InputStream stream) throws IOException { + return path.endsWith(".svgz") ? new GZIPInputStream(stream) : stream; + } + + public static InputStream openFile(@NonNull File file, @NonNull Shell shell) throws FileNotFoundException, + IOException { + SuFile suFile = new SuFile(file.getAbsolutePath()); + suFile.setShell(shell); + InputStream fis = SuFileInputStream.open(suFile); + return handleSvgzStream(file.getPath(), fis); + } + + /** + * Use {@link MimeUtil#getMimeFromFileName} to guess MIME type or return the + * {@link #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 {@link #DEFAULT_MIME_TYPE}. + */ + @NonNull + public static String guessMimeType(@NonNull String filePath) { + String mimeType = MimeUtil.getMimeFromFileName(filePath); + return mimeType == null ? DEFAULT_MIME_TYPE : mimeType; + } +} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/WebViewInterface.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/WebViewInterface.kt new file mode 100644 index 00000000..c8e10ebc --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/webui/WebViewInterface.kt @@ -0,0 +1,192 @@ +package me.weishu.kernelsu.ui.webui + +import android.app.Activity +import android.content.Context +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.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.topjohnwu.superuser.CallbackList +import com.topjohnwu.superuser.ShellUtils +import me.weishu.kernelsu.ui.util.createRootShell +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.CompletableFuture + +class WebViewInterface(val context: Context, private val webView: WebView) { + + companion object { + var isHideSystemUI: Boolean = false + } + + @JavascriptInterface + fun exec(cmd: String): String { + val shell = createRootShell() + return ShellUtils.fastCmd(shell, cmd) + } + + @JavascriptInterface + fun exec(cmd: String, callbackFunc: String) { + exec(cmd, null, callbackFunc) + } + + private fun processOptions(sb: StringBuilder, options: String?) { + val opts = if (options == null) JSONObject() else { + JSONObject(options) + } + + val cwd = opts.optString("cwd") + if (!TextUtils.isEmpty(cwd)) { + sb.append("cd ${cwd};") + } + + opts.optJSONObject("env")?.let { env -> + env.keys().forEach { key -> + sb.append("export ${key}=${env.getString(key)};") + } + } + } + + @JavascriptInterface + fun exec( + cmd: String, + options: String?, + callbackFunc: String + ) { + val finalCommand = StringBuilder() + processOptions(finalCommand, options) + finalCommand.append(cmd) + + val shell = createRootShell() + val result = shell.newJob().add(finalCommand.toString()).to(ArrayList(), ArrayList()).exec() + val stdout = result.out.joinToString(separator = "\n") + val stderr = result.err.joinToString(separator = "\n") + + val jsCode = + "javascript: (function() { try { ${callbackFunc}(${result.code}, ${ + JSONObject.quote( + stdout + ) + }, ${JSONObject.quote(stderr)}); } catch(e) { console.error(e); } })();" + webView.post { + webView.loadUrl(jsCode) + } + } + + @JavascriptInterface + fun spawn(command: String, args: String, options: String?, callbackFunc: String) { + val finalCommand = StringBuilder() + + processOptions(finalCommand, options) + + if (!TextUtils.isEmpty(args)) { + finalCommand.append(command).append(" ") + JSONArray(args).let { argsArray -> + for (i in 0 until argsArray.length()) { + finalCommand.append(argsArray.getString(i)) + finalCommand.append(" ") + } + } + } else { + finalCommand.append(command) + } + + val shell = createRootShell() + + val emitData = fun(name: String, data: String) { + val jsCode = + "javascript: (function() { try { ${callbackFunc}.${name}.emit('data', ${ + JSONObject.quote( + data + ) + }); } catch(e) { console.error('emitData', e); } })();" + webView.post { + webView.loadUrl(jsCode) + } + } + + val stdout = object : CallbackList() { + override fun onAddElement(s: String) { + emitData("stdout", s) + } + } + + val stderr = object : CallbackList() { + override fun onAddElement(s: String) { + emitData("stderr", s) + } + } + + val future = shell.newJob().add(finalCommand.toString()).to(stdout, stderr).enqueue() + val completableFuture = CompletableFuture.supplyAsync { + future.get() + } + + completableFuture.thenAccept { result -> + val emitExitCode = + "javascript: (function() { try { ${callbackFunc}.emit('exit', ${result.code}); } catch(e) { console.error(`emitExit error: \${e}`); } })();" + webView.post { + webView.loadUrl(emitExitCode) + } + + if (result.code != 0) { + val emitErrCode = + "javascript: (function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${ + JSONObject.quote( + result.err.joinToString( + "\n" + ) + ) + };${callbackFunc}.emit('error', err); } catch(e) { console.error('emitErr', e); } })();" + webView.post { + webView.loadUrl(emitErrCode) + } + } + } + } + + @JavascriptInterface + fun toast(msg: String) { + webView.post { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + + @JavascriptInterface + fun fullScreen(enable: Boolean) { + if (context is Activity) { + Handler(Looper.getMainLooper()).post { + if (enable) { + hideSystemUI(context.window) + } else { + showSystemUI(context.window) + } + isHideSystemUI = enable + } + } + } + +} + +fun hideSystemUI(window: Window) { + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } +} + +fun showSystemUI(window: Window) { + WindowCompat.setDecorFitsSystemWindows(window, true) + WindowInsetsControllerCompat( + window, + window.decorView + ).show(WindowInsetsCompat.Type.systemBars()) +} \ No newline at end of file diff --git a/manager/gradle/libs.versions.toml b/manager/gradle/libs.versions.toml index 11873e1b..51fa56d0 100644 --- a/manager/gradle/libs.versions.toml +++ b/manager/gradle/libs.versions.toml @@ -45,6 +45,7 @@ com-google-accompanist-webview = { group = "com.google.accompanist", name = "acc com-github-topjohnwu-libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } com-github-topjohnwu-libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" } +com-github-topjohnwu-libsu-io= { group = "com.github.topjohnwu.libsu", name = "io", version.ref = "libsu" } dev-rikka-rikkax-parcelablelist = { module = "dev.rikka.rikkax.parcelablelist:parcelablelist", version = "2.0.1" }