diff --git a/.github/actions/build-apk/action.yml b/.github/actions/build-apk/action.yml index 9ef275bb..f15a6559 100644 --- a/.github/actions/build-apk/action.yml +++ b/.github/actions/build-apk/action.yml @@ -2,6 +2,12 @@ name: "Build APK" description: "Builds an APK for a specified flavor and build type." inputs: + username: + description: "Username" + required: true + token: + description: "Token" + required: true flavor: description: "Flavor" required: true @@ -58,6 +64,15 @@ runs: echo ${{ inputs.keyStore }} | base64 --decode > ${{ github.workspace }}/key.jks fi + - name: Set up credentials for private packages + shell: bash + run: | + [ -f gradle.properties ] && [ "$(tail -c1 gradle.properties)" != "" ] && echo >> gradle.properties + { + echo "gpr.user=${{ inputs.username }}" + echo "gpr.key=${{ inputs.token }}" + } >> gradle.properties + - name: Set up JDK uses: actions/setup-java@v4 with: diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 0ed8e5e2..8a310513 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -38,6 +38,8 @@ jobs: id: build-apk uses: ./.github/actions/build-apk with: + username: ${{ secrets.ACTOR }} + token: ${{ secrets.GH_TOKEN }} flavor: ${{ matrix.flavor }} buildType: "release" keyStore: ${{ secrets.KEY_STORE }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee46a77e..59e6b478 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,5 @@ import app.cash.licensee.ViolationAction -import com.android.build.gradle.internal.api.ApkVariantOutputImpl +import com.android.build.api.variant.impl.VariantOutputImpl plugins { alias(libs.plugins.self.application) @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.self.hilt) alias(libs.plugins.licensee) alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) } @@ -54,7 +55,7 @@ android { signingConfigs.getByName("debug") } - flavorDimensions += "distribution" + flavorDimensions += listOf("distribution") productFlavors { create("official") { @@ -64,6 +65,34 @@ android { buildConfigField("Boolean", "IS_SPOOFED_BUILD", "false") } + create("internal") { + dimension = "distribution" + applicationIdSuffix = ".internal" + resValue("string", "app_name", "[ᛁ] $baseAppName") + buildConfigField("Boolean", "IS_SPOOFED_BUILD", "false") + } + + create("alpha") { + dimension = "distribution" + applicationIdSuffix = ".alpha" + resValue("string", "app_name", "[ᚨ] $baseAppName") + buildConfigField("Boolean", "IS_SPOOFED_BUILD", "false") + } + + create("beta") { + dimension = "distribution" + applicationIdSuffix = ".beta" + resValue("string", "app_name", "[ᛒ] $baseAppName") + buildConfigField("Boolean", "IS_SPOOFED_BUILD", "false") + } + + create("rc") { + dimension = "distribution" + applicationIdSuffix = ".rc" + resValue("string", "app_name", "[ᚱ] $baseAppName") + buildConfigField("Boolean", "IS_SPOOFED_BUILD", "false") + } + create("spoofed") { dimension = "distribution" applicationId = generateRandomPackageName() @@ -85,10 +114,9 @@ android { buildConfigField("Boolean", "IS_GOOGLE_PLAY_BUILD", "false") isDebuggable = false isJniDebuggable = false - versionNameSuffix = "-release" renderscriptOptimLevel = 3 multiDexEnabled = true - + versionNameSuffix = "-release" manifestPlaceholders["webuiPermissionId"] = mmrlBaseApplicationId } @@ -103,13 +131,12 @@ android { buildConfigField("Boolean", "IS_DEV_VERSION", "true") buildConfigField("Boolean", "IS_GOOGLE_PLAY_BUILD", "false") applicationIdSuffix = ".debug" - versionNameSuffix = "-debug" isJniDebuggable = true isDebuggable = true renderscriptOptimLevel = 0 isMinifyEnabled = false multiDexEnabled = true - + versionNameSuffix = "-debug" manifestPlaceholders["webuiPermissionId"] = "$mmrlBaseApplicationId.debug" } @@ -126,6 +153,7 @@ android { buildFeatures { buildConfig = true + resValues = true } compileOptions { @@ -133,27 +161,27 @@ android { targetCompatibility = JavaVersion.VERSION_21 } - packaging.resources.excludes += setOf( - "META-INF/**", - "okhttp3/**", - //"kotlin/**", - "org/**", - "**.properties", - "**.bin", - "**/*.proto" - ) + packaging { + resources { + excludes += setOf( + "META-INF/**", + "okhttp3/**", + //"kotlin/**", + "org/**", + "**.properties", + "**.bin", + "**/*.proto" + ) + pickFirsts += listOf( + "tables/**" + ) + } + } dependenciesInfo { includeInApk = false includeInBundle = false } - - applicationVariants.configureEach { - outputs.configureEach { - (this as? ApkVariantOutputImpl)?.outputFileName = - "WebUI-X-$versionName-$flavorName.apk" - } - } } licensee { @@ -161,11 +189,37 @@ licensee { violationAction(ViolationAction.IGNORE) } +androidComponents { + onVariants { variant -> + val variantName = variant.name + + // Use maybeCreate to dynamically generate the source set if it doesn't exist + android.sourceSets.maybeCreate(variantName).apply { + java.srcDirs( + "build/generated/ksp/$variantName/java", + "build/generated/ksp/$variantName/kotlin" + ) + } + + // Your existing APK renaming logic + val distributionFlavor = variant.productFlavors + .firstOrNull { it.first == "distribution" } + ?.second + + variant.outputs.filterIsInstance().forEach { output -> + output.outputFileName.set( + output.versionName.map { vName -> + "WebUI-X-$vName-$distributionFlavor.apk" + } + ) + } + } +} + dependencies { implementation(projects.webui) implementation(projects.modconf) implementation(projects.jna) - implementation(projects.hwui) implementation(libs.mmrl.ext) implementation(libs.mmrl.ui) implementation(libs.mmrl.platform) @@ -180,6 +234,7 @@ dependencies { implementation(libs.androidx.lifecycle.process) implementation(libs.hiddenApiBypass) + implementation(libs.kotlin.parcelize.runtime) implementation(libs.semver) implementation(libs.coil.compose) @@ -247,4 +302,13 @@ dependencies { implementation(libs.composedestinations.core) ksp(libs.composedestinations.ksp) + + implementation(libs.mmrlx.ui) + implementation(libs.mmrlx.utilities) + implementation(libs.mmrlx.webui.core) + implementation(libs.mmrlx.webui.lua) + implementation(libs.mmrlx.nio) + + implementation("com.github.MMRLApp.RootThread:thread:0.0.3") + implementation("org.jsoup:jsoup:1.18.3") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f1892ffb..1ef551cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,8 @@ + @@ -71,9 +76,13 @@ @@ -81,11 +90,36 @@ + + + + + + + + + + + + , +) : Comparable { + val id: String = properties.get("", "id") + val path: ModulePath = ModulePath(adbPath, id) + val webrootConfig: WebrootConfig = WebrootConfig(this) + val name: String = properties.get(NA, "name") + val version: String = properties.get(NA, "version") + val versionCode: Int = properties.get(-1, "versionCode") + val author: String = properties.get(NA, "author") + val description: String = properties.get(NA, "description") + + private val metaModuleBoolean = properties.get(false, "metamodule") + private val metaModuleInt = properties.get(0, "metamodule") + val metaModule: Boolean = metaModuleBoolean || metaModuleInt != 0 + + val banner: SuFile? = properties.get(null, "banner", "cover").relativeModuleOrWebrootDir + val icon: SuFile? = properties.get(null, "webuiIcon", "icon").relativeModuleOrWebrootDir + + + @IgnoredOnParcel + val hasWebUI: Boolean by lazy { + val webroot = SuFile(path.webrootDir) + val index = webroot.resolve("index.html") + webroot.exists() && index.isFile + } +// +// val state: ModuleState by lazy { +// SuFile(path.removeFile).apply { +// if (exists()) return@lazy ModuleState.REMOVE +// } +// +// SuFile(path.disableFile).apply { +// if (exists()) return@lazy ModuleState.DISABLE +// } +// +// SuFile(path.updateFile).apply { +// if (exists()) return@lazy ModuleState.UPDATE +// } +// +// return@lazy ModuleState.ENABLE +// } + + + @IgnoredOnParcel + val state: com.dergoogler.mmrl.platform.content.State by lazy { + SuFile(path.removeFile).apply { + if (exists()) return@lazy com.dergoogler.mmrl.platform.content.State.REMOVE + } + + SuFile(path.disableFile).apply { + if (exists()) return@lazy com.dergoogler.mmrl.platform.content.State.DISABLE + } + + SuFile(path.updateFile).apply { + if (exists()) return@lazy com.dergoogler.mmrl.platform.content.State.UPDATE + } + + return@lazy com.dergoogler.mmrl.platform.content.State.ENABLE + } + + @IgnoredOnParcel + val lastUpdated: Long by lazy { + path.files.map { SuFile(it) }.forEach { + if (it.exists()) { + return@lazy it.lastModified() + } + } + + return@lazy 0L + } + + + @IgnoredOnParcel + val size: Long by lazy { + val directory = SuFile(adbPath.modulesDir, id) + calculateSizeFast(directory) + } + + private fun calculateSizeFast(file: SuFile): Long { + // skip symbolic links entirely + if (file.isSymlink()) return 0L + if (file.isFile) return file.length() + + var totalSize = 0L + val children = file.listFiles() + + for (child in children) { + // skip symlinks before checking if it's a file or directory + if (child.isSymlink()) continue + + totalSize += if (child.isFile) { + child.length() + } else { + calculateSizeFast(child) + } + } + return totalSize + } + + override fun compareTo(other: Module): Int = id.compareTo(other.id) + + private val String?.relativeModuleOrWebrootDir: SuFile? + get() = this?.run { + val webrootFile = SuFile(path.webrootDir, this) + if (webrootFile.exists()) return@run webrootFile + + val moduleFile = SuFile(path.moduleDir, this) + if (moduleFile.exists()) return@run moduleFile + + null + } + + private inline fun Map.get( + defaultValue: T, + vararg aliases: String, + ): T { + for (alias in aliases) { + this[alias]?.let { + return it.asOrDefault(defaultValue) + } + } + + for (alias in aliases) { + val value = webrootConfig.get(alias, defaultValue) + if (value != null) { + return value + } + } + + return defaultValue + } + + fun toLuaTable(): LuaTable { + val table = LuaTable() + + table.set("adbPath", adbPath.toLuaTable()) + table.set("id", id) + table.set("name", name) + table.set("version", version) + table.set("versionCode", versionCode) + table.set("author", author) + table.set("description", description) + table.set("metamodule", metaModule) + table.set("hasWebUI", hasWebUI) + table.set("path", path.toLuaTable()) + // table.set("banner", banner) + // table.set("icon", icon) + // table.set("webrootConfig", webrootConfig.toLuaTable()) + + return table + } + + companion object { + private const val NA = "N/A" + internal fun readProps(input: InputStream): Map { + val result = linkedMapOf() + + input.bufferedReader().useLines { lines -> + lines.forEach { raw -> + val line = raw.trim() + + if (line.isEmpty()) return@forEach + if (line.startsWith("#") || line.startsWith("!")) return@forEach + + var separatorIndex = -1 + var escaped = false + + for (i in line.indices) { + val c = line[i] + + if (escaped) { + escaped = false + continue + } + + if (c == '\\') { + escaped = true + continue + } + + if (c == '=' || c == ':') { + separatorIndex = i + break + } + } + + val key: String + val rawValue: String + + if (separatorIndex >= 0) { + key = line.substring(0, separatorIndex) + .replace("\\=", "=") + .replace("\\:", ":") + .trim() + + rawValue = line.substring(separatorIndex + 1).trim() + } else { + key = line + rawValue = "" + } + + result[key] = parseValue(rawValue) + } + } + + return result + } + + private fun parseValue(value: String): Any { + val v = value.trim() + + return when { + v.equals("true", ignoreCase = true) -> true + v.equals("false", ignoreCase = true) -> false + + v.toIntOrNull() != null -> v.toInt() + + v.toLongOrNull() != null -> v.toLong() + + v.toDoubleOrNull() != null -> v.toDouble() + + else -> v + } + } + + val Empty get() = Module(AdbPath.Empty, emptyMap()) + + @Composable + fun rememberBasePath(): State { + val prefs = LocalUserPreferences.current + val context = LocalContext.current + + return produceState( + initialValue = ModuleUIState.Loading, + prefs.workingMode + ) { + val initialized = SuFile.AutoInit(context) + + if (!initialized) { + value = ModuleUIState.Error.SuInitFailed() + return@produceState + } + + val basePath: String? = prefs.getAdbPath(context) + + if (basePath == null) { + value = ModuleUIState.Error.MissingAdbPath() + return@produceState + } + + val baseFileDir = SuFile.async(basePath) + + if (!baseFileDir.exists()) { + value = ModuleUIState.Error.ModuleNotFound() + return@produceState + } + + value = ModuleUIState.ReadyBasePath(baseFileDir) + } + } + + @Composable + fun rememberCreate(id: String): State { + val prefs = LocalUserPreferences.current + val context = LocalContext.current + + return produceState( + initialValue = ModuleUIState.Loading, + id, + prefs.workingMode + ) { + val initialized = SuFile.AutoInit(context) + + if (!initialized) { + value = ModuleUIState.Error.SuInitFailed() + return@produceState + } + + val basePath: String? = prefs.getAdbPath(context) + + if (basePath == null) { + value = ModuleUIState.Error.MissingAdbPath() + return@produceState + } + + val adbPath = AdbPath(basePath) + + val moduleDir = SuFile.async(adbPath.modulesDir, id) + + if (!moduleDir.exists()) { + value = ModuleUIState.Error.ModuleNotFound() + return@produceState + } + + val propsFile = SuFile.async(moduleDir, "module.prop") + + if (!propsFile.exists()) { + value = ModuleUIState.Error.InvalidModule() + return@produceState + } + + val props = propsFile.inputStream() + .use { readProps(it) } + + value = ModuleUIState.Ready( + Module(adbPath, props) + ) + } + } + + fun fromZip(adbPath: AdbPath, file: File): Module? { + val zipFile = ZipFile.Builder().setFile(file).get() + val entry = zipFile.getEntry(PROP_FILE) ?: return null + return zipFile.getInputStream(entry).use { + Module(adbPath, readProps(it)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModulePaths.kt b/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModulePaths.kt new file mode 100644 index 00000000..0976d1d3 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModulePaths.kt @@ -0,0 +1,167 @@ +package com.dergoogler.mmrl.wx.model.module + +import com.dergoogler.mmrl.wx.util.PathVarArgFunction +import dev.mmrlx.nio.Path +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.json.JSONObject +import org.luaj.LuaTable + +@Parcelize +@Serializable +data class AdbPath( + val baseDir: String, +) { + val configDir get() = Path.resolve(baseDir, HIDDEN_CONFIG_DIR) + val localDir get() = Path.resolve(baseDir, HIDDEN_LOCAL_DIR) + val modulesDir get() = Path.resolve(baseDir, MODULES_DIR) + + fun toJSONObject() = JSONObject().apply { + put("baseDir", baseDir) + put("configDir", configDir) + put("localDir", localDir) + put("modulesDir", modulesDir) + } + + companion object { + const val MODULES_DIR = "modules" + const val HIDDEN_CONFIG_DIR = ".config" + const val HIDDEN_LOCAL_DIR = ".local" + + val Empty = AdbPath("/dev/null") + } + + fun toLuaTable(): LuaTable { + val table = LuaTable() + + table.set("baseDir", baseDir) + table.set("configDir", configDir) + table.set("localDir", localDir) + table.set("modulesDir", modulesDir) + table.set("resolve", PathVarArgFunction(baseDir)) + + return table + } +} + +@Parcelize +@Serializable +data class ModulePath( + private val adbPath: AdbPath, + private val moduleId: String, +) { + val serviceFiles + get() = + listOf( + actionFile, + serviceFile, + postFsDataFile, + postMountFile, + webrootDir, + bootCompletedFile, + sepolicyFile, + ) + + val files + get() = + listOf( + *serviceFiles.toTypedArray(), + uninstallFile, + systemPropFile, + systemDir, + propFile, + disableFile, + removeFile, + updateFile, + ) + + val configDir get() = Path.resolve(adbPath.configDir, moduleId) + val moduleDir get() = Path.resolve(adbPath.modulesDir, moduleId) + val webrootDir get() = Path.resolve(moduleDir, WEBROOT_DIR) + val webrootConfig get() = Path.resolve(moduleDir, WEBROOT_DIR, "config.json") + val webrootLuaIndex get() = Path.resolve(moduleDir, WEBROOT_DIR, "index.lua") + val propFile get() = Path.resolve(moduleDir, PROP_FILE) + val actionFile get() = Path.resolve(moduleDir, ACTION_FILE) + val serviceFile get() = Path.resolve(moduleDir, SERVICE_FILE) + val postFsDataFile get() = Path.resolve(moduleDir, POST_FS_DATA_FILE) + val postMountFile get() = Path.resolve(moduleDir, POST_MOUNT_FILE) + val systemPropFile get() = Path.resolve(moduleDir, SYSTEM_PROP_FILE) + val bootCompletedFile get() = Path.resolve(moduleDir, BOOT_COMPLETED_FILE) + val sepolicyFile get() = Path.resolve(moduleDir, SE_POLICY_FILE) + val uninstallFile get() = Path.resolve(moduleDir, UNINSTALL_FILE) + val systemDir get() = Path.resolve(moduleDir, SYSTEM_DIR) + val disableFile get() = Path.resolve(moduleDir, DISABLE_FILE) + val removeFile get() = Path.resolve(moduleDir, REMOVE_FILE) + val updateFile get() = Path.resolve(moduleDir, UPDATE_FILE) + + fun toJSONObject() = JSONObject().apply { + put("moduleId", moduleId) + put("configDir", configDir) + put("moduleDir", moduleDir) + put("webrootDir", webrootDir) + put("webrootConfig", webrootConfig) + put("webrootLuaIndex", webrootLuaIndex) + put("propFile", propFile) + put("actionFile", actionFile) + put("serviceFile", serviceFile) + put("postFsDataFile", postFsDataFile) + put("postMountFile", postMountFile) + put("systemPropFile", systemPropFile) + put("bootCompletedFile", bootCompletedFile) + put("sepolicyFile", sepolicyFile) + put("uninstallFile", uninstallFile) + put("systemDir", systemDir) + put("disableFile", disableFile) + put("removeFile", removeFile) + put("updateFile", updateFile) + } + + fun toLuaTable(): LuaTable { + val table = LuaTable() + + table.set("moduleId", moduleId) + table.set("configDir", configDir) + table.set("moduleDir", moduleDir) + table.set("webrootDir", webrootDir) + table.set("webrootConfig", webrootConfig) + table.set("webrootLuaIndex", webrootLuaIndex) + table.set("propFile", propFile) + table.set("actionFile", actionFile) + table.set("serviceFile", serviceFile) + table.set("postFsDataFile", postFsDataFile) + table.set("postMountFile", postMountFile) + table.set("systemPropFile", systemPropFile) + table.set("bootCompletedFile", bootCompletedFile) + table.set("sepolicyFile", sepolicyFile) + table.set("uninstallFile", uninstallFile) + table.set("systemDir", systemDir) + table.set("disableFile", disableFile) + table.set("removeFile", removeFile) + table.set("updateFile", updateFile) + table.set("webroot", PathVarArgFunction(webrootDir)) + table.set("mod", PathVarArgFunction(moduleDir)) + + return table + } + + companion object { + const val WEBROOT_DIR = "webroot" + + const val PROP_FILE = "module.prop" + + const val ACTION_FILE = "action.sh" + const val BOOT_COMPLETED_FILE = "boot-completed.sh" + const val SERVICE_FILE = "service.sh" + const val POST_FS_DATA_FILE = "post-fs-data.sh" + const val POST_MOUNT_FILE = "post-mount.sh" + const val SYSTEM_PROP_FILE = "system.prop" + const val SE_POLICY_FILE = "sepolicy.rule" + const val UNINSTALL_FILE = "uninstall.sh" + const val SYSTEM_DIR = "system" + + // State files + const val DISABLE_FILE = "disable" + const val REMOVE_FILE = "remove" + const val UPDATE_FILE = "update" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModuleState.kt b/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModuleState.kt new file mode 100644 index 00000000..a73516de --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModuleState.kt @@ -0,0 +1,11 @@ +package com.dergoogler.mmrl.wx.model.module + +import java.io.Serializable + +enum class ModuleState : Serializable { + ENABLE, + REMOVE, + DISABLE, + UPDATE, + UNAVAILABLE +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModuleUIState.kt b/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModuleUIState.kt new file mode 100644 index 00000000..3cea3766 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/model/module/ModuleUIState.kt @@ -0,0 +1,40 @@ +package com.dergoogler.mmrl.wx.model.module + +import dev.mmrlx.nio.SuFile + +sealed interface ModuleUIState { + data object Loading : ModuleUIState + + data class Ready( + val module: Module, + ) : ModuleUIState + + data class ReadyBasePath( + val file: SuFile, + ) : ModuleUIState + + sealed interface Error : ModuleUIState { + val message: String + val error: Exception? + + data class SuInitFailed( + override val message: String = "Failed to initialize SuFile", + override val error: Exception? = null, + ) : Error + + data class MissingAdbPath( + override val message: String = "Missing adb path", + override val error: Exception? = null, + ) : Error + + data class ModuleNotFound( + override val message: String = "Module not found", + override val error: Exception? = null, + ) : Error + + data class InvalidModule( + override val message: String = "Invalid module", + override val error: Exception? = null, + ) : Error + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/model/module/WebrootConfig.kt b/app/src/main/java/com/dergoogler/mmrl/wx/model/module/WebrootConfig.kt new file mode 100644 index 00000000..8e5c1942 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/model/module/WebrootConfig.kt @@ -0,0 +1,309 @@ +@file:Suppress("PropertyName") + +package com.dergoogler.mmrl.wx.model.module + +import androidx.compose.runtime.mutableStateMapOf +import com.dergoogler.mmrl.webui.model.DexSourceType +import com.dergoogler.mmrl.webui.model.WebUIConfigDexFile +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.readText +import dev.mmrlx.nio.writeText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.serialization.Contextual +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.double +import kotlinx.serialization.json.float +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long + +class WebrootConfig( + private val module: Module, +) { + + @Contextual + @IgnoredOnParcel + private val json = Json { ignoreUnknownKeys = true } + + private val sourceConfigFile: SuFile + get() = SuFile(module.path.webrootConfig) + + private val destConfigFile: SuFile + get() = SuFile(module.path.configDir, "config.webroot.json") + + /** + * Compose observable map. + * + * Any reads inside composition automatically trigger recomposition + * when values change. + */ + @PublishedApi + internal val _map = mutableStateMapOf() + + init { + loadSync() + } + + operator fun get(key: String): JsonElement? = resolvePath(key) + + inline fun get(key: String, default: T): T = + resolvePath(key).getOrDefault(default) + + inline fun JsonElement?.getOrDefault(default: T): T { + if (this == null) return default + + @Suppress("UNCHECKED_CAST") + return runCatching { + when (T::class) { + + JsonObject::class -> this as? JsonObject ?: return default + JsonArray::class -> this as? JsonArray ?: return default + JsonPrimitive::class -> this as? JsonPrimitive ?: return default + + String::class -> jsonPrimitive.content + Boolean::class -> jsonPrimitive.boolean + Int::class -> jsonPrimitive.int + Long::class -> jsonPrimitive.long + Float::class -> jsonPrimitive.float + Double::class -> jsonPrimitive.double + Short::class -> jsonPrimitive.int.toShort() + Byte::class -> jsonPrimitive.int.toByte() + + else -> return default + } as T + }.getOrDefault(default) + } + + operator fun set(key: String, value: JsonElement) { + _map[key] = value + saveSync() + } + + inline fun set(key: String, value: T) { + when (T::class) { + String::class -> set(key, JsonPrimitive(value as String)) + Boolean::class -> set(key, JsonPrimitive(value as Boolean)) + Int::class -> set(key, JsonPrimitive(value as Int)) + Long::class -> set(key, JsonPrimitive(value as Long)) + Float::class -> set(key, JsonPrimitive(value as Float)) + Double::class -> set(key, JsonPrimitive(value as Double)) + Short::class -> set(key, JsonPrimitive(value as Short)) + Byte::class -> set(key, JsonPrimitive(value as Byte)) + else -> throw IllegalArgumentException("Unsupported type: ${T::class}") + } + } + + fun remove(key: String): JsonElement? { + val prev = _map.remove(key) + + if (prev != null) { + saveSync() + } + + return prev + } + + operator fun contains(key: String): Boolean = key in _map + + fun putAll(map: Map) { + _map.clear() + _map.putAll(map) + saveSync() + } + + fun clear() { + _map.clear() + saveSync() + } + + suspend fun reload(): Unit = withContext(Dispatchers.IO) { + loadSync() + } + + suspend fun save(): Unit = withContext(Dispatchers.IO) { + saveSync() + } + + @PublishedApi + internal fun resolvePath(path: String): JsonElement? { + val segments = path.split(".") + + if (segments.size == 1) { + return _map[path] + } + + var current: JsonElement = JsonObject(_map.toMap()) + + for (i in segments.indices) { + if (current !is JsonObject) { + return null + } + + val remaining = segments.drop(i).joinToString(".") + + val literal = current[remaining] + if (literal != null) { + return literal + } + + current = current[segments[i]] ?: return null + } + + return current + } + + private fun loadSync() { + runCatching { + val merged = mutableMapOf() + + val source = sourceConfigFile + + if (source.exists() && source.isFile) { + json.parseToJsonElement(source.readText()) + .let { it as? JsonObject } + ?.forEach { (key, value) -> + merged[key] = value + } + } + + val dest = destConfigFile + + if (dest.exists() && dest.isFile) { + json.parseToJsonElement(dest.readText()) + .let { it as? JsonObject } + ?.forEach { (key, value) -> + merged[key] = value + } + } + + _map.clear() + _map.putAll(merged) + + }.onFailure { + it.printStackTrace() + } + } + + private fun saveSync() { + runCatching { + val file = destConfigFile + + val parent = file.parentFile + + if (parent != null && !parent.exists()) { + parent.mkdirs() + } + + val jsonObject = buildJsonObject { + _map.forEach { (k, v) -> + put(k, v) + } + } + + file.writeText(jsonObject.toString()) + }.onFailure { + it.printStackTrace() + } + } +} + +val WebrootConfig.historyFallback + get() = get("historyFallback", false) + +val WebrootConfig.historyFallbackFile + get() = get("historyFallbackFile", "index.html") + +const val DEFAULT_CSP = "default-src 'self' data: blob: {domain}; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval' {domain}; " + + "img-src 'self' ksu:; " + + "style-src 'self' 'unsafe-inline' {domain}; " + + "connect-src *; " + +val WebrootConfig.contentSecurityPolicy + get() = get( + "contentSecurityPolicy", + DEFAULT_CSP + ) + +val WebrootConfig.autoStatusBarsStyle + get() = get("autoStatusBarsStyle", true) + +val WebrootConfig.autoAddInsets + get() = get("autoAddInsets", true) + +val WebrootConfig.windowResize + get() = get("windowResize", true) + +val WebrootConfig.caching + get() = get("caching", true) + +val WebrootConfig.exitConfirm + get() = get("exitConfirm", true) + +val WebrootConfig.pullToRefresh + get() = get("pullToRefresh", false) + +val WebrootConfig.cachingMaxAge + get() = get("cachingMaxAge", 86400) + +val WebrootConfig.killShellWhenBackground + get() = get("killShellWhenBackground", true) + +val WebrootConfig.title + get() = get("title", null) + +val WebrootConfig.icon + get() = get("icon", null) + +val WebrootConfig.refreshInterceptor + get() = get("refreshInterceptor", "native") + +val WebrootConfig.backInterceptor + get() = get("backInterceptor", "native") + +val WebrootConfig.backHandler + get() = get("backHandler", true) + +val WebrootConfig.permissions + get() = get("permissions", JsonArray(emptyList())) + +@Deprecated("Kept for backwards compatibility.") +val WebrootConfig.dexFiles: List + get() { + val entries = get("dexFiles", JsonArray(emptyList())) + + return entries.map { e -> + val entry = e as JsonObject + + val type: DexSourceType = + when (entry["type"].getOrDefault(null)) { + "apk" -> DexSourceType.APK + "dex" -> DexSourceType.DEX + else -> DexSourceType.DEX + } + + val path: String? = + entry["path"].getOrDefault(null) + + val className: String? = + entry["className"].getOrDefault(null) + + val cache: Boolean = + entry["cache"].getOrDefault(true) + + WebUIConfigDexFile( + type = type, + path = path, + className = className, + cache = cache + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/FileExplorerActivity.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/FileExplorerActivity.kt new file mode 100644 index 00000000..3b0bd61f --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/FileExplorerActivity.kt @@ -0,0 +1,83 @@ +package com.dergoogler.mmrl.wx.ui.activity + +import android.os.Bundle +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.Crossfade +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.WindowCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavBackStackEntry +import com.dergoogler.mmrl.ui.providable.LocalNavController +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode.Companion.isSetup +import com.dergoogler.mmrl.wx.ui.screens.fileexplorer.FileExplorerScreen +import com.dergoogler.mmrl.wx.util.BaseActivity +import com.dergoogler.mmrl.wx.util.setBaseContent +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle +import com.ramcosta.composedestinations.generated.NavGraphs +import kotlinx.coroutines.launch + +class FileExplorerActivity: BaseActivity() { + private var isLoading by mutableStateOf(true) + + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + WindowCompat.setDecorFitsSystemWindows(window, false) + + super.onCreate(savedInstanceState) + + splashScreen.setKeepOnScreenCondition { isLoading } + + setBaseContent { + val userPreferences by userPreferencesRepository.data + .collectAsStateWithLifecycle(initialValue = null) + + val preferences = if (userPreferences == null) { + return@setBaseContent + } else { + isLoading = false + checkNotNull(userPreferences) + } + + Crossfade( + targetState = preferences.workingMode.isSetup, + label = "MainActivity" + ) { isSetup -> + if (isSetup) { + SetupScreen(::setWorkingMode) + } else { + DestinationsNavHost( + navGraph = NavGraphs.fileExplorer, + navController = LocalNavController.current, + defaultTransitions = FileExplorerTransitions + ) + } + } + } + } + + object FileExplorerTransitions : NavHostAnimatedDestinationStyle() { + override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition + get() = { fadeIn(animationSpec = tween(340)) } + + override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition + get() = { fadeOut(animationSpec = tween(340)) } + } + + + private fun setWorkingMode(value: WorkingMode) { + lifecycleScope.launch { + userPreferencesRepository.setWorkingMode(value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/MainActivity.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/MainActivity.kt index ca725d41..4a8e1eae 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/MainActivity.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/MainActivity.kt @@ -2,15 +2,15 @@ package com.dergoogler.mmrl.wx.ui.activity import android.os.Bundle import androidx.compose.animation.Crossfade -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import com.dergoogler.mmrl.datastore.model.WorkingMode -import com.dergoogler.mmrl.datastore.model.WorkingMode.Companion.isSetup +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode.Companion.isSetup import com.dergoogler.mmrl.wx.ui.screens.MainScreen import com.dergoogler.mmrl.wx.util.BaseActivity import com.dergoogler.mmrl.wx.util.setBaseContent diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/SetupScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/SetupScreen.kt index 549ed567..d35848f0 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/SetupScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/SetupScreen.kt @@ -29,9 +29,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.dergoogler.mmrl.datastore.model.WorkingMode +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode import com.dergoogler.mmrl.ext.nullable -import com.dergoogler.mmrl.platform.Platform import com.dergoogler.mmrl.ui.component.card.Card import com.dergoogler.mmrl.ui.component.listItem.dsl.List import com.dergoogler.mmrl.ui.component.listItem.dsl.component.RadioItem @@ -39,7 +38,6 @@ import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Icon import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Title import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.model.FeaturedManager -import com.dergoogler.mmrl.wx.util.toWorkingMode @Composable fun SetupScreen(setWorkingMode: (WorkingMode) -> Unit) { diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/webui/interfaces/KernelSUInterface.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/webui/interfaces/KernelSUInterface.kt index 9aed50c4..7f81f41c 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/webui/interfaces/KernelSUInterface.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/activity/webui/interfaces/KernelSUInterface.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import com.dergoogler.mmrl.ext.findActivity import com.dergoogler.mmrl.platform.PlatformManager import com.dergoogler.mmrl.platform.model.ModId.Companion.moduleDir import com.dergoogler.mmrl.webui.interfaces.WXInterface @@ -56,7 +57,6 @@ class KernelSUInterface( return shell } - @JavascriptInterface fun mmrl(): Boolean { return true @@ -71,11 +71,12 @@ class KernelSUInterface( @JavascriptInterface fun fullScreen(enable: Boolean) { - runMainLooperPost { + val act = context.findActivity() ?: return + mainThread { if (enable) { - hideSystemUI(window) + hideSystemUI(act.window) } else { - showSystemUI(window) + showSystemUI(act.window) } } } diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/Alert.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/Alert.kt new file mode 100644 index 00000000..07ceda80 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/Alert.kt @@ -0,0 +1,111 @@ +package com.dergoogler.mmrl.wx.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.dergoogler.mmrl.wx.util.badgeDebugBackground +import com.dergoogler.mmrl.wx.util.badgeDebugForeground +import com.dergoogler.mmrl.wx.util.badgeErrorBackground +import com.dergoogler.mmrl.wx.util.badgeErrorForeground +import com.dergoogler.mmrl.wx.util.badgeInfoBackground +import com.dergoogler.mmrl.wx.util.badgeInfoForeground +import com.dergoogler.mmrl.wx.util.badgeWarnBackground +import com.dergoogler.mmrl.wx.util.badgeWarnForeground +import dev.mmrlx.compose.ui.ProvideContentColor +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.theme.MMRLXTheme + +@Composable +fun Alert( + title: String, + message: String, + background: Color, + contentColor: Color, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(background) + .padding(16.dp) + ) { + ProvideContentColor(contentColor) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + Spacer(Modifier.height(6.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +fun InfoAlert( + title: String, + message: String, + modifier: Modifier = Modifier +) = Alert( + title, + message, + MMRLXTheme.colors.badgeInfoBackground, + MMRLXTheme.colors.badgeInfoForeground, + modifier, +) + +@Composable +fun WarningAlert( + title: String, + message: String, + modifier: Modifier = Modifier +) = Alert( + title, + message, + MMRLXTheme.colors.badgeWarnBackground, + MMRLXTheme.colors.badgeWarnForeground, + modifier, +) + +@Composable +fun DebugAlert( + title: String, + message: String, + modifier: Modifier = Modifier +) = Alert( + title, + message, + MMRLXTheme.colors.badgeDebugBackground, + MMRLXTheme.colors.badgeDebugForeground, + modifier, +) + +@Composable +fun ErrorAlert( + title: String, + message: String, + modifier: Modifier = Modifier +) = Alert( + title, + message, + MMRLXTheme.colors.badgeErrorBackground, + MMRLXTheme.colors.badgeErrorForeground, + modifier, +) \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/BottomNavigation.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/BottomNavigation.kt new file mode 100644 index 00000000..e68a1bef --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/BottomNavigation.kt @@ -0,0 +1,48 @@ +package com.dergoogler.mmrl.wx.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.painterResource +import com.dergoogler.mmrl.ui.providable.LocalNavController +import com.dergoogler.mmrl.wx.ui.navigation.MainDestination +import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator +import com.ramcosta.composedestinations.utils.isRouteOnBackStackAsState +import com.ramcosta.composedestinations.generated.NavGraphs +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.navigationbar.NavigationBar +import dev.mmrlx.compose.ui.navigationbar.NavigationBarItem + +@Composable +fun BottomNavigation() { + val navController = LocalNavController.current + val navigator = LocalDestinationsNavigator.current + + NavigationBar() { + MainDestination.entries.forEach { screen -> + val isSelected by navController.isRouteOnBackStackAsState(screen.direction) + + NavigationBarItem( + selected = isSelected, + onClick = { + if (isSelected) { + navigator.popBackStack(screen.direction, false) + } + + navigator.navigate(screen.direction) { + popUpTo(NavGraphs.root) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + painter = painterResource(if (isSelected) screen.iconFilled else screen.icon), + contentDescription = null + ) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/Cover.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/Cover.kt new file mode 100644 index 00000000..30a02b9b --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/Cover.kt @@ -0,0 +1,56 @@ +package com.dergoogler.mmrl.wx.ui.component + +import android.graphics.BitmapFactory +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import java.io.InputStream + +private const val DefaultAspectRatio = 2.048f +private val DefaultShape: RoundedCornerShape = RoundedCornerShape(0.dp) + +@Composable +fun LocalCover( + modifier: Modifier = Modifier, + inputStream: InputStream, + shape: RoundedCornerShape = DefaultShape, + aspectRatio: Float = DefaultAspectRatio, +) { + val bitmap = remember(inputStream) { + try { + inputStream.use { stream -> + val bytes = stream.readBytes() + val nativeBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + nativeBitmap?.asImageBitmap() + } + } catch (e: Exception) { + Log.e("LocalCover", "Failed to load cover image", e) + null + } + } + + // Safely exit the Composable layout if bitmap creation failed + if (bitmap == null) return + + Image( + painter = BitmapPainter(bitmap), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .aspectRatio(aspectRatio) + .then(modifier), + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ErrorContent.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ErrorContent.kt new file mode 100644 index 00000000..a2bd4434 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ErrorContent.kt @@ -0,0 +1,65 @@ +package com.dergoogler.mmrl.wx.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.dergoogler.mmrl.wx.R +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.button.Button +import dev.mmrlx.compose.ui.icon.Icon + +@Composable +fun ErrorContent( + message: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + painter = painterResource(R.drawable.bug), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp) + ) + + Text( + text = "Something went wrong", + style = MaterialTheme.typography.titleLarge + ) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (onRetry != null) { + Button( + onClick = onRetry + ) { + Text("Retry") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ListItems.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ListItems.kt index a291265c..f605ce7d 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ListItems.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ListItems.kt @@ -3,23 +3,23 @@ package com.dergoogler.mmrl.wx.ui.component import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.dergoogler.mmrl.ext.nullable -import com.dergoogler.mmrl.ui.component.listItem.dsl.ListItemScope -import com.dergoogler.mmrl.ui.component.listItem.dsl.ListItemSlot -import com.dergoogler.mmrl.ui.component.listItem.dsl.ListScope -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.ButtonItem -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.SwitchItem -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Description -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Icon -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Title import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator import com.ramcosta.composedestinations.spec.Direction - +import dev.mmrlx.compose.ui.list.ListItemScope +import dev.mmrlx.compose.ui.list.ListItemSlot +import dev.mmrlx.compose.ui.list.ListScope +import dev.mmrlx.compose.ui.list.component.RawItem +import dev.mmrlx.compose.ui.list.component.SwitchItem +import dev.mmrlx.compose.ui.list.component.item.Description +import dev.mmrlx.compose.ui.list.component.item.Icon +import dev.mmrlx.compose.ui.list.component.item.Title @Composable internal fun ListScope.NavButton( @@ -30,22 +30,23 @@ internal fun ListScope.NavButton( ) { val navigator = LocalDestinationsNavigator.current - ButtonItem( - onClick = { - navigator.navigate(route) - }, - content = { - icon.nullable { - Icon( - painter = painterResource(it) - ) - } - Title(title) - desc.nullable { - Description(it) + RawItem( + modifier = Modifier + .onClick { + navigator.navigate(route) } + .contentPadding() + ) { + icon?.let { + Icon( + painter = painterResource(it) + ) } - ) + Title(title) + desc?.let { + Description(it) + } + } } @Composable @@ -57,25 +58,26 @@ internal fun ListScope.LinkButton( ) { val browser = LocalUriHandler.current - ButtonItem( - onClick = { - browser.openUri(uri) - }, - content = { - Icon( - painter = painterResource(icon) - ) - Title(title) - desc.nullable { - Description(it) + RawItem( + modifier = Modifier + .onClick { + browser.openUri(uri) } - Icon( - slot = ListItemSlot.End, - size = 12.dp, - painter = painterResource(R.drawable.external_link) - ) + .contentPadding() + ) { + Icon( + painter = painterResource(icon) + ) + Title(title) + desc.nullable { + Description(it) } - ) + Icon( + slot = ListItemSlot.End, + size = 12.dp, + painter = painterResource(R.drawable.external_link) + ) + } } @Composable diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/LoadingContent.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/LoadingContent.kt new file mode 100644 index 00000000..84e55a6d --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/LoadingContent.kt @@ -0,0 +1,21 @@ +package com.dergoogler.mmrl.wx.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.mmrlx.compose.ui.CircularProgressIndicator + +@Composable +fun LoadingContent( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ModuleImporter.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ModuleImporter.kt index 824f7f3d..8012a88b 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ModuleImporter.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ModuleImporter.kt @@ -17,13 +17,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import com.dergoogler.mmrl.ext.systemBarsPaddingEnd -import com.dergoogler.mmrl.platform.PlatformManager -import com.dergoogler.mmrl.platform.content.LocalModule -import com.dergoogler.mmrl.platform.file.ExtFile import com.dergoogler.mmrl.ui.component.dialog.ConfirmData import com.dergoogler.mmrl.ui.component.dialog.rememberConfirm import com.dergoogler.mmrl.wx.R -import com.dergoogler.mmrl.wx.util.getBaseDir +import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.model.module.Module +import com.dergoogler.mmrl.wx.viewmodel.ModulesViewModel +import dev.mmrlx.nio.ExtFile import java.io.BufferedInputStream import java.io.FileInputStream import java.io.FileOutputStream @@ -31,7 +31,8 @@ import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @Composable -fun ModuleImporter() { +fun ModuleImporter(viewModel: ModulesViewModel) { + val prefs = LocalUserPreferences.current val context = LocalContext.current val interactionSource = remember { MutableInteractionSource() } val confirm = rememberConfirm() @@ -39,7 +40,7 @@ fun ModuleImporter() { rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri == null) return@rememberLauncherForActivityResult - val result = importZipToModules(context, uri) + val result = importZipToModules(context, uri, viewModel) confirm( ConfirmData( @@ -78,7 +79,7 @@ data class ResultData( val message: String, ) -fun importZipToModules(context: Context, zipUri: Uri): ResultData { +fun importZipToModules(context: Context, zipUri: Uri, viewModel: ModulesViewModel): ResultData { val tempZipName = "temp_module_import_${System.currentTimeMillis()}.zip" val zipFile = ExtFile(context.cacheDir, tempZipName) @@ -94,15 +95,14 @@ fun importZipToModules(context: Context, zipUri: Uri): ResultData { ) } - val newModule: LocalModule? = PlatformManager.moduleManager.getModuleInfo(zipFile.path) - if (newModule == null) { - return ResultData( - title = "Failed", - message = "Could not parse module information from the ZIP file. It might be corrupted or not a valid module." - ) - } + val baseDir = viewModel.adbPath - val existingModule: LocalModule? = PlatformManager.moduleManager.getModuleById(newModule.id) + val newModule: Module = Module.fromZip(baseDir, zipFile) ?: return ResultData( + title = "Failed", + message = "Could not parse module information from the ZIP file. It might be corrupted or not a valid module." + ) + + val existingModule: Module? = viewModel.findById(newModule.id) if (existingModule != null) { return ResultData( title = "Failed", @@ -110,9 +110,7 @@ fun importZipToModules(context: Context, zipUri: Uri): ResultData { ) } - val baseDir = context.getBaseDir() - - val targetDir = ExtFile(baseDir, "modules/${newModule.id}") + val targetDir = ExtFile(baseDir.baseDir, "modules/${newModule.id}") if (targetDir.exists()) { if (!targetDir.delete()) { return ResultData( @@ -135,6 +133,7 @@ fun importZipToModules(context: Context, zipUri: Uri): ResultData { val successMessage = "Module '${newModule.name}' imported successfully." Log.i("ModuleImport", successMessage) // Log success + viewModel.refreshModules() return ResultData( title = "Success", message = successMessage diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ModuleScope.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ModuleScope.kt new file mode 100644 index 00000000..fbf8a63a --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/ModuleScope.kt @@ -0,0 +1,106 @@ +package com.dergoogler.mmrl.wx.ui.component + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import com.dergoogler.mmrl.ext.none +import com.dergoogler.mmrl.wx.model.module.Module +import com.dergoogler.mmrl.wx.model.module.ModuleUIState +import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.scaffold.ScaffoldScope +import dev.mmrlx.nio.SuFile +import dev.mmrlx.utilities.io.NullSuFile + +val LocalModule = compositionLocalOf { Module.Empty } +val LocalBasePath = compositionLocalOf { NullSuFile() } + +@Composable +fun BasePathScope( + toolbar: Boolean = true, + content: @Composable () -> Unit, +) { + val state by Module.rememberBasePath() + + when (state) { + ModuleUIState.Loading -> { + ContentWrapper(toolbar, "Loading...") { + LoadingContent() + } + } + + is ModuleUIState.Error -> { + ContentWrapper(toolbar, "ERROR") { + val msg = (state as ModuleUIState.Error).message + ErrorContent(msg) + } + } + + is ModuleUIState.ReadyBasePath -> { + val module = (state as ModuleUIState.ReadyBasePath).file + + CompositionLocalProvider(LocalBasePath provides module) { + content() + } + } + + else -> {} + } +} + +@Composable +fun ModuleScope( + moduleId: String, + toolbar: Boolean = true, + content: @Composable () -> Unit, +) { + val state by Module.rememberCreate(moduleId) + + when (state) { + ModuleUIState.Loading -> { + ContentWrapper(toolbar, "Loading...") { + LoadingContent() + } + } + + is ModuleUIState.Error -> { + ContentWrapper(toolbar, "ERROR") { + val msg = (state as ModuleUIState.Error).message + ErrorContent(msg) + } + } + + is ModuleUIState.Ready -> { + val module = (state as ModuleUIState.Ready).module + + CompositionLocalProvider(LocalModule provides module) { + content() + } + } + + else -> {} + } +} + +@Composable +private fun ContentWrapper( + toolbar: Boolean, + title: String = "Error", + content: @Composable ScaffoldScope.() -> Unit, +) { + val navigator = LocalDestinationsNavigator.current + Scaffold( + toolbar = { + if (!toolbar) return@Scaffold + + NavigateUpToolbar( + title = title, + onBack = { navigator.popBackStack() }, + ) + }, + contentWindowInsets = WindowInsets.none, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/NavigateUpToolbar.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/NavigateUpToolbar.kt new file mode 100644 index 00000000..d6b189cd --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/NavigateUpToolbar.kt @@ -0,0 +1,121 @@ +package com.dergoogler.mmrl.wx.ui.component + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.navigation.NavController +import com.dergoogler.mmrl.ext.takeTrue +import com.dergoogler.mmrl.ui.R +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.icon.IconButton +import dev.mmrlx.compose.ui.toolbar.Toolbar +import dev.mmrlx.compose.ui.toolbar.ToolbarColors +import dev.mmrlx.compose.ui.toolbar.ToolbarDefaults +import dev.mmrlx.compose.ui.toolbar.ToolbarScrollBehavior +import dev.mmrlx.compose.ui.toolbar.ToolbarTitle + +@Composable +fun NavigateUpToolbar( + title: String, + navController: NavController, + modifier: Modifier = Modifier, + subtitle: String? = null, + enable: Boolean = true, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = ToolbarDefaults.windowInsets, + colors: ToolbarColors = ToolbarDefaults.colors(), + scrollBehavior: ToolbarScrollBehavior? = null, +) = NavigateUpToolbar( + modifier = modifier, + title = title, + subtitle = subtitle, + onBack = { navController.popBackStack() }, + actions = actions, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + enable = enable, +) + +@Composable +fun NavigateUpToolbar( + title: String, + modifier: Modifier = Modifier, + context: Context = LocalContext.current, + subtitle: String? = null, + enable: Boolean = true, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = ToolbarDefaults.windowInsets, + colors: ToolbarColors = ToolbarDefaults.colors(), + scrollBehavior: ToolbarScrollBehavior? = null, +) = NavigateUpToolbar( + modifier = modifier, + title = title, + subtitle = subtitle, + onBack = { (context as ComponentActivity).finish() }, + actions = actions, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + enable = enable, +) + +@Composable +fun NavigateUpToolbar( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + enable: Boolean = true, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = ToolbarDefaults.windowInsets, + colors: ToolbarColors = ToolbarDefaults.colors(), + scrollBehavior: ToolbarScrollBehavior? = null, +) = NavigateUpToolbar( + modifier = modifier, + title = { + ToolbarTitle(title = title, subtitle = subtitle) + }, + onBack = onBack, + actions = actions, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + enable = enable, +) + +@Composable +fun NavigateUpToolbar( + title: @Composable () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + enable: Boolean = true, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = ToolbarDefaults.windowInsets, + colors: ToolbarColors = ToolbarDefaults.colors(), + scrollBehavior: ToolbarScrollBehavior? = null, +) = Toolbar( + title = title, + modifier = modifier, + navigationIcon = { + enable.takeTrue { + IconButton( + onClick = { if (it) onBack() }, + ) { + Icon( + painter = painterResource(id = R.drawable.arrow_left), + contentDescription = null, + ) + } + } + }, + actions = actions, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, +) diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/ConsoleTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/ConsoleTab.kt index 4ca3b650..31c1f9de 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/ConsoleTab.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/ConsoleTab.kt @@ -21,8 +21,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -34,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -57,12 +54,12 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.dergoogler.mmrl.webui.view.WXView +import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.hybridwebui.ConsoleEntry import com.dergoogler.mmrl.hybridwebui.PrimitiveKind import com.dergoogler.mmrl.hybridwebui.ResultNode import com.dergoogler.mmrl.hybridwebui.wrapConsoleEvalResult -import com.dergoogler.mmrl.webui.view.WXView -import com.dergoogler.mmrl.wx.R import org.json.JSONObject private sealed class LogEntry { @@ -624,7 +621,7 @@ private fun ConsoleToolbar( ) { IconButton(onClick = onClear, modifier = Modifier.size(28.dp)) { Icon( - imageVector = Icons.Default.Clear, + painter = painterResource(com.dergoogler.mmrl.webui.R.drawable.refresh), contentDescription = "Clear console", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurface diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/DOMTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/DOMTab.kt index 2068ec82..6960e380 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/DOMTab.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/DOMTab.kt @@ -54,10 +54,6 @@ import com.dergoogler.mmrl.wx.R import org.json.JSONArray import org.json.JSONObject -// --------------------------------------------------------------------------- -// Data model -// --------------------------------------------------------------------------- - private data class DomNode( val id: Int, val parentId: Int?, @@ -68,10 +64,26 @@ private data class DomNode( val hasChildren: Boolean, ) + private sealed class RenderEntry { - data class Open(val node: DomNode) : RenderEntry() - data class Close(val nodeId: Int, val tag: String, val depth: Int) : RenderEntry() - data class TextNode(val text: String, val depth: Int, val parentId: Int) : RenderEntry() + abstract val stableId: String + + data class Open(val node: DomNode) : RenderEntry() { + override val stableId: String = "open:${node.id}" + } + + data class Close(val nodeId: Int, val tag: String, val depth: Int) : RenderEntry() { + override val stableId: String = "close:$nodeId:$depth" + } + + data class TextNode( + val text: String, + val depth: Int, + val parentId: Int, + val index: Int + ) : RenderEntry() { + override val stableId: String = "text:$parentId:$depth:$index" + } } private sealed class RawEntry { @@ -79,10 +91,6 @@ private sealed class RawEntry { data class Text(val text: String, val depth: Int, val parentId: Int?) : RawEntry() } -// --------------------------------------------------------------------------- -// DOM manipulation actions -// --------------------------------------------------------------------------- - private sealed class DomAction { data class EditAttribute(val node: DomNode) : DomAction() data class AddAttribute(val node: DomNode) : DomAction() @@ -95,10 +103,6 @@ private sealed class DomAction { data class RemoveClass(val node: DomNode) : DomAction() } -// --------------------------------------------------------------------------- -// JS helpers -// --------------------------------------------------------------------------- - /** * Builds a JS expression that resolves a node by its serialization id. * We re-query by id attribute path using a counter-based data attribute @@ -109,10 +113,6 @@ private sealed class DomAction { private fun jsNodeById(nodeId: Int) = "document.querySelector('[data-devtools-id=\"$nodeId\"]')" -// --------------------------------------------------------------------------- -// DomTab -// --------------------------------------------------------------------------- - @Composable fun DomTab(webview: WXView) { var nodes by remember { mutableStateOf>(emptyList()) } @@ -352,7 +352,7 @@ fun DomTab(webview: WXView) { } else -> { LazyColumn(modifier = Modifier.fillMaxSize()) { - items(renderList, key = { it.key() }) { entry -> + items(renderList, key = { it.stableId }) { entry -> RenderEntryRow( entry = entry, collapsedIds = collapsedIds, @@ -385,10 +385,6 @@ fun DomTab(webview: WXView) { } } -// --------------------------------------------------------------------------- -// Context menu -// --------------------------------------------------------------------------- - @Composable private fun DomContextMenu( node: DomNode, @@ -464,10 +460,6 @@ private fun DomContextMenu( } } -// --------------------------------------------------------------------------- -// Dialogs -// --------------------------------------------------------------------------- - @Composable private fun EditAttributeDialog( node: DomNode, @@ -635,10 +627,6 @@ private fun ConfirmDialog( } } -// --------------------------------------------------------------------------- -// Shared dialog / field components -// --------------------------------------------------------------------------- - @Composable private fun DevToolsDialog( title: String, @@ -693,10 +681,6 @@ private fun DevToolsTextField( } } -// --------------------------------------------------------------------------- -// Render list builder (unchanged from before) -// --------------------------------------------------------------------------- - private fun buildRenderList( raw: List, nodeMap: Map, @@ -705,10 +689,12 @@ private fun buildRenderList( if (raw.isEmpty()) return emptyList() val result = mutableListOf() val stack = ArrayDeque() + val textCounters = mutableMapOf() + for (entry in raw) { val currentDepth = when (entry) { is RawEntry.Element -> entry.node.depth - is RawEntry.Text -> entry.depth + is RawEntry.Text -> entry.depth } while (stack.isNotEmpty() && stack.last().depth >= currentDepth) { val closing = stack.removeLast() @@ -726,7 +712,12 @@ private fun buildRenderList( is RawEntry.Text -> { if (entry.parentId != null && collapsedIds.contains(entry.parentId)) continue if (entry.parentId != null && isAncestorCollapsed(entry.parentId, nodeMap, collapsedIds)) continue - result.add(RenderEntry.TextNode(entry.text, entry.depth, entry.parentId ?: -1)) + val pid = entry.parentId ?: -1 + // pack parentId and depth into a single Long key for the counter map + val counterKey = (pid.toLong() shl 32) or (entry.depth.toLong() and 0xFFFFFFFFL) + val idx = textCounters.getOrDefault(counterKey, 0) + textCounters[counterKey] = idx + 1 + result.add(RenderEntry.TextNode(entry.text, entry.depth, pid, idx)) } } } @@ -754,10 +745,6 @@ private fun RenderEntry.key(): String = when (this) { is RenderEntry.TextNode -> "text_${parentId}_${text.hashCode()}" } -// --------------------------------------------------------------------------- -// Row composable -// --------------------------------------------------------------------------- - @OptIn(ExperimentalFoundationApi::class) @Composable private fun RenderEntryRow( diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/DevToolsContainer.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/DevToolsContainer.kt index 7ada4508..da5c0729 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/DevToolsContainer.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/DevToolsContainer.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.MaterialTheme @@ -26,8 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.dp @Composable @@ -38,12 +35,6 @@ fun DevToolsContainer( content: @Composable () -> Unit, ) { val borderColor = MaterialTheme.colorScheme.outlineVariant - val statusBarHeight = - WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 28.dp - val density = LocalDensity.current - val maxHeight = with(density) { - LocalWindowInfo.current.containerSize.height.toDp() - } - statusBarHeight Box( modifier = Modifier @@ -76,7 +67,6 @@ fun DevToolsContainer( Surface( modifier = Modifier .fillMaxWidth() - .heightIn(max = maxHeight) .drawWithContent { drawContent() diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/NetworkTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/NetworkTab.kt index c25c002e..44a5c4ad 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/NetworkTab.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/NetworkTab.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dergoogler.mmrl.ui.component.Tab import com.dergoogler.mmrl.webui.view.WXView +import com.dergoogler.mmrl.wx.R @Composable fun NetworkTab(webview: WXView) { @@ -195,7 +197,7 @@ private fun RequestInspector( ) IconButton(onClick = onClose, modifier = Modifier.size(24.dp)) { Icon( - imageVector = Icons.Default.Close, + painter = painterResource(R.drawable.x), contentDescription = "Close inspector", modifier = Modifier.size(16.dp), tint = mutedColor diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/ViewTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/ViewTab.kt index 0b3a06a6..4aa5f9ef 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/ViewTab.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/devtools/ViewTab.kt @@ -6,16 +6,17 @@ import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.pager.PagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -41,6 +43,8 @@ fun ViewTab( state: PagerState, onDismissRequest: () -> Unit, ) { + val statusBarHeight = + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val scope = rememberCoroutineScope() val pages = remember { @@ -56,7 +60,9 @@ fun ViewTab( verticalAlignment = Alignment.CenterVertically ) { DevToolsTabRow( - modifier = Modifier.weight(1f), + modifier = Modifier + .padding(top = statusBarHeight) + .weight(1f), selectedTabIndex = state.currentPage, indicator = { tabPositions -> AnimatedIndicator( @@ -90,7 +96,7 @@ fun ViewTab( IconButton(onClick = onDismissRequest) { Icon( - imageVector = Icons.Default.Close, + painter = painterResource(R.drawable.square_chevrons_left), contentDescription = "Close DevTools", modifier = Modifier.size(20.dp) ) diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/toolbar/SearchToolbar.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/toolbar/SearchToolbar.kt new file mode 100644 index 00000000..cc543253 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/component/toolbar/SearchToolbar.kt @@ -0,0 +1,115 @@ +//package com.dergoogler.mmrl.wx.ui.component.toolbar +// +//import androidx.compose.foundation.layout.RowScope +//import androidx.compose.foundation.layout.WindowInsets +//import androidx.compose.foundation.shape.RoundedCornerShape +//import androidx.compose.foundation.text.KeyboardActions +//import androidx.compose.foundation.text.KeyboardOptions +//import androidx.compose.material3.Icon +//import androidx.compose.material3.IconButton +//import androidx.compose.material3.MaterialTheme +//import androidx.compose.material3.OutlinedTextField +//import androidx.compose.material3.OutlinedTextFieldDefaults +//import androidx.compose.material3.Text +//import androidx.compose.material3.TopAppBarColors +//import androidx.compose.material3.TopAppBarDefaults +//import androidx.compose.material3.TopAppBarScrollBehavior +//import androidx.compose.runtime.Composable +//import androidx.compose.runtime.LaunchedEffect +//import androidx.compose.runtime.remember +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.Modifier.Companion +//import androidx.compose.ui.focus.FocusRequester +//import androidx.compose.ui.focus.focusRequester +//import androidx.compose.ui.graphics.Color.Companion +//import androidx.compose.ui.platform.LocalSoftwareKeyboardController +//import androidx.compose.ui.res.painterResource +//import androidx.compose.ui.res.stringResource +//import androidx.compose.ui.text.input.ImeAction.Companion +//import androidx.compose.ui.text.input.KeyboardType +//import androidx.compose.ui.text.input.KeyboardType.Companion +//import androidx.compose.ui.unit.dp +//import com.dergoogler.mmrl.ui.R +//import com.dergoogler.mmrl.ui.component.TopAppBar +//import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +//import dev.mmrlx.compose.ui.Toolbar +// +//@OptIn(ExperimentalHazeMaterialsApi::class) +//@Composable +//fun SearchToolbar( +// modifier: Modifier = Modifier, +// isSearch: Boolean, +// autoFocus: Boolean = true, +// query: String, +// onQueryChange: (String) -> Unit, +// onClose: (() -> Unit)? = null, +// title: @Composable () -> Unit, +// navigationIcon: @Composable () -> Unit = {}, +// actions: @Composable (() -> Unit)? = null, +//) = Toolbar( +// modifier = modifier, +// actions = actions, +// navigationIcon = +// if (onClose != null && isSearch) { +// { +// IconButton( +// onClick = onClose, +// ) { +// Icon( +// painter = painterResource(id = R.drawable.arrow_left), +// contentDescription = null, +// ) +// } +// } +// } else { +// navigationIcon +// }, +// title = +// if (isSearch) { +// { +// val focusRequester = remember { FocusRequester() } +// val keyboardController = LocalSoftwareKeyboardController.current +// +// LaunchedEffect(focusRequester) { +// if (autoFocus) { +// focusRequester.requestFocus() +// } +// keyboardController?.show() +// } +// +// OutlinedTextField( +// modifier = Modifier.focusRequester(focusRequester), +// value = query, +// onValueChange = onQueryChange, +// keyboardOptions = +// KeyboardOptions( +// keyboardType = KeyboardType.Text, +// imeAction = ImeAction.Search, +// ), +// keyboardActions = +// KeyboardActions { +// defaultKeyboardAction(ImeAction.Search) +// }, +// shape = RoundedCornerShape(15.dp), +// colors = +// OutlinedTextFieldDefaults.colors( +// focusedBorderColor = Color.Transparent, +// unfocusedBorderColor = Color.Transparent, +// ), +// leadingIcon = { +// Icon( +// painter = painterResource(id = R.drawable.search), +// contentDescription = null, +// ) +// }, +// placeholder = { +// Text(text = stringResource(id = R.string.search_placeholder)) +// }, +// singleLine = true, +// textStyle = MaterialTheme.typography.bodyLarge, +// ) +// } +// } else { +// title +// }, +//) diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/navigation/Graphs.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/navigation/Graphs.kt new file mode 100644 index 00000000..a3255f95 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/navigation/Graphs.kt @@ -0,0 +1,8 @@ +package com.dergoogler.mmrl.wx.ui.navigation + +import com.ramcosta.composedestinations.annotation.NavHostGraph + +@NavHostGraph +annotation class FileExplorerGraph( + val start: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/MainScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/MainScreen.kt index 598bd5b0..6af51764 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/MainScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/MainScreen.kt @@ -8,39 +8,22 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.navigation.NavBackStackEntry import com.dergoogler.mmrl.ext.none import com.dergoogler.mmrl.ui.providable.LocalNavController import com.dergoogler.mmrl.wx.App.Companion.TAG import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences import com.dergoogler.mmrl.wx.service.PlatformService -import com.dergoogler.mmrl.wx.ui.navigation.MainDestination -import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle import com.ramcosta.composedestinations.generated.NavGraphs -import com.ramcosta.composedestinations.utils.isRouteOnBackStackAsState +import dev.mmrlx.compose.ui.scaffold.Scaffold @Composable fun MainScreen() { @@ -64,14 +47,10 @@ fun MainScreen() { } Scaffold( - bottomBar = { - BottomNav() - }, snackbarHost = { SnackbarHost(snackbarHostState) }, contentWindowInsets = WindowInsets.none - ) { paddingValues -> + ) { DestinationsNavHost( - modifier = Modifier.padding(bottom = paddingValues.calculateBottomPadding()), navGraph = NavGraphs.root, navController = navController, defaultTransitions = object : NavHostAnimatedDestinationStyle() { @@ -82,61 +61,4 @@ fun MainScreen() { } ) } -} - -@Composable -private fun BottomNav() { - val navController = LocalNavController.current - val navigator = LocalDestinationsNavigator.current - - NavigationBar( - modifier = Modifier - .imePadding() - .clip( - RoundedCornerShape( - topStart = 20.dp, - topEnd = 20.dp - ) - ) - ) { - MainDestination.entries.forEach { screen -> - val isSelected by navController.isRouteOnBackStackAsState(screen.direction) - - NavigationBarItem( - icon = { - Icon( - painter = painterResource( - id = if (isSelected) { - screen.iconFilled - } else { - screen.icon - } - ), - contentDescription = null, - ) - }, - label = { - Text( - text = stringResource(screen.label), - style = MaterialTheme.typography.labelLarge - ) - }, - alwaysShowLabel = true, - selected = isSelected, - onClick = { - if (isSelected) { - navigator.popBackStack(screen.direction, false) - } - - navigator.navigate(screen.direction) { - popUpTo(NavGraphs.root) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - ) - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/AdbPathFileResolver.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/AdbPathFileResolver.kt new file mode 100644 index 00000000..da2bab47 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/AdbPathFileResolver.kt @@ -0,0 +1,27 @@ +package com.dergoogler.mmrl.wx.ui.screens.fileexplorer + +import android.util.Log +import com.dergoogler.mmrl.wx.model.module.AdbPath +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.inputStream +import io.github.rosemoe.sora.langs.textmate.registry.provider.FileResolver +import java.io.InputStream + +class AdbPathFileResolver( + private val debug: Boolean, + private val adbPath: AdbPath, +) : FileResolver { + override fun resolveStreamByPath(path: String): InputStream? { + val file = SuFile(adbPath.localDir, "webuix", path) + + if (debug) { + Log.d("AdbPathFileResolver", file.toString()) + } + + return if (file.exists()) { + file.inputStream() + } else { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/FileEditorScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/FileEditorScreen.kt new file mode 100644 index 00000000..8a292b95 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/FileEditorScreen.kt @@ -0,0 +1,670 @@ +@file:Suppress("CanBeParameter", "ClassName") + +package com.dergoogler.mmrl.wx.ui.screens.fileexplorer + +import android.content.Context +import android.graphics.Typeface +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontSynthesis +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.dergoogler.mmrl.ext.none +import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.model.module.AdbPath +import com.dergoogler.mmrl.wx.ui.component.BasePathScope +import com.dergoogler.mmrl.wx.ui.component.LocalModule +import com.dergoogler.mmrl.wx.ui.component.NavigateUpToolbar +import com.dergoogler.mmrl.wx.ui.navigation.FileExplorerGraph +import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator +import com.ramcosta.composedestinations.annotation.Destination +import dev.mmrlx.compose.ui.LocalTextStyle +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.button.Button +import dev.mmrlx.compose.ui.button.ButtonVariant +import dev.mmrlx.compose.ui.dialog.Content +import dev.mmrlx.compose.ui.dialog.Footer +import dev.mmrlx.compose.ui.dialog.Title +import dev.mmrlx.compose.ui.dialog.rememberDialog +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.icon.IconButton +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.theme.Colors +import dev.mmrlx.compose.ui.theme.MMRLXTheme +import dev.mmrlx.compose.ui.toolbar.ToolbarTitle +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.SuFile.Companion.toSuFile +import dev.mmrlx.nio.readText +import dev.mmrlx.nio.writeText +import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme +import io.github.rosemoe.sora.langs.textmate.TextMateLanguage +import io.github.rosemoe.sora.langs.textmate.registry.FileProviderRegistry +import io.github.rosemoe.sora.langs.textmate.registry.GrammarRegistry +import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry +import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel +import io.github.rosemoe.sora.text.Content +import io.github.rosemoe.sora.text.ContentListener +import io.github.rosemoe.sora.widget.CodeEditor +import io.github.rosemoe.sora.widget.schemes.EditorColorScheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.eclipse.tm4e.core.registry.IThemeSource + +object TextMateManager { + + private var initialized = false + + fun initialize(debug: Boolean, adbPath: AdbPath) { + if (initialized) return + + initialized = true + + FileProviderRegistry + .getInstance() + .addFileProvider( + AdbPathFileResolver(debug, adbPath) + ) + + GrammarRegistry + .getInstance() + .loadGrammars("textmate/languages.json") + } +} + +data class CodeEditorState( + private val scope: CoroutineScope, + private val adbPath: AdbPath, + private val context: Context, + private val colors: Colors, + private val darkMode: Boolean, + private val debug: Boolean, + private val initialFile: SuFile?, + private val threadSafe: Boolean = true, + private val textStyle: TextStyle, + private val typeface: Typeface, +) { + + val editor = CodeEditor(context) + + var isSaveAllowed by mutableStateOf(true) + + val file: SuFile? by lazy { + + if (initialFile == null) { + isSaveAllowed = false + return@lazy null + } + + val f = SuFile(initialFile.path) + + if (!f.exists()) { + isSaveAllowed = false + return@lazy null + } + + if (f.isDirectory) { + isSaveAllowed = false + return@lazy null + } + + f + } + + var content by mutableStateOf( + Content( + file?.readText(), + threadSafe + ) + ) + + var isModified by mutableStateOf(false) + + init { + initialize() + } + + private fun Color.darken(fraction: Float) = lerp(this, Color.Black, fraction) + private fun Color.lighten(fraction: Float) = lerp(this, Color.White, fraction) + + private fun buildTextMateTheme(): String { + val comment = if (darkMode) "#8B949E" else "#6B7280" + val keyword = if (darkMode) "#FF7B72" else "#C0392B" + val string = if (darkMode) "#A5D6FF" else "#1155A3" + val function = if (darkMode) "#D2A8FF" else "#7C3AED" + val variable = if (darkMode) "#FFA657" else "#B45309" + val type = if (darkMode) "#7EE787" else "#166534" + val constant = if (darkMode) "#79C0FF" else "#0369A1" + val tag = if (darkMode) "#7EE787" else "#166534" + val attribute = if (darkMode) "#FFA657" else "#9A6700" + val property = if (darkMode) "#79C0FF" else "#0E7490" + val number = if (darkMode) "#79C0FF" else "#047857" + val punctuation = if (darkMode) "#C9D1D9" else "#374151" + val cssValue = if (darkMode) "#A5D6FF" else "#1155A3" + + return """ +{ + "name": "Sora GitHub Contrast", + "type": "${if (darkMode) "dark" else "light"}", + "tokenColors": [ + { + "name": "Comments", + "scope": [ + "comment", + "comment.line", + "comment.block", + "punctuation.definition.comment" + ], + "settings": { + "foreground": "$comment" + } + }, + { + "name": "Keywords", + "scope": [ + "keyword", + "keyword.control", + "keyword.operator.word", + "storage", + "storage.type", + "storage.modifier" + ], + "settings": { + "foreground": "$keyword" + } + }, + { + "name": "Operators / punctuation", + "scope": [ + "keyword.operator", + "punctuation", + "meta.brace", + "meta.delimiter" + ], + "settings": { + "foreground": "$punctuation" + } + }, + { + "name": "Strings", + "scope": [ + "string", + "string.quoted", + "punctuation.definition.string" + ], + "settings": { + "foreground": "$string" + } + }, + { + "name": "Numbers / constants", + "scope": [ + "constant.numeric", + "constant.language", + "constant.character.escape", + "constant" + ], + "settings": { + "foreground": "$number" + } + }, + { + "name": "Functions", + "scope": [ + "entity.name.function", + "support.function", + "meta.function-call", + "variable.function" + ], + "settings": { + "foreground": "$function" + } + }, + { + "name": "Variables", + "scope": [ + "variable", + "variable.other", + "variable.parameter" + ], + "settings": { + "foreground": "$variable" + } + }, + { + "name": "Types / classes", + "scope": [ + "entity.name.type", + "entity.name.class", + "support.type", + "support.class", + "storage.type.java" + ], + "settings": { + "foreground": "$type" + } + }, + { + "name": "HTML/XML tag", + "scope": [ + "entity.name.tag", + "punctuation.definition.tag", + "meta.tag" + ], + "settings": { + "foreground": "$tag" + } + }, + { + "name": "HTML/XML attributes", + "scope": [ + "entity.other.attribute-name", + "entity.other.attribute-name.html", + "entity.other.attribute-name.css" + ], + "settings": { + "foreground": "$attribute" + } + }, + { + "name": "CSS property names", + "scope": [ + "support.type.property-name.css", + "meta.property-name", + "variable.property", + "meta.object-literal.key" + ], + "settings": { + "foreground": "$property" + } + }, + { + "name": "CSS values", + "scope": [ + "support.constant.property-value", + "meta.property-value", + "string.unquoted", + "entity.other.attribute-name.id.css", + "entity.other.attribute-name.class.css" + ], + "settings": { + "foreground": "$cssValue" + } + }, + { + "name": "Regex", + "scope": [ + "string.regexp", + "source.regexp" + ], + "settings": { + "foreground": "$string" + } + }, + { + "name": "Links / markup", + "scope": [ + "markup.underline.link", + "constant.other.reference.link" + ], + "settings": { + "foreground": "$constant", + "fontStyle": "underline" + } + } + ] +} +""".trimIndent() + } + + private fun buildColorScheme(colors: Colors): EditorColorScheme { + val themeRegistry = ThemeRegistry.getInstance() + + // Build and register a dynamic theme from our Colors + val themeJson = buildTextMateTheme() + val themeModel = ThemeModel( + IThemeSource.fromString(IThemeSource.ContentType.JSON, themeJson), + "dynamic" + ) + + runCatching { themeRegistry.loadTheme(themeModel) } + themeRegistry.setTheme("dynamic") + + return object : TextMateColorScheme(themeRegistry, themeModel) { + override fun getColor(type: Int): Int { + val color: Color? = when (type) { + // syntax + ANNOTATION -> colors.mutedForeground.lighten(0.1f) + FUNCTION_NAME -> colors.primary.darken(0.2f) + IDENTIFIER_NAME -> colors.foreground + IDENTIFIER_VAR -> colors.secondary.darken(0.15f) + LITERAL -> colors.accent + OPERATOR -> colors.mutedForeground + COMMENT -> colors.mutedForeground.lighten(0.15f) + KEYWORD -> colors.accent.darken(0.1f) + + // editor chrome + WHOLE_BACKGROUND -> colors.background + TEXT_NORMAL -> colors.foreground + TEXT_SELECTED -> colors.primaryForeground + + // line numbers + LINE_NUMBER_BACKGROUND -> colors.card.darken(0.05f) + LINE_NUMBER -> colors.mutedForeground + LINE_NUMBER_CURRENT -> colors.primary.darken(0.1f) + + // lines / blocks + LINE_DIVIDER -> colors.border + CURRENT_LINE -> colors.muted.darken(0.05f) + BLOCK_LINE -> colors.border + BLOCK_LINE_CURRENT -> colors.border.darken(0.15f) + + // selection / search + SELECTED_TEXT_BACKGROUND -> colors.primary.lighten(0.15f).copy(alpha = 0.25f) + MATCHED_TEXT_BACKGROUND -> colors.primary.lighten(0.3f).copy(alpha = 0.2f) + SELECTION_INSERT -> colors.primary.lighten(0.1f) + SELECTION_HANDLE -> colors.primary.darken(0.1f) + + // scroll + SCROLL_BAR_THUMB -> colors.primary.copy(alpha = 0.45f) + SCROLL_BAR_THUMB_PRESSED -> colors.primary.darken(0.1f).copy(alpha = 0.45f) + + // misc + NON_PRINTABLE_CHAR -> colors.mutedForeground.darken(0.1f) + + else -> null + } + return color?.toArgb() ?: super.getColor(type) + } + } + } + + // --------------------------------------------------------------------------- + // Initialisation + // --------------------------------------------------------------------------- + + private fun initialize() { + TextMateManager.initialize(debug, adbPath) + + val scopeName = when (file?.extension) { + "kt" -> "source.kotlin" + "kts" -> "source.kotlin" + "java" -> "source.java" + "js" -> "source.js" + "ts" -> "source.ts" + "json" -> "source.json" + "xml" -> "text.xml" + "html" -> "text.html.basic" + "css" -> "source.css" + "sh" -> "source.shell" + "lua" -> "source.lua" + "py" -> "source.python" + "cpp" -> "source.cpp" + "c" -> "source.c" + "rs" -> "source.rust" + else -> null + } + + val scheme = buildColorScheme(colors) + + editor.apply { + setText(content) + setTextSize(textStyle.fontSize.value) + setTypefaceText(typeface) + scopeName?.runCatching { + setEditorLanguage( + TextMateLanguage.create(this, true) + ) + }?.onFailure { + Log.e("CodeEditor", "Failed to set language: $it") + } + colorScheme = scheme + setHighlightCurrentLine(true) + setEditable(true) + isWordwrap = false + setUndoEnabled(true) + } + + content.addContentListener(contentListener) + } + + fun saveFile() { + + if (!isModified) return + + val target = + file ?: run { + Toast + .makeText( + context, + "Cannot save", + Toast.LENGTH_SHORT + ) + .show() + + return + } + + scope.launch { + + target.writeText( + editor.text.toString() + ) + + isModified = false + + Toast + .makeText( + context, + "Saved", + Toast.LENGTH_SHORT + ) + .show() + } + } + + private val contentListener + get() = + object : ContentListener { + + override fun beforeReplace(content: Content) { + isModified = true + } + + override fun afterInsert( + content: Content, + startLine: Int, + startColumn: Int, + endLine: Int, + endColumn: Int, + insertedContent: CharSequence, + ) { + isModified = true + } + + override fun afterDelete( + content: Content, + startLine: Int, + startColumn: Int, + endLine: Int, + endColumn: Int, + deletedContent: CharSequence, + ) { + isModified = true + } + } +} + +@Composable +fun rememberCodeEditorState( + file: SuFile? = null, + threadSafe: Boolean = true, + colors: Colors = MMRLXTheme.colors, + textStyle: TextStyle = LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ), +): CodeEditorState { + val prefs = LocalUserPreferences.current + val module = LocalModule.current + val context = LocalContext.current + val typeface by rememberTypefaceFrom(textStyle) + val scope = rememberCoroutineScope() + + return remember { + CodeEditorState( + scope = scope, + context = context, + colors = colors, + darkMode = true, + initialFile = file, + threadSafe = threadSafe, + textStyle = textStyle, + typeface = typeface, + adbPath = module.adbPath, + debug = prefs.developerMode + ) + } +} + +@Composable +fun CodeEditor( + modifier: Modifier = Modifier, + state: CodeEditorState, +) { + AndroidView( + factory = { state.editor }, + modifier = modifier, + onRelease = { it.release() } + ) +} + +@Destination +@Composable +fun FileEditorScreen(path: String) { + BasePathScope { + FileEditorContent(path) + } +} + +@Composable +fun FileEditorContent(path: String) { + val navigator = LocalDestinationsNavigator.current + val file = remember(path) { path.toSuFile() } + val state = rememberCodeEditorState( + file = file, + ) + + val confirmExit = rememberDialog() + + val backClick: () -> Unit = remember { + { + if (state.isModified) { + confirmExit.open() + } else { + navigator.popBackStack() + } + } + } + + BackHandler(onBack = backClick) + + Scaffold( + toolbar = { + NavigateUpToolbar( + title = { + ToolbarTitle( + title = file.name + ) + }, + onBack = backClick, + actions = { + IconButton( + enabled = state.isModified, + onClick = { + state.saveFile() + } + ) { + Icon( + painter = painterResource(R.drawable.device_floppy), + contentDescription = "Save" + ) + } + } + ) + }, + contentWindowInsets = WindowInsets.none + ) { + CodeEditor( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize(), + state = state + ) + } + + confirmExit { + Title { + Text("Unsaved") + } + + Content { + Text("There'll unsaved changes in your file. Do you want exit?") + } + + Footer { + Button( + onClick = { + confirmExit.close() + }, + variant = ButtonVariant.Outline + ) { + Text(stringResource(R.string.cancel)) + } + + Button( + onClick = { + confirmExit.close() + navigator.popBackStack() + }, + ) { + Text(stringResource(R.string.confirm)) + } + } + } +} + +@Composable +fun rememberTypefaceFrom(textStyle: TextStyle): State { + val resolver = LocalFontFamilyResolver.current + val family = textStyle.fontFamily + val weight = textStyle.fontWeight ?: FontWeight.Normal + val style = textStyle.fontStyle ?: FontStyle.Normal + val synth = textStyle.fontSynthesis ?: FontSynthesis.All + return remember(family, weight, style, synth) { + resolver.resolve(family, weight, style, synth) + } as State +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/editor/FileExplorerScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/FileExplorerScreen.kt similarity index 73% rename from app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/editor/FileExplorerScreen.kt rename to app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/FileExplorerScreen.kt index 4dc736e3..f60cd968 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/editor/FileExplorerScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/fileexplorer/FileExplorerScreen.kt @@ -1,4 +1,4 @@ -package com.dergoogler.mmrl.wx.ui.screens.modules.screens.editor +package com.dergoogler.mmrl.wx.ui.screens.fileexplorer import android.net.Uri import androidx.activity.compose.BackHandler @@ -6,6 +6,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -25,25 +26,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -65,20 +52,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.dergoogler.mmrl.ext.iconSize import com.dergoogler.mmrl.ext.none -import com.dergoogler.mmrl.platform.content.LocalModule -import com.dergoogler.mmrl.platform.file.SuFile -import com.dergoogler.mmrl.platform.file.SuFile.Companion.toFormattedFileSize -import com.dergoogler.mmrl.platform.model.ModId.Companion.moduleDir -import com.dergoogler.mmrl.ui.component.listItem.dsl.List -import com.dergoogler.mmrl.ui.component.listItem.dsl.ListScope -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.Item -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Description -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Start -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Title -import com.dergoogler.mmrl.ui.component.scaffold.Scaffold -import com.dergoogler.mmrl.ui.component.toolbar.Toolbar -import com.dergoogler.mmrl.ui.component.toolbar.ToolbarTitle import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.ui.component.BasePathScope +import com.dergoogler.mmrl.wx.ui.component.LocalBasePath +import com.dergoogler.mmrl.wx.ui.component.NavigateUpToolbar +import com.dergoogler.mmrl.wx.ui.navigation.FileExplorerGraph import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator import com.dergoogler.mmrl.wx.util.toFormattedDateSafely import com.dergoogler.mmrl.wx.viewmodel.FileExplorerViewModel @@ -86,17 +64,50 @@ import com.dergoogler.mmrl.wx.viewmodel.FileItem import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.FileEditorScreenDestination - -@Destination +import dev.mmrlx.compose.layout.card +import dev.mmrlx.compose.ui.CircularProgressIndicator +import dev.mmrlx.compose.ui.LocalTextStyle +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.button.Button +import dev.mmrlx.compose.ui.button.ButtonVariant +import dev.mmrlx.compose.ui.dialog.Content +import dev.mmrlx.compose.ui.dialog.DialogScope +import dev.mmrlx.compose.ui.dialog.Footer +import dev.mmrlx.compose.ui.dialog.Title +import dev.mmrlx.compose.ui.dialog.rememberDialog +import dev.mmrlx.compose.ui.ext.with +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.icon.IconButton +import dev.mmrlx.compose.ui.list.component.RawItem +import dev.mmrlx.compose.ui.list.component.item.Description +import dev.mmrlx.compose.ui.list.component.item.Start +import dev.mmrlx.compose.ui.list.component.item.Title +import dev.mmrlx.compose.ui.text.OutlinedInput +import dev.mmrlx.compose.ui.text.rememberInputState +import dev.mmrlx.compose.ui.theme.LocalContentColor +import dev.mmrlx.compose.ui.theme.MMRLXTheme +import dev.mmrlx.compose.ui.theme.ripple +import dev.mmrlx.compose.ui.toolbar.ToolbarDefaults +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.toFormattedFileSize + +@Destination(start = true) @OptIn(ExperimentalMaterial3Api::class) @Composable -fun FileExplorerScreen( - module: LocalModule, -) { +fun FileExplorerScreen() { + BasePathScope { + FileExplorerContent() + } +} + +@Composable +fun FileExplorerContent() { + val basePath = LocalBasePath.current val navigator = LocalDestinationsNavigator.current val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() - val initialPath = SuFile(module.id.moduleDir) + val createDialog = rememberDialog() + val snackbarHostState = remember { SnackbarHostState() } // FAB state management @@ -135,8 +146,11 @@ fun FileExplorerScreen( } } - LaunchedEffect(module.id) { - viewModel.initialize(initialPath) + LaunchedEffect(basePath, state.currentPath) { + // Only initialize on first load, not when returning from other screens + if (state.currentPath == null) { + viewModel.initialize(SuFile(basePath, "modules")) + } } // Show snackbar for messages @@ -151,7 +165,7 @@ fun FileExplorerScreen( } } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scrollBehavior = ToolbarDefaults.pinnedScrollBehavior() val backClick: () -> Unit = remember(state, isSelectionMode) { { @@ -169,15 +183,15 @@ fun FileExplorerScreen( BackHandler(onBack = backClick) - Scaffold( + dev.mmrlx.compose.ui.scaffold.Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - Toolbar( + toolbar = { + dev.mmrlx.compose.ui.toolbar.Toolbar( title = { - ToolbarTitle( + dev.mmrlx.compose.ui.toolbar.ToolbarTitle( titleContent = { Text( - text = if (isSelectionMode) "${selectedFiles.size} selected" else module.name, + text = if (isSelectionMode) "${selectedFiles.size} selected" else "Explorer", maxLines = 1, overflow = TextOverflow.Ellipsis, color = LocalContentColor.current @@ -235,7 +249,7 @@ fun FileExplorerScreen( Icon( painter = painterResource(R.drawable.trash), contentDescription = "Delete selected", - tint = MaterialTheme.colorScheme.error + tint = MMRLXTheme.colors.destructive ) } } else { @@ -259,12 +273,12 @@ fun FileExplorerScreen( onExpandedChange = { isFabExpanded = it }, onCreateFolder = { createDialogType = CreateType.FOLDER - showCreateDialog = true + createDialog.open() isFabExpanded = false }, onCreateFile = { createDialogType = CreateType.FILE - showCreateDialog = true + createDialog.open() isFabExpanded = false }, onImportFile = { @@ -280,44 +294,37 @@ fun FileExplorerScreen( } }, snackbarHost = { SnackbarHost(snackbarHostState) }, - contentWindowInsets = WindowInsets.none - ) { innerPadding -> + ) { Box { - List( - modifier = Modifier.padding(innerPadding), - ) { - // Error message + dev.mmrlx.compose.ui.list.List { state.errorMessage?.let { error -> - Card( + Box( modifier = Modifier .fillMaxWidth() + .card() + .background(MMRLXTheme.colors.destructive) .padding(bottom = 8.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) ) { Text( text = error, modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onErrorContainer + color = MMRLXTheme.colors.destructiveForeground ) } } - // Success message state.successMessage?.let { success -> - Card( + Box( modifier = Modifier .fillMaxWidth() + .card() + .background(MMRLXTheme.colors.primary) .padding(bottom = 8.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) ) { Text( text = success, modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer + color = MMRLXTheme.colors.primaryForeground ) } } @@ -331,6 +338,11 @@ fun FileExplorerScreen( } } else { LazyColumn( + modifier = Modifier + .with(this@Scaffold) { + it.scaffoldHazeSource("licenses") + }, + contentPadding = this@Scaffold.contentPadding, verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(state.fileItems) { fileItem -> @@ -354,7 +366,6 @@ fun FileExplorerScreen( } else { navigator.navigate( FileEditorScreenDestination( - module, fileItem.file.path ) ) @@ -384,23 +395,42 @@ fun FileExplorerScreen( } } - // Create dialog - if (showCreateDialog) { + createDialog { CreateDialog( type = createDialogType, - onDismiss = { showCreateDialog = false }, + onDismiss = { createDialog.close() }, onConfirm = { name, content -> when (createDialogType) { CreateType.FOLDER -> viewModel.createFolder(name) CreateType.FILE -> viewModel.createFile(name, content ?: "") } - showCreateDialog = false + createDialog.close() } ) } } } + +@Composable +private fun ContentWrapper( + title: String = "Error", + content: @Composable dev.mmrlx.compose.ui.scaffold.ScaffoldScope.() -> Unit, +) { + val navigator = LocalDestinationsNavigator.current + dev.mmrlx.compose.ui.scaffold.Scaffold( + toolbar = { + NavigateUpToolbar( + title = title, + onBack = { navigator.popBackStack() }, + ) + }, + contentWindowInsets = WindowInsets.none, + content = content + ) +} + + @Composable private fun ExpandableFab( isExpanded: Boolean, @@ -433,11 +463,8 @@ private fun ExpandableFab( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + Box( + modifier = Modifier.card() ) { Text( text = label, @@ -445,10 +472,9 @@ private fun ExpandableFab( style = MaterialTheme.typography.bodySmall ) } - SmallFloatingActionButton( + + dev.mmrlx.compose.ui.fab.SmallFloatingActionButton( onClick = action, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer ) { Icon( painter = painterResource(icon), @@ -460,7 +486,7 @@ private fun ExpandableFab( } // Main FAB - ExtendedFloatingActionButton( + dev.mmrlx.compose.ui.fab.ExtendedFloatingActionButton( onClick = { onExpandedChange(!isExpanded) }, text = { Text( @@ -493,57 +519,60 @@ enum class CreateType { } @Composable -private fun CreateDialog( +private fun DialogScope.CreateDialog( type: CreateType, onDismiss: () -> Unit, onConfirm: (String, String?) -> Unit, ) { - var name by remember { mutableStateOf("") } - var content by remember { mutableStateOf("") } + + val name = rememberInputState("") + val content = rememberInputState("") val isFile = type == CreateType.FILE - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(text = "Create ${if (isFile) "File" else "Folder"}") - }, - text = { - Column { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("${if (isFile) "File" else "Folder"} name") }, - singleLine = !isFile, - modifier = Modifier.fillMaxWidth() - ) + Title { + Text(text = "Create ${if (isFile) "File" else "Folder"}") + } - if (isFile) { - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = content, - onValueChange = { content = it }, - label = { Text("Content (optional)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - maxLines = 5 - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { onConfirm(name, if (isFile) content else null) }, - enabled = name.isNotBlank() - ) { - Text("Create") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") + Content { + Column { + OutlinedInput( + label = { Text("${if (isFile) "File" else "Folder"} name") }, + state = name, + modifier = Modifier + .fillMaxWidth(), + lineLimits = TextFieldLineLimits.SingleLine, + ) + + if (isFile) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedInput( + label = { Text("Content (optional)") }, + state = content, + modifier = Modifier + .fillMaxWidth(), + lineLimits = TextFieldLineLimits.MultiLine(3, 5), + ) } } - ) + } + + Footer { + Button(onClick = onDismiss, variant = ButtonVariant.Outline) { + Text("Cancel") + } + + Button( + onClick = { + onConfirm( + name.text.toString(), + if (isFile) content.text.toString() else null + ) + }, + enabled = name.text.isNotBlank(), variant = ButtonVariant.Default + ) { + Text("Create") + } + } } @Composable @@ -554,7 +583,6 @@ private fun PathBreadcrumb( val pathParts = mutableListOf() var tempPath: SuFile? = currentPath - // Build path hierarchy while (tempPath != null) { pathParts.add(0, tempPath) tempPath = tempPath.parentSuFile @@ -568,10 +596,11 @@ private fun PathBreadcrumb( itemsIndexed( items = pathParts, key = { index, item -> item.path + index } - ) { index, path -> + val density = LocalDensity.current val textStyle = LocalTextStyle.current + val iconSize = Modifier.iconSize( density = density, textStyle = textStyle, @@ -583,18 +612,22 @@ private fun PathBreadcrumb( painter = painterResource(R.drawable.chevron_right), contentDescription = null, modifier = iconSize, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MMRLXTheme.colors.mutedForeground ) Spacer(Modifier.width(4.dp)) } Text( - text = if (index == 0 && path.name.isEmpty()) "Root" else path.name, + text = if (index == 0 && path.name.isEmpty()) { + "Root" + } else { + path.name + }, color = if (index == pathParts.lastIndex) { - MaterialTheme.colorScheme.primary + MMRLXTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant + MMRLXTheme.colors.mutedForeground }, modifier = Modifier.clickable { if (index != pathParts.lastIndex) { @@ -607,7 +640,7 @@ private fun PathBreadcrumb( } @Composable -private fun ListScope.FileItemRow( +private fun dev.mmrlx.compose.ui.list.ListScope.FileItemRow( fileItem: FileItem, isSelected: Boolean = false, isSelectionMode: Boolean = false, @@ -616,8 +649,21 @@ private fun ListScope.FileItemRow( ) { val interactionSource = remember { MutableInteractionSource() } - Item( + val titleColor = when { + isSelected -> MMRLXTheme.colors.primary + else -> MMRLXTheme.colors.foreground + } + + val descriptionColor = MMRLXTheme.colors.mutedForeground + + val itemBackground = when { + isSelected -> MMRLXTheme.colors.accent + else -> Color.Transparent + } + + RawItem( modifier = Modifier + .background(itemBackground) .combinedClickable( enabled = true, interactionSource = interactionSource, @@ -625,19 +671,15 @@ private fun ListScope.FileItemRow( indication = ripple(), onClick = onClick, onLongClick = onLongClick - ), + ) + .contentPadding(), ) { Start { Box { - Icon( + Image( painter = painterResource(fileItem.icon), contentDescription = null, modifier = Modifier.size(32.dp), - tint = when { - isSelected -> MaterialTheme.colorScheme.primary - fileItem.isDirectory -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant - } ) if (isSelected) { @@ -647,7 +689,7 @@ private fun ListScope.FileItemRow( modifier = Modifier .size(16.dp) .align(Alignment.BottomEnd), - tint = MaterialTheme.colorScheme.primary + tint = MMRLXTheme.colors.primary ) } } @@ -656,11 +698,15 @@ private fun ListScope.FileItemRow( Title { Text( text = fileItem.name, - style = MaterialTheme.typography.bodyLarge, + style = MMRLXTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, - fontWeight = if (fileItem.isDirectory) FontWeight.Medium else FontWeight.Normal, - color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + fontWeight = if (fileItem.isDirectory) { + FontWeight.Medium + } else { + FontWeight.Normal + }, + color = titleColor ) } @@ -671,15 +717,15 @@ private fun ListScope.FileItemRow( if (!fileItem.isDirectory && fileItem.size > 0) { Text( text = fileItem.size.toFormattedFileSize(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MMRLXTheme.typography.bodySmall, + color = descriptionColor ) } Text( text = fileItem.lastModified.toFormattedDateSafely, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MMRLXTheme.typography.bodySmall, + color = descriptionColor ) } } diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModuleItem.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModuleItem.kt index 100a312f..0b9dc4f2 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModuleItem.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModuleItem.kt @@ -1,24 +1,27 @@ package com.dergoogler.mmrl.wx.ui.screens.modules +import android.content.Intent import android.widget.Toast import androidx.annotation.DrawableRes import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter @@ -26,40 +29,51 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.dergoogler.mmrl.ext.fadingEdge -import com.dergoogler.mmrl.ext.nullable import com.dergoogler.mmrl.ext.nullply -import com.dergoogler.mmrl.ext.takeTrue +import com.dergoogler.mmrl.ext.toFormattedDateSafely import com.dergoogler.mmrl.platform.PlatformManager -import com.dergoogler.mmrl.platform.content.LocalModule -import com.dergoogler.mmrl.platform.content.LocalModule.Companion.config -import com.dergoogler.mmrl.platform.content.LocalModule.Companion.hasModConf -import com.dergoogler.mmrl.platform.content.LocalModule.Companion.hasWebUI import com.dergoogler.mmrl.platform.content.State -import com.dergoogler.mmrl.platform.file.SuFile import com.dergoogler.mmrl.platform.file.SuFile.Companion.toFormattedFileSize -import com.dergoogler.mmrl.platform.model.ModId.Companion.moduleDir -import com.dergoogler.mmrl.ui.component.LabelItem -import com.dergoogler.mmrl.ui.component.LabelItemDefaults -import com.dergoogler.mmrl.ui.component.LocalCover -import com.dergoogler.mmrl.ui.component.card.Card -import com.dergoogler.mmrl.ui.component.card.component.Absolute -import com.dergoogler.mmrl.ui.component.text.TextWithIcon -import com.dergoogler.mmrl.ui.component.text.TextWithIconDefaults +import com.dergoogler.mmrl.platform.model.ModId.Companion.toModId +import com.dergoogler.mmrl.webui.activity.WXActivity.Companion.launchWebUIX import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.datastore.model.WebUIEngine import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.model.module.Module +import com.dergoogler.mmrl.wx.ui.component.LocalCover import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator -import com.dergoogler.mmrl.wx.util.launchModConf -import com.dergoogler.mmrl.wx.util.launchWebUI -import com.dergoogler.mmrl.wx.util.toFormattedDateSafely +import com.dergoogler.mmrl.wx.util.toPainter import com.dergoogler.mmrl.wx.util.versionDisplay -import com.ramcosta.composedestinations.generated.destinations.FileExplorerScreenDestination +import dev.mmrlx.compose.layout.flashlightCard +import dev.mmrlx.compose.ui.Avatar +import dev.mmrlx.compose.ui.HorizontalDivider +import dev.mmrlx.compose.ui.Skeleton +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.ext.fadingEdge +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.text.FormatText +import dev.mmrlx.compose.ui.theme.MMRLXTheme +import dev.mmrlx.nio.inputStream +import dev.mmrlx.thread.RootCallable +import dev.mmrlx.thread.ktx.asThread + +@Composable +fun RootCallable.produceState( + initialValue: T, +) = produceState(initialValue) { + value = asThread() +} + +@Composable +fun produceRootCallableState( + initialValue: T, + block: RootCallable, +) = block.produceState(initialValue) @Composable fun ModuleItem( - module: LocalModule, + module: Module, alpha: Float = 1f, decoration: TextDecoration = TextDecoration.None, indicator: @Composable() (() -> Unit?)? = null, @@ -68,155 +82,141 @@ fun ModuleItem( ) { val navigator = LocalDestinationsNavigator.current val userPreferences = LocalUserPreferences.current + // TODO: add menu settings back val menu = userPreferences.modulesMenu val context = LocalContext.current val canWenUIAccessed = - PlatformManager.isAlive && (module.hasWebUI || module.hasModConf) && module.state != State.REMOVE + PlatformManager.isAlive && (module.hasWebUI) && module.state != State.REMOVE +// +// val config = remember(module) { +// module.config +// } - val clicker: (() -> Unit)? = canWenUIAccessed nullable jump@{ - if (module.hasModConf) { - userPreferences.launchModConf(context, module.id) - return@jump - } + val toastStr = stringResource(R.string.unsupported_engine) - if (module.hasWebUI) { - userPreferences.launchWebUI(context, module.id) - return@jump - } + Column( + modifier = Modifier + .combinedClickable( + onClick = { + if (canWenUIAccessed) { + val baseDir = module.adbPath.baseDir - Toast.makeText(context, "Unsupported module", Toast.LENGTH_SHORT).show() - } + if (userPreferences.webuiEngine == WebUIEngine.MX) { + val intent = Intent( + context, + com.dergoogler.mmrl.wx.ui.webui.WebUIActivity::class.java + ) + .apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + putExtra("MODULE_ID", module.id) + } - val config = remember(module) { - module.config - } + context.startActivity(intent) + return@combinedClickable + } - Card( - onClick = clicker, - onLongClick = { - navigator.navigate(FileExplorerScreenDestination(module)) - } + // TODO: deprecate WX engine, devtools are crashing currently! + if (userPreferences.webuiEngine == WebUIEngine.WX) { + context.launchWebUIX( + module.id.toModId(baseDir), + baseDir + ) + return@combinedClickable + } + } + + Toast.makeText( + context, toastStr, Toast.LENGTH_SHORT + ).show() + } + ) + .fillMaxWidth() + .flashlightCard() ) { - Absolute( - alignment = Alignment.Center, - ) { - indicator.nullable { - it() + module.banner?.let { + it.exists { cover -> + LocalCover( + modifier = Modifier.fadingEdge( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black, + ), + startY = Float.POSITIVE_INFINITY, + endY = 0f + ), + ), + inputStream = cover.inputStream(), + ) } } Column( - modifier = Modifier.relative() + modifier = Modifier.padding(16.dp), ) { - config.cover.nullable(menu.showCover) { - val file = SuFile(module.id.moduleDir, it) - - file.exists { i -> - LocalCover( - modifier = Modifier.fadingEdge( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black, - ), - startY = Float.POSITIVE_INFINITY, - endY = 0f - ), - ), - inputStream = i.newInputStream(), - ) - } - } - Row( - modifier = Modifier.padding(all = 16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - Column( - modifier = Modifier - .alpha(alpha = alpha) - .weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - TextWithIcon( - text = config.name ?: module.name, - icon = module.hasModConf nullable R.drawable.brand_kotlin, - style = TextWithIconDefaults.style.copy( - overflow = TextOverflow.Ellipsis, - textStyle = MaterialTheme.typography.titleSmall, - maxLines = 2 - ) - ) + Avatar( + initials = module.name.take( + 2 + ).uppercase(), + size = 36.dp, + painter = module.icon?.toPainter() + ) + Column { Text( - text = stringResource( - id = R.string.author, - module.versionDisplay, module.author - ), - style = MaterialTheme.typography.bodySmall, - textDecoration = decoration, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = module.name, + style = MMRLXTheme.typography.titleSmall ) - if (module.lastUpdated != 0L && menu.showUpdatedTime) { - Text( - text = stringResource( - id = R.string.update_on, - module.lastUpdated.toFormattedDateSafely - ), - style = MaterialTheme.typography.bodySmall, - textDecoration = decoration, - color = MaterialTheme.colorScheme.outline - ) - } + Text( + text = "${module.author}, ${module.versionDisplay}", + style = MMRLXTheme.typography.labelSmall, + color = MMRLXTheme.colors.mutedForeground + ) } } Text( - modifier = Modifier - .alpha(alpha = alpha) - .padding(horizontal = 16.dp), - text = config.description ?: module.description, - style = MaterialTheme.typography.bodySmall, - textDecoration = decoration, - maxLines = 5, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.outline + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + text = module.description, + style = MMRLXTheme.typography.bodySmall ) - Row( - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + FormatText( + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), + text = "%y %s • %y %s", + style = MMRLXTheme.typography.labelSmall, + color = MMRLXTheme.colors.mutedForeground ) { - userPreferences.developerMode.takeTrue { - LabelItem( - text = module.id.toString(), - upperCase = false + composable { + Icon( + modifier = Modifier.size(fontSize.dp), + painter = painterResource(R.drawable.folder), + tint = MMRLXTheme.colors.mutedForeground ) } - - LabelItem( - text = module.size.toFormattedFileSize(), - style = LabelItemDefaults.style.copy( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer + string(module.size.toFormattedFileSize()) + composable { + Icon( + modifier = Modifier.size(fontSize.dp), + painter = painterResource(R.drawable.git_branch), + tint = MMRLXTheme.colors.mutedForeground ) - ) + } + string(module.lastUpdated.toFormattedDateSafely(userPreferences.datePattern)) } - HorizontalDivider( - thickness = 1.5.dp, - color = MaterialTheme.colorScheme.surface, - modifier = Modifier.padding(top = 8.dp) - ) + HorizontalDivider(Modifier.padding(top = 8.dp)) Row( modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(top = 8.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -235,6 +235,81 @@ fun ModuleItem( } } +@Composable +fun SkeletonModuleItem( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .flashlightCard() + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + modifier = Modifier.padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Skeleton( + modifier = Modifier.size(36.dp) + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Skeleton( + modifier = Modifier + .height(18.dp) + .width(140.dp) + ) + + Skeleton( + modifier = Modifier + .height(12.dp) + .width(100.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Skeleton( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Skeleton( + modifier = Modifier + .fillMaxWidth(0.82f) + .height(12.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Skeleton( + modifier = Modifier + .width(72.dp) + .height(10.dp) + ) + + Skeleton( + modifier = Modifier + .width(92.dp) + .height(10.dp) + ) + } + } + } +} + @Composable fun StateIndicator( @DrawableRes icon: Int, @@ -246,3 +321,6 @@ fun StateIndicator( alpha = 0.1f, colorFilter = ColorFilter.tint(color) ) + +private const val DefaultAspectRatio = 2.048f +private val DefaultShape: RoundedCornerShape = RoundedCornerShape(0.dp) diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModuleList.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModuleList.kt index 770d9427..a54c910a 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModuleList.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModuleList.kt @@ -2,90 +2,87 @@ package com.dergoogler.mmrl.wx.ui.screens.modules import android.widget.Toast import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.dergoogler.mmrl.modconf.config.toModConfConfig -import com.dergoogler.mmrl.platform.Platform -import com.dergoogler.mmrl.platform.content.LocalModule -import com.dergoogler.mmrl.platform.content.LocalModule.Companion.hasModConf -import com.dergoogler.mmrl.platform.content.LocalModule.Companion.hasWebUI import com.dergoogler.mmrl.platform.content.State -import com.dergoogler.mmrl.platform.model.ModId.Companion.moduleDir -import com.dergoogler.mmrl.ui.component.dialog.ConfirmData -import com.dergoogler.mmrl.ui.component.dialog.confirm -import com.dergoogler.mmrl.ui.component.scrollbar.VerticalFastScrollbar -import com.dergoogler.mmrl.webui.model.toWebUIConfig import com.dergoogler.mmrl.wx.R -import com.dergoogler.mmrl.wx.ui.activity.modconf.ModConfActivity -import com.dergoogler.mmrl.wx.ui.activity.webui.WebUIActivity +import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.model.module.Module import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator import com.ramcosta.composedestinations.generated.destinations.ConfigEditorScreenDestination +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.button.Button +import dev.mmrlx.compose.ui.button.ButtonSize +import dev.mmrlx.compose.ui.button.ButtonVariant +import dev.mmrlx.compose.ui.dialog.Content +import dev.mmrlx.compose.ui.dialog.Footer +import dev.mmrlx.compose.ui.dialog.Title +import dev.mmrlx.compose.ui.dialog.rememberDialog +import dev.mmrlx.compose.ui.ext.with +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.scaffold.ScaffoldScope +import java.io.File @Composable -fun ModulesList( - list: List, +fun ScaffoldScope.ModulesList( + list: List, state: LazyListState, - isProviderAlive: Boolean, - platform: Platform, -) = Box( - modifier = Modifier.fillMaxSize() ) { LazyColumn( state = state, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.with(this@ModulesList) { it.scaffoldHazeSource() }, + contentPadding = PaddingValues( + top = this@ModulesList.scaffoldTopPadding + 8.dp, + start = 8.dp, + end = 8.dp, + bottom = this@ModulesList.scaffoldBottomPadding + 8.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { items( - items = list.filter { it.hasWebUI || it.hasModConf }, + items = list.filter { it.hasWebUI }, key = { it.id } ) { module -> ModuleItem( - isProviderAlive = isProviderAlive, - platform = platform, module = module, + placeholder = null ) } } - - VerticalFastScrollbar( - state = state, - modifier = Modifier.align(Alignment.CenterEnd) - ) +// +// VerticalFastScrollbar( +// state = state, +// modifier = Modifier.align(Alignment.CenterEnd) +// ) } @Composable fun ModuleItem( - module: LocalModule, - platform: Platform, - isProviderAlive: Boolean, + module: Module, + placeholder: Nothing?, ) { val context = LocalContext.current + val prefs = LocalUserPreferences.current val navigator = LocalDestinationsNavigator.current + val removeDialog = rememberDialog() + ModuleItem( module = module, indicator = { when (module.state) { - State.REMOVE -> StateIndicator(R.drawable.trash) + State.REMOVE, + -> StateIndicator(R.drawable.trash) + State.UPDATE -> StateIndicator(R.drawable.device_mobile_down) else -> {} } @@ -93,124 +90,105 @@ fun ModuleItem( leadingButton = { ConfigButton( onClick = { - navigator.navigate(ConfigEditorScreenDestination(module)) + navigator.navigate(ConfigEditorScreenDestination(module.id)) }, enabled = module.state != State.REMOVE ) }, trailingButton = { - ShortcutAdd( - module = module, - enabled = isProviderAlive - ) - - if (platform.isNonRoot) { - val colorScheme = MaterialTheme.colorScheme - RemoveButton(isProviderAlive) { - context.confirm( - ConfirmData( - title = "Remove ${module.name}?", - description = "Are you sure that you want to remove this module?", - onConfirm = { - val file = module.id.moduleDir.toExtFile() - - if (file.deleteRecursively()) { - Toast.makeText( - context, - "Successfully removed!", - Toast.LENGTH_SHORT - ).show() - return@ConfirmData - } - - Toast.makeText( - context, - "Failed to remove", - Toast.LENGTH_SHORT - ).show() - }, - ), - colorScheme - ) + if (prefs.isNonRoot) { + RemoveButton { + removeDialog.open() } } } ) -} -@Composable -private fun ShortcutAdd( - module: LocalModule, - enabled: Boolean, -) { - val webUiConfig = module.id.toWebUIConfig() - val modConfConfig = module.id.toModConfConfig() + removeDialog { + Title { + Text("Remove ${module.name}?") + } - val context = LocalContext.current + Content { + Text("Are you sure that you want to remove this module?") + } - FilledTonalButton( - onClick = { - if (module.hasModConf) { - modConfConfig.createShortcut(context, ModConfActivity::class.java) - return@FilledTonalButton + Footer { + Button( + onClick = { + removeDialog.close() + }, + variant = ButtonVariant.Outline + ) { + Text(stringResource(R.string.cancel)) } - if (module.hasWebUI) { - webUiConfig.createShortcut(context, WebUIActivity::class.java) - return@FilledTonalButton + Button( + variant = ButtonVariant.Destructive, + onClick = { + val file = File(module.path.moduleDir) + + if (file.deleteRecursively()) { + Toast.makeText( + context, + "Successfully removed!", + Toast.LENGTH_SHORT + ).show() + return@Button + } + + Toast.makeText( + context, + "Failed to remove", + Toast.LENGTH_SHORT + ).show() + + removeDialog.close() + }, + ) { + Text(stringResource(R.string.confirm)) } + } - Toast.makeText(context, "Unsupported module", Toast.LENGTH_SHORT).show() - }, - enabled = enabled - && (webUiConfig.canAddWebUIShortcut() || modConfConfig.canAddWebUIShortcut()) - && !(webUiConfig.hasWebUIShortcut( - context - ) || modConfConfig.hasWebUIShortcut(context)), - contentPadding = PaddingValues(horizontal = 12.dp) - ) { - Icon( - modifier = Modifier.size(20.dp), - painter = painterResource(id = R.drawable.link), - contentDescription = null - ) - - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(id = R.string.add_shortcut) - ) } } - @Composable private fun ConfigButton( enabled: Boolean, onClick: () -> Unit, -) = FilledTonalButton( - onClick = onClick, - enabled = enabled, - contentPadding = PaddingValues(horizontal = 12.dp) ) { - Icon( - modifier = Modifier.size(20.dp), - painter = painterResource(id = R.drawable.settings), - contentDescription = null - ) + Button( + onClick = onClick, + enabled = enabled, + variant = ButtonVariant.Outline, + size = ButtonSize.Sm + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.settings), + contentDescription = null + + ) + } } @Composable private fun RemoveButton( - enabled: Boolean, + enabled: Boolean = true, onClick: () -> Unit, -) = FilledTonalButton( - onClick = onClick, - enabled = enabled, - contentPadding = PaddingValues(horizontal = 12.dp) ) { - Icon( - modifier = Modifier.size(20.dp), - painter = painterResource(id = R.drawable.trash), - contentDescription = null - ) + Button( + onClick = onClick, + enabled = enabled, + variant = ButtonVariant.Destructive, + size = ButtonSize.Sm + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.trash), + contentDescription = null + + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModulesMenu.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModulesMenu.kt index 939adcd7..c8dd7ba7 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModulesMenu.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModulesMenu.kt @@ -1,53 +1,56 @@ package com.dergoogler.mmrl.wx.ui.screens.modules +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.datastore.model.ModulesMenu import com.dergoogler.mmrl.datastore.model.Option +import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences -import com.dergoogler.mmrl.ui.component.BottomSheet -import com.dergoogler.mmrl.ui.component.MenuChip -import com.dergoogler.mmrl.ui.component.Segment -import com.dergoogler.mmrl.ui.component.SegmentedButtons -import com.dergoogler.mmrl.ui.component.SegmentedButtonsDefaults +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.button.Segment +import dev.mmrlx.compose.ui.button.SegmentedButtons +import dev.mmrlx.compose.ui.chip.FilterChip +import dev.mmrlx.compose.ui.chip.FilterChipDefaults +import dev.mmrlx.compose.ui.dialog.rememberModalBottomSheet +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.icon.IconButton @Composable fun ModulesMenu( setMenu: (ModulesMenu) -> Unit, ) { val userPreferences = LocalUserPreferences.current - var open by rememberSaveable { mutableStateOf(false) } + val sheet = rememberModalBottomSheet() IconButton( - onClick = { open = true } + onClick = { sheet.open() } ) { Icon( painter = painterResource(id = R.drawable.filter_outlined), contentDescription = null ) - if (open) { - MenuBottomSheet( - onClose = { open = false }, + sheet { + MenuBottomSheetContent( menu = userPreferences.modulesMenu, setMenu = setMenu ) @@ -56,11 +59,10 @@ fun ModulesMenu( } @Composable -private fun MenuBottomSheet( - onClose: () -> Unit, +private fun ColumnScope.MenuBottomSheetContent( menu: ModulesMenu, setMenu: (ModulesMenu) -> Unit, -) = BottomSheet(onDismissRequest = onClose) { +) { val options = listOf( Option.Name to R.string.menu_sort_option_name, Option.UpdatedTime to R.string.menu_sort_option_updated, @@ -82,19 +84,11 @@ private fun MenuBottomSheet( style = MaterialTheme.typography.titleSmall ) - SegmentedButtons( - border = SegmentedButtonsDefaults.border( - color = MaterialTheme.colorScheme.secondary - ) - ) { + SegmentedButtons { options.forEach { (option, label) -> Segment( selected = option == menu.option, onClick = { setMenu(menu.copy(option = option)) }, - colors = SegmentedButtonsDefaults.buttonColor( - selectedContainerColor = MaterialTheme.colorScheme.secondary, - selectedContentColor = MaterialTheme.colorScheme.onSecondary - ), icon = null ) { Text(text = stringResource(id = label)) @@ -128,4 +122,50 @@ private fun MenuBottomSheet( ) } } -} \ No newline at end of file +} + +@Composable +fun MenuChip( + selected: Boolean, + onClick: () -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = label, + modifier = modifier.height(FilterChipDefaults.Height), + enabled = enabled, + leadingIcon = { + if (!selected) { + Point(size = 8.dp) + } + }, + trailingIcon = { + if (selected) { + Icon( + painter = painterResource(dev.mmrlx.ui.R.drawable.done), + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize), + ) + } + }, + shape = CircleShape, + ) +} + +@Composable +private fun Point( + size: Dp, + color: Color = LocalContentColor.current, +) = Canvas( + modifier = Modifier.size(size), +) { + drawCircle( + color = color, + radius = this.size.width / 2, + center = this.center, + ) +} diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModulesScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModulesScreen.kt index d4ee2a83..b05570d5 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModulesScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/ModulesScreen.kt @@ -1,44 +1,54 @@ +// ModulesScreen.kt package com.dergoogler.mmrl.wx.ui.screens.modules -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dergoogler.mmrl.datastore.model.ModulesMenu -import com.dergoogler.mmrl.ext.none -import com.dergoogler.mmrl.platform.Platform -import com.dergoogler.mmrl.ui.component.Loading import com.dergoogler.mmrl.ui.component.PageIndicator -import com.dergoogler.mmrl.ui.component.SearchTopBar import com.dergoogler.mmrl.ui.component.text.TextRow import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode +import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.ui.component.BottomNavigation import com.dergoogler.mmrl.wx.ui.component.ModuleImporter import com.dergoogler.mmrl.wx.viewmodel.ModulesViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import dev.mmrlx.compose.ui.PullToRefreshBox +import dev.mmrlx.compose.ui.PullToRefreshDefaults.Indicator +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.ext.with +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.icon.IconButton +import dev.mmrlx.compose.ui.rememberPullToRefreshState +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.text.rememberInputState +import dev.mmrlx.compose.ui.toolbar.SearchableToolbar +import dev.mmrlx.compose.ui.toolbar.ToolbarDefaults +import dev.mmrlx.compose.ui.toolbar.ToolbarScrollBehavior +import kotlinx.coroutines.flow.distinctUntilChanged @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeApi::class) @Destination(start = true) @@ -46,95 +56,135 @@ import com.ramcosta.composedestinations.annotation.RootGraph fun ModulesScreen( viewModel: ModulesViewModel = hiltViewModel(), ) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val prefs = LocalUserPreferences.current + val scrollBehavior = ToolbarDefaults.pinnedScrollBehavior() val listState = rememberLazyListState() - val state by viewModel.screenState.collectAsStateWithLifecycle() - val list by viewModel.local.collectAsStateWithLifecycle() + val ptrState = rememberPullToRefreshState() + + val modules by viewModel.local.collectAsStateWithLifecycle() + val isLoaded by viewModel.isLoaded.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val query by viewModel.query.collectAsStateWithLifecycle() - val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val isSearch by viewModel.isSearch.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel.refreshDone) { + viewModel.refreshDone.collect { + ptrState.animateToHidden() + } + } Scaffold( - topBar = { - TopBar( - isSearch = viewModel.isSearch, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + toolbar = { + ModuleScreenToolbar( + isSearch = isSearch, query = query, onQueryChange = viewModel::search, onOpenSearch = viewModel::openSearch, onCloseSearch = viewModel::closeSearch, setMenu = viewModel::setModulesMenu, - scrollBehavior = scrollBehavior + scrollBehavior = scrollBehavior, ) }, + bottomBar = { BottomNavigation() }, floatingActionButton = { - if (viewModel.platform != Platform.NonRoot) return@Scaffold - - ModuleImporter() + if (prefs.workingMode != WorkingMode.MODE_NON_ROOT) return@Scaffold + ModuleImporter(viewModel) }, - contentWindowInsets = WindowInsets.none - ) { innerPadding -> - Box( - modifier = Modifier.padding(innerPadding) - ) { - if (isLoading) { - Loading() - } - - if (list.isEmpty() && !isLoading) { - PageIndicator( - icon = if (viewModel.isSearch) R.drawable.mood_search else R.drawable.mood_cry, - text = if (viewModel.isSearch) R.string.search_empty else R.string.modules_empty, + ) { + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + state = ptrState, + isRefreshing = isRefreshing && isLoaded, + onRefresh = viewModel::refreshModules, + indicator = { + Indicator( + modifier = Modifier + .padding(top = this@Scaffold.scaffoldTopPadding) + .align(Alignment.TopCenter), + isRefreshing = isRefreshing && isLoaded, + state = ptrState, ) } - - PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::getLocalAll + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .with(this@Scaffold) { it.scaffoldHazeSource() }, + contentPadding = PaddingValues( + top = this@Scaffold.scaffoldTopPadding + 8.dp, + start = 8.dp, + end = 8.dp, + bottom = this@Scaffold.scaffoldBottomPadding + 8.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - ModulesList( - list = list, - isProviderAlive = viewModel.isProviderAlive, - platform = viewModel.platform, - state = listState, - ) + when { + !isLoaded -> { + items(6) { SkeletonModuleItem() } + } + + modules.isEmpty() -> { + item { + PageIndicator( + icon = if (isSearch) R.drawable.mood_search else R.drawable.mood_cry, + text = if (isSearch) R.string.search_empty else R.string.modules_empty, + ) + } + } + + else -> { + items( + items = modules.filter { it.hasWebUI }, + key = { it.id }, + ) { module -> + ModuleItem(module = module, placeholder = null) + } + } + } } } } } @Composable -private fun TopBar( +private fun ModuleScreenToolbar( isSearch: Boolean, query: String, onQueryChange: (String) -> Unit, onOpenSearch: () -> Unit, onCloseSearch: () -> Unit, setMenu: (ModulesMenu) -> Unit, - scrollBehavior: TopAppBarScrollBehavior, + scrollBehavior: ToolbarScrollBehavior, ) { - var currentQuery by remember { mutableStateOf(query) } - DisposableEffect(isSearch) { - onDispose { currentQuery = "" } + val state = rememberInputState(query) + + LaunchedEffect(Unit) { + snapshotFlow { state.text.toString() } + .distinctUntilChanged() + .collect { text -> + if (text != query) onQueryChange(text) + } } - SearchTopBar( + LaunchedEffect(query) { + if (state.text.toString() != query) { + state.setTextAndPlaceCursorAtEnd(query) + } + } + + SearchableToolbar( + state = state, isSearch = isSearch, - query = currentQuery, - onQueryChange = { - onQueryChange(it) - currentQuery = it - }, - onClose = { - onCloseSearch() - currentQuery = "" - }, title = { TextRow( leadingContent = { Icon( modifier = Modifier.size(30.dp), - painter = painterResource(id = R.drawable.launcher_outline), + painter = painterResource(R.drawable.launcher_outline), contentDescription = null, - tint = MaterialTheme.colorScheme.surfaceTint + tint = MaterialTheme.colorScheme.surfaceTint, ) } ) { @@ -142,21 +192,17 @@ private fun TopBar( } }, scrollBehavior = scrollBehavior, + onClose = onCloseSearch, actions = { if (!isSearch) { - IconButton( - onClick = onOpenSearch - ) { + IconButton(onClick = onOpenSearch) { Icon( - painter = painterResource(id = R.drawable.search), - contentDescription = null + painter = painterResource(R.drawable.search), + contentDescription = null, ) } } - - ModulesMenu( - setMenu = setMenu - ) - } + ModulesMenu(setMenu = setMenu) + }, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/ConfigEditorScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/ConfigEditorScreen.kt index fb846f1c..e026044a 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/ConfigEditorScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/ConfigEditorScreen.kt @@ -2,420 +2,328 @@ package com.dergoogler.mmrl.wx.ui.screens.modules.screens import android.content.Context import android.widget.Toast -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.unit.dp -import com.dergoogler.mmrl.ext.isNotNullOrEmpty -import com.dergoogler.mmrl.ext.none -import com.dergoogler.mmrl.ext.shareText -import com.dergoogler.mmrl.platform.compose.rememberConfigFile -import com.dergoogler.mmrl.platform.content.LocalModule -import com.dergoogler.mmrl.platform.model.ModuleConfig -import com.dergoogler.mmrl.ui.component.BottomSheet -import com.dergoogler.mmrl.ui.component.LabelItem -import com.dergoogler.mmrl.ui.component.NavigateUpTopBar -import com.dergoogler.mmrl.ui.component.dialog.RadioOptionItem -import com.dergoogler.mmrl.ui.component.listItem.ListButtonItem -import com.dergoogler.mmrl.ui.component.listItem.ListEditTextItem -import com.dergoogler.mmrl.ui.component.listItem.ListEditTextSwitchItem -import com.dergoogler.mmrl.ui.component.listItem.ListHeader -import com.dergoogler.mmrl.ui.component.listItem.ListItemDefaults -import com.dergoogler.mmrl.ui.component.listItem.ListRadioCheckItem -import com.dergoogler.mmrl.ui.component.listItem.ListSwitchItem -import com.dergoogler.mmrl.webui.model.WebUIConfig import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.model.module.autoStatusBarsStyle +import com.dergoogler.mmrl.wx.model.module.backHandler +import com.dergoogler.mmrl.wx.model.module.backInterceptor +import com.dergoogler.mmrl.wx.model.module.caching +import com.dergoogler.mmrl.wx.model.module.cachingMaxAge +import com.dergoogler.mmrl.wx.model.module.contentSecurityPolicy +import com.dergoogler.mmrl.wx.model.module.exitConfirm +import com.dergoogler.mmrl.wx.model.module.historyFallback +import com.dergoogler.mmrl.wx.model.module.historyFallbackFile +import com.dergoogler.mmrl.wx.model.module.icon +import com.dergoogler.mmrl.wx.model.module.killShellWhenBackground +import com.dergoogler.mmrl.wx.model.module.pullToRefresh +import com.dergoogler.mmrl.wx.model.module.refreshInterceptor +import com.dergoogler.mmrl.wx.model.module.title +import com.dergoogler.mmrl.wx.model.module.windowResize +import com.dergoogler.mmrl.wx.ui.component.LocalModule +import com.dergoogler.mmrl.wx.ui.component.ModuleScope +import com.dergoogler.mmrl.wx.ui.component.NavButton +import com.dergoogler.mmrl.wx.ui.component.NavigateUpToolbar import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.AdditionalConfigEditorScreenDestination -import com.ramcosta.composedestinations.generated.destinations.PluginsScreenDestination - - -private val Context.interceptorList: List> +import com.ramcosta.composedestinations.generated.destinations.ShortcutCreateScreenDestination +import dev.mmrlx.compose.ui.Badge +import dev.mmrlx.compose.ui.BadgeVariant +import dev.mmrlx.compose.ui.list.List +import dev.mmrlx.compose.ui.list.component.InputDialogItem +import dev.mmrlx.compose.ui.list.component.RadioDialogItem +import dev.mmrlx.compose.ui.list.component.RadioDialogOption +import dev.mmrlx.compose.ui.list.component.SwitchItem +import dev.mmrlx.compose.ui.list.component.item.Description +import dev.mmrlx.compose.ui.list.component.item.Supporting +import dev.mmrlx.compose.ui.list.component.item.Title +import dev.mmrlx.compose.ui.list.component.item.VerticalDividerSwitch +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.toolbar.ToolbarTitle + + +private val Context.interceptorList: List> get() = listOf( - RadioOptionItem( + RadioDialogOption( value = "native", - title = getString(R.string.controlled_by_native) + title = getString(R.string.controlled_by_native), + desc = getString(R.string.controlled_by_native_desc) ), - RadioOptionItem( + RadioDialogOption( value = "javascript", - title = getString(R.string.controlled_by_javascript) + title = getString(R.string.controlled_by_javascript), + desc = getString(R.string.controlled_by_javascript_desc) + ), + RadioDialogOption( + value = "javascript-full", + title = getString(R.string.controlled_by_javascript_full), + desc = getString(R.string.controlled_by_javascript_full_desc) ), ) @Destination() @Composable -fun ConfigEditorScreen(module: LocalModule) { - val navigator = LocalDestinationsNavigator.current +fun ConfigEditorScreen(moduleId: String) { + ModuleScope(moduleId) { + ConfigEditorContent() + } +} + +@Composable +fun ConfigEditorContent() { + val module = LocalModule.current val userPrefs = LocalUserPreferences.current val context = LocalContext.current - val modId = module.id - - val (webUIConfig, saveWebUIConfig) = rememberConfigFile(modId.WebUIConfig) - val (moduleConfig, saveModuleConfig) = rememberConfigFile(modId.ModuleConfig) - - var exportBottomSheet by remember { mutableStateOf(false) } - if (exportBottomSheet) ExportBottomSheet( - onClose = { exportBottomSheet = false }, - onModuleExport = { - context.shareText(moduleConfig.getOverrideConfigFile(modId)?.readText() ?: "{}") - }, - onConfigExport = { - context.shareText(webUIConfig.getOverrideConfigFile(modId)?.readText() ?: "{}") - } - ) + val navigator = LocalDestinationsNavigator.current + val config = remember { module.webrootConfig } Scaffold( - topBar = { - NavigateUpTopBar( - title = "Config", - subtitle = module.name, + toolbar = { + NavigateUpToolbar( + title = { + ToolbarTitle( + title = "Config", + subtitle = module.name + ) + }, onBack = { navigator.popBackStack() }, - actions = { - IconButton( - onClick = { - exportBottomSheet = true - } - ) { - Icon( - painter = painterResource(id = R.drawable.file_export), - contentDescription = null - ) - } - } ) }, - contentWindowInsets = WindowInsets.none - ) { innerPadding -> - Column( + contentWindowInsets = WindowInsets.systemBars, + ) { + List( modifier = Modifier - .padding(innerPadding) + .scaffoldHazeSource() + .fillMaxWidth() .verticalScroll(rememberScrollState()) + .scaffoldPadding() ) { - ListHeader(title = stringResource(R.string.webui_config)) - - ListEditTextItem( - title = stringResource(R.string.webui_config_title_title), - desc = webUIConfig.title ?: stringResource(R.string.webui_config_title_desc), - itemTextStyle = ListItemDefaults.itemStyle.apply { - if (webUIConfig.title == null) { - copy( - descTextStyle = MaterialTheme.typography.bodyMedium.copy( - fontStyle = FontStyle.Italic - ) - ) - } - }, - value = webUIConfig.title ?: "", + InputDialogItem( + value = config.title ?: "", onConfirm = { - saveWebUIConfig { _ -> - "title" change it - } + config.set("title", it) } - ) - - ListEditTextItem( - title = stringResource(R.string.webui_config_icon_title), - desc = webUIConfig.icon ?: stringResource(R.string.webui_config_icon_desc), - itemTextStyle = ListItemDefaults.itemStyle.apply { - if (webUIConfig.icon == null) { - copy( - descTextStyle = MaterialTheme.typography.bodyMedium.copy( - fontStyle = FontStyle.Italic - ) + ) { + Title(R.string.webui_config_title_title) + Description(config.title ?: stringResource(R.string.webui_config_title_desc)) { + if (config.title == null) { + it.copy( + fontStyle = FontStyle.Italic ) - } - }, - value = webUIConfig.icon ?: "", - onConfirm = { - saveWebUIConfig { _ -> - "icon" change it - } + } else it } - ) + } - ListButtonItem( - title = stringResource(R.string.plugins), - desc = stringResource(R.string.plugins_desc), - onClick = { - navigator.navigate(PluginsScreenDestination(module)) + InputDialogItem( + value = config.icon ?: "", + onConfirm = { + config.set("icon", it) + } + ) { + Title(R.string.webui_config_icon_title) + Description(config.icon ?: stringResource(R.string.webui_config_icon_desc)) { + if (config.icon == null) { + it.copy( + fontStyle = FontStyle.Italic + ) + } else it } - ) - - if (webUIConfig.additionalConfig.isNotNullOrEmpty()) { - ListButtonItem( - title = stringResource(R.string.webui_additional_config), - desc = stringResource(R.string.webui_additional_config_desc), - onClick = { - navigator.navigate(AdditionalConfigEditorScreenDestination(module)) - } - ) } - val hasNoJsBackInterceptor = webUIConfig.backInterceptor != "javascript" + NavButton( + route = ShortcutCreateScreenDestination(module.id), + title = R.string.create_shortcut, + desc = R.string.create_shortcut_desc + ) + + val hasNoJsBackInterceptor = + !listOf("javascript", "javascript-full").contains(config.backInterceptor) - ListSwitchItem( + SwitchItem( enabled = hasNoJsBackInterceptor && !userPrefs.disableGlobalExitConfirm, - title = stringResource(R.string.webui_config_exit_confirm_title), - desc = stringResource(R.string.webui_config_exit_confirm_desc), - checked = hasNoJsBackInterceptor && webUIConfig.exitConfirm, + checked = hasNoJsBackInterceptor && config.exitConfirm, onChange = { isChecked -> - saveWebUIConfig { - "exitConfirm" change isChecked - } - }, - base = { + config.set("exitConfirm", isChecked) + } + ) { + Title(R.string.webui_config_exit_confirm_title) + Description(R.string.webui_config_exit_confirm_desc) + + Supporting { if (userPrefs.disableGlobalExitConfirm) { - labels = listOf { LabelItem(stringResource(R.string.globally_disabled)) } + Badge( + text = stringResource(R.string.globally_disabled), + variant = BadgeVariant.Warning + ) } } - ) + } + - val backHandler = webUIConfig.backHandler ?: true + val backHandler = config.backHandler ?: true - ListSwitchItem( - title = stringResource(R.string.webui_config_back_handler_title), - desc = stringResource(R.string.webui_config_back_handler_desc), + SwitchItem( checked = backHandler, onChange = { isChecked -> - saveWebUIConfig { - "backHandler" change isChecked - } - }, - ) + config.set("backHandler", isChecked) + } + ) { + Title(R.string.webui_config_back_handler_title) + Description(R.string.webui_config_back_handler_desc) + } - ListRadioCheckItem( - enabled = backHandler, - title = stringResource(R.string.webui_config_back_interceptor_title), - desc = stringResource(R.string.webui_config_back_interceptor_desc), - value = webUIConfig.backInterceptor as String?, + RadioDialogItem( + selection = config.backInterceptor, options = context.interceptorList, onConfirm = { if (it.value == null) { Toast.makeText(context, "Please select an option", Toast.LENGTH_SHORT) .show() - return@ListRadioCheckItem + return@RadioDialogItem } - saveWebUIConfig { _ -> - "backInterceptor" change it.value - } + config.set("backInterceptor", it.value) } - ) + ) { + Title(R.string.webui_config_back_interceptor_title) + Description(R.string.webui_config_back_interceptor_desc) + } - val pullToRefresh = webUIConfig.pullToRefresh + val pullToRefresh = config.pullToRefresh - ListSwitchItem( - title = stringResource(R.string.webui_config_pull_to_refresh_title), - desc = stringResource(R.string.webui_config_pull_to_refresh_desc), + SwitchItem( checked = pullToRefresh, onChange = { isChecked -> - saveWebUIConfig { - "pullToRefresh" change isChecked - } + config.set("pullToRefresh", isChecked) } - ) + ) { + Title(R.string.webui_config_pull_to_refresh_title) + Description(R.string.webui_config_pull_to_refresh_desc) + } - ListSwitchItem( - enabled = pullToRefresh && webUIConfig.useNativeRefreshInterceptor, - title = stringResource(R.string.webui_config_pull_to_refresh_helper_title), - desc = stringResource(R.string.webui_config_pull_to_refresh_helper_desc), - checked = webUIConfig.pullToRefreshHelper && webUIConfig.useNativeRefreshInterceptor, - onChange = { isChecked -> - saveWebUIConfig { - "pullToRefreshHelper" change isChecked - } - } - ) - ListRadioCheckItem( - enabled = pullToRefresh, - title = stringResource(R.string.webui_config_refresh_interceptor_title), - desc = stringResource(R.string.webui_config_refresh_interceptor_desc), - value = webUIConfig.refreshInterceptor, - options = context.interceptorList, - onConfirm = { item -> - if (item.value == null) { +// val useNativeRefreshInterceptor = config.refreshInterceptor == "native" +// +// ListSwitchItem( +// enabled = pullToRefresh && useNativeRefreshInterceptor, +// title = stringResource(R.string.webui_config_pull_to_refresh_helper_title), +// desc = stringResource(R.string.webui_config_pull_to_refresh_helper_desc), +// checked = useNativeRefreshInterceptor, +// onChange = { isChecked -> +// saveconfig { +// "pullToRefreshHelper" change isChecked +// } +// } +// ) + + RadioDialogItem( + selection = config.refreshInterceptor, + options = context.interceptorList.filterIndexed { i, _ -> i != 2 }, + onConfirm = { + if (it.value == null) { Toast.makeText(context, "Please select an option", Toast.LENGTH_SHORT) .show() - return@ListRadioCheckItem + return@RadioDialogItem } - saveWebUIConfig { _ -> - "refreshInterceptor" change item.value - } + config.set("refreshInterceptor", it.value) } - ) + ) { + Title(R.string.webui_config_refresh_interceptor_title) + Description(R.string.webui_config_refresh_interceptor_desc) + } - ListSwitchItem( - title = stringResource(R.string.webui_config_window_resize_title), - desc = stringResource(R.string.webui_config_window_resize_desc), - checked = webUIConfig.windowResize, + SwitchItem( + checked = config.windowResize, onChange = { isChecked -> - saveWebUIConfig { - "windowResize" change isChecked - } + config.set("windowResize", isChecked) } - ) + ) { + Title(R.string.webui_config_window_resize_title) + Description(R.string.webui_config_window_resize_desc) + } - ListSwitchItem( - title = stringResource(R.string.webui_config_auto_style_statusbars_title), - desc = stringResource(R.string.webui_config_auto_style_statusbars_desc), - checked = webUIConfig.autoStatusBarsStyle, + SwitchItem( + checked = config.autoStatusBarsStyle, onChange = { isChecked -> - saveWebUIConfig { - "autoStatusBarsStyle" change isChecked - } + config.set("autoStatusBarsStyle", isChecked) } - ) + ) { + Title(R.string.webui_config_auto_style_statusbars_title) + Description(R.string.webui_config_auto_style_statusbars_desc) + } - ListSwitchItem( - title = stringResource(R.string.webui_config_kill_shell_when_background), - desc = stringResource(R.string.webui_config_kill_shell_when_background_desc), - checked = webUIConfig.killShellWhenBackground, + SwitchItem( + checked = config.killShellWhenBackground, onChange = { isChecked -> - saveWebUIConfig { - "killShellWhenBackground" change isChecked - } + config.set("killShellWhenBackground", isChecked) } - ) + ) { + Title(R.string.webui_config_kill_shell_when_background) + Description(R.string.webui_config_kill_shell_when_background_desc) + } - ListEditTextSwitchItem( - title = stringResource(R.string.webui_config_history_fallback_title), - desc = stringResource(R.string.webui_config_history_fallback_desc), - value = webUIConfig.historyFallbackFile, - checked = webUIConfig.historyFallback, - onChange = { isChecked -> - saveWebUIConfig { - "historyFallback" change isChecked - } - }, + InputDialogItem( + value = config.historyFallbackFile, onConfirm = { - saveWebUIConfig { _ -> - "historyFallbackFile" change it - } - } - ) + config.set("historyFallbackFile", it.value) + }, + ) { + Title(R.string.webui_config_history_fallback_title) + Description(R.string.webui_config_history_fallback_desc) + + VerticalDividerSwitch( + checked = config.historyFallback, + onChange = { isChecked -> + config.set("historyFallback", isChecked) + }, + ) + } - ListEditTextItem( - title = stringResource(R.string.webui_config_content_security_policy_title), - desc = stringResource(R.string.webui_config_content_security_policy_desc), - value = webUIConfig.contentSecurityPolicy, + InputDialogItem( + value = config.contentSecurityPolicy, onConfirm = { - saveWebUIConfig { _ -> - "contentSecurityPolicy" change it - } - } - ) + config.set("contentSecurityPolicy", it.value) + }, + ) { + Title(R.string.webui_config_content_security_policy_title) + Description(R.string.webui_config_content_security_policy_desc) + } - ListSwitchItem( - title = stringResource(R.string.webui_config_caching_title), - desc = stringResource(R.string.webui_config_caching_desc), - checked = webUIConfig.caching, + SwitchItem( + checked = config.caching, onChange = { isChecked -> - saveWebUIConfig { - "caching" change isChecked - } + config.set("caching", isChecked) } - ) + ) { + Title(R.string.webui_config_caching_title) + Description(R.string.webui_config_caching_desc) + } - ListEditTextItem( - enabled = webUIConfig.caching, - title = stringResource(R.string.webui_config_caching_max_age_title), - desc = stringResource(R.string.webui_config_caching_max_age_desc), - value = webUIConfig.cachingMaxAge.toString(), + InputDialogItem( + value = config.cachingMaxAge.toString(), onValid = { !Regex("^[0-9]+$").matches(it) }, onConfirm = { - saveWebUIConfig { _ -> - "cachingMaxAge" change it.toInt() - } - } - ) - - - ListHeader(title = stringResource(R.string.module_config)) - - val engine by remember(moduleConfig) { - derivedStateOf { - moduleConfig.getWebuiEngine(context) + config.set("cachingMaxAge", it.value.toInt()) } + ) { + Title(R.string.webui_config_caching_max_age_title) + Description(R.string.webui_config_caching_max_age_desc) } - - ListRadioCheckItem( - title = stringResource(R.string.settings_webui_engine), - value = engine, - options = listOf( - RadioOptionItem( - value = "wx", - title = stringResource(R.string.settings_webui_engine_wx) - ), - RadioOptionItem( - value = "ksu", - title = stringResource(R.string.settings_webui_engine_ksu) - ), - RadioOptionItem( - value = null, - title = stringResource(R.string.settings_webui_engine_undefined) - ) - ), - onConfirm = { - saveModuleConfig { _ -> - "webui-engine" change it.value - } - } - ) } } -} - -@Composable -private fun ExportBottomSheet( - onClose: () -> Unit, - onModuleExport: () -> Unit, - onConfigExport: () -> Unit, -) = BottomSheet( - onDismissRequest = onClose -) { - Text( - modifier = Modifier.padding(vertical = 16.dp, horizontal = 25.dp), - text = stringResource(R.string.export_config), - style = MaterialTheme.typography.headlineSmall.copy(color = MaterialTheme.colorScheme.primary) - ) - - ListButtonItem( - title = stringResource(R.string.export_module_config_json), - onClick = onModuleExport - ) - - ListButtonItem( - title = stringResource(R.string.export_webui_config_json), - onClick = onConfigExport - ) - - Spacer(Modifier.height(16.dp)) } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/ShortcutCreateScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/ShortcutCreateScreen.kt new file mode 100644 index 00000000..8040402b --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/ShortcutCreateScreen.kt @@ -0,0 +1,444 @@ +package com.dergoogler.mmrl.wx.ui.screens.modules.screens + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.BitmapFactory +import android.graphics.drawable.Icon +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.dergoogler.mmrl.platform.model.ModId.Companion.INTENT_MOD_ID +import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.datastore.model.WebUIEngine +import com.dergoogler.mmrl.wx.model.module.Module +import com.dergoogler.mmrl.wx.ui.component.LocalModule +import com.dergoogler.mmrl.wx.ui.component.ModuleScope +import com.dergoogler.mmrl.wx.ui.component.NavigateUpToolbar +import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator +import com.dergoogler.mmrl.wx.util.toPainter +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import dev.mmrlx.compose.ui.HorizontalDivider +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.button.Button +import dev.mmrlx.compose.ui.button.ButtonVariant +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.text.Input +import dev.mmrlx.compose.ui.text.rememberInputState +import dev.mmrlx.compose.ui.toolbar.ToolbarTitle +import dev.mmrlx.nio.SuFile +import com.dergoogler.mmrl.wx.ui.activity.webui.WebUIActivity as WxWebUIActivity +import com.dergoogler.mmrl.wx.ui.webui.WebUIActivity as MxWebUIActivity + +@Destination() +@Composable +fun ShortcutCreateScreen(moduleId: String) { + ModuleScope(moduleId) { + ShortcutCreateContent() + } +} + +@Composable +fun ShortcutCreateContent() { + val module = LocalModule.current + val context = LocalContext.current + + val navigator = LocalDestinationsNavigator.current + + val moduleIcon = remember(module) { module.icon } + + var shortcutName = rememberInputState(module.name) + var selectedEngine by remember { mutableStateOf(null) } + var iconUri by remember { mutableStateOf(null) } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { iconUri = it.toString() } + } + + val isCreateEnabled = + selectedEngine != null && shortcutName.text.isNotBlank() && (iconUri != null || moduleIcon != null) + + Scaffold( + toolbar = { + NavigateUpToolbar( + title = { + ToolbarTitle( + title = "Create Shortcut", + subtitle = module.name + ) + }, + onBack = { navigator.popBackStack() }, + ) + }, + contentWindowInsets = WindowInsets.systemBars, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .scaffoldPadding() + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = stringResource(R.string.shortcut_name) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Input( + state = shortcutName, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text(module.name) + } + ) + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + text = stringResource(R.string.shortcut_icon) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Button( + modifier = Modifier.weight(1f), + onClick = { launcher.launch("image/*") }, + variant = ButtonVariant.Outline + ) { + Text(stringResource(R.string.shortcut_pick_icon)) + } + + Button( + modifier = Modifier.weight(1f), + onClick = { iconUri = null }, + variant = ButtonVariant.Outline + ) { + Text(stringResource(R.string.shortcut_reset_icon)) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + ShortcutIconPreview( + iconUri = iconUri, + moduleIcon = moduleIcon + ) + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + text = stringResource(R.string.settings_webui_engine) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + EngineOption( + modifier = Modifier.weight(1f), + title = stringResource(R.string.settings_webui_engine_wx), + selected = selectedEngine == WebUIEngine.WX, + onClick = { selectedEngine = WebUIEngine.WX } + ) + + EngineOption( + modifier = Modifier.weight(1f), + title = stringResource(R.string.settings_webui_engine_mx), + selected = selectedEngine == WebUIEngine.MX, + onClick = { selectedEngine = WebUIEngine.MX } + ) + } + } + + HorizontalDivider() + + val engineRequiredString = stringResource(R.string.shortcut_engine_required) + val invalidShortcutString = stringResource(R.string.shortcut_invalid_fields) + val createRequestedString = stringResource(R.string.shortcut_create_requested) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + text = "Review your shortcut settings and create it.", + ) + + Button( + enabled = isCreateEnabled, + variant = ButtonVariant.Default, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + onClick = { + if (selectedEngine == null) { + Toast.makeText( + context, + engineRequiredString, + Toast.LENGTH_SHORT + ).show() + return@Button + } + + if (shortcutName.text.isBlank()) { + Toast.makeText( + context, + invalidShortcutString, + Toast.LENGTH_SHORT + ).show() + return@Button + } + + val isCreated = createShortcut( + context = context, + module = module, + title = shortcutName.text.toString(), + engine = selectedEngine!!, + iconUri = iconUri, + ) + + if (isCreated) { + Toast.makeText( + context, + createRequestedString, + Toast.LENGTH_SHORT + ).show() + navigator.popBackStack() + } + } + ) { + Text(stringResource(R.string.add_shortcut)) + } + } + } + } +} + +@Composable +private fun EngineOption( + modifier: Modifier = Modifier, + title: String, + selected: Boolean, + onClick: () -> Unit, +) { + Button( + modifier = modifier.defaultMinSize(minHeight = 56.dp), + onClick = onClick, + variant = if (selected) ButtonVariant.Default else ButtonVariant.Outline, + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.engine) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(title) + } + } + } +} + +@Composable +private fun ShortcutIconPreview( + iconUri: String?, + moduleIcon: SuFile?, +) { + val context = LocalContext.current + + val uriBitmap = remember(iconUri) { + iconUri?.let { + runCatching { + context.contentResolver.openInputStream(Uri.parse(it)).use { stream -> + BitmapFactory.decodeStream(stream) + } + }.getOrNull() + } + } + + val uriPainter = uriBitmap?.let { BitmapPainter(it.asImageBitmap()) } + + when { + uriPainter != null -> { + Image( + painter = uriPainter, + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(6.dp)) + Text(text = stringResource(R.string.shortcut_preview_custom_icon)) + } + + moduleIcon != null -> { + Image( + painter = moduleIcon.toPainter(), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(6.dp)) + Text(text = stringResource(R.string.shortcut_preview_module_icon)) + } + + else -> { + Text( + text = stringResource(R.string.shortcut_icon_file_not_found), + color = colorResource(android.R.color.holo_red_light) + ) + } + } +} + +private fun createShortcut( + context: Context, + module: Module, + title: String, + engine: WebUIEngine, + iconUri: String?, +): Boolean { + val shortcutManager = context.getSystemService(ShortcutManager::class.java) + val shortcutId = "shortcut_${module.id}_${engine.name.lowercase()}" + + if (!shortcutManager.isRequestPinShortcutSupported) { + Toast.makeText( + context, + context.getString(R.string.shortcut_not_supported), + Toast.LENGTH_SHORT + ) + .show() + return false + } + + if (shortcutManager.pinnedShortcuts.any { it.id == shortcutId }) { + Toast.makeText( + context, + context.getString(R.string.shortcut_already_exists), + Toast.LENGTH_SHORT + ) + .show() + return false + } + + val bitmap = loadShortcutBitmap( + context = context, + module = module, + iconUri = iconUri + ) + + if (bitmap == null) { + Toast.makeText( + context, + context.getString(R.string.shortcut_icon_invalid), + Toast.LENGTH_SHORT + ) + .show() + return false + } + + val shortcutIntent = when (engine) { + WebUIEngine.WX -> { + Intent(context, WxWebUIActivity::class.java).apply { + putExtra(INTENT_MOD_ID, module.id) + action = Intent.ACTION_VIEW + } + } + + WebUIEngine.MX -> { + Intent(context, MxWebUIActivity::class.java).apply { + putExtra("MODULE_ID", module.id) + action = Intent.ACTION_VIEW + addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + } + } + + else -> { + Toast.makeText( + context, + context.getString(R.string.unsupported_engine), + Toast.LENGTH_SHORT + ) + .show() + return false + } + } + + val shortcut = ShortcutInfo.Builder(context, shortcutId) + .setShortLabel(title) + .setLongLabel(title) + .setIcon(Icon.createWithAdaptiveBitmap(bitmap)) + .setIntent(shortcutIntent) + .build() + + shortcutManager.requestPinShortcut(shortcut, null) + return true +} + +private fun loadShortcutBitmap( + context: Context, + module: Module, + iconUri: String?, +) = runCatching { + if (iconUri != null) { + context.contentResolver.openInputStream(Uri.parse(iconUri)).use { input -> + input?.let { BitmapFactory.decodeStream(it) } + } + } else { + module.icon?.newInputStream()?.buffered()?.use { BitmapFactory.decodeStream(it) } + } +}.getOrNull() diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/editor/FileEditorScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/editor/FileEditorScreen.kt deleted file mode 100644 index 5f583370..00000000 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/modules/screens/editor/FileEditorScreen.kt +++ /dev/null @@ -1,380 +0,0 @@ -@file:Suppress("CanBeParameter", "ClassName") - -package com.dergoogler.mmrl.wx.ui.screens.modules.screens.editor - -import android.content.Context -import android.graphics.Typeface -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFontFamilyResolver -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontSynthesis -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import com.dergoogler.mmrl.ext.none -import com.dergoogler.mmrl.platform.content.LocalModule -import com.dergoogler.mmrl.platform.file.SuFile -import com.dergoogler.mmrl.platform.file.SuFile.Companion.toSuFile -import com.dergoogler.mmrl.ui.component.NavigateUpTopBar -import com.dergoogler.mmrl.ui.component.dialog.ConfirmDialog -import com.dergoogler.mmrl.ui.component.scaffold.Scaffold -import com.dergoogler.mmrl.ui.component.toolbar.ToolbarTitle -import com.dergoogler.mmrl.wx.R -import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences -import com.dergoogler.mmrl.wx.ui.providable.LocalDestinationsNavigator -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import io.github.rosemoe.sora.text.Content -import io.github.rosemoe.sora.text.ContentListener -import io.github.rosemoe.sora.widget.CodeEditor -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.ANNOTATION -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.BLOCK_LINE -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.BLOCK_LINE_CURRENT -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.COMMENT -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.CURRENT_LINE -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.FUNCTION_NAME -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.IDENTIFIER_NAME -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.IDENTIFIER_VAR -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.KEYWORD -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.LINE_DIVIDER -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.LINE_NUMBER -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.LINE_NUMBER_BACKGROUND -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.LINE_NUMBER_CURRENT -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.LITERAL -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.MATCHED_TEXT_BACKGROUND -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.NON_PRINTABLE_CHAR -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.OPERATOR -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.SCROLL_BAR_THUMB -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.SCROLL_BAR_THUMB_PRESSED -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.SELECTED_TEXT_BACKGROUND -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.SELECTION_HANDLE -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.SELECTION_INSERT -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.TEXT_NORMAL -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.TEXT_SELECTED -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme.WHOLE_BACKGROUND -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -data class CodeEditorState( - private val scope: CoroutineScope, - private val context: Context, - private val colorScheme: ColorScheme, - private val darkMode: Boolean, - private val initialFile: SuFile?, - private val threadSafe: Boolean = true, - private val textStyle: TextStyle, - private val typeface: Typeface, -) { - val file: SuFile? by lazy { - if (initialFile == null) return@lazy null - - val f = SuFile(initialFile.path) - - if (!f.exists()) { - isSaveAllowed = false - return@lazy null - } - - if (f.isDirectory) { - isSaveAllowed = false - return@lazy null - } - - return@lazy f - } - - var isSaveAllowed by mutableStateOf(true) - var content by mutableStateOf(Content(file?.readText(), threadSafe)) - var isModified by mutableStateOf(false) - val editor = CodeEditor(context) - - fun saveFile() { - if (!isModified) return - - if (!isSaveAllowed) { - Toast.makeText(context, "Cannot save", Toast.LENGTH_SHORT).show() - return - } - - val myFile = - file ?: run { - Toast.makeText(context, "Cannot save", Toast.LENGTH_SHORT).show() - return - } - - scope.launch { - myFile.writeText(editor.text.toString()) - isModified = false - Toast.makeText(context, "Saved", Toast.LENGTH_SHORT).show() - } - } - - private fun init() { - val scheme = FUCK_THIS_SHIT_EDITOR_COLOR_SCHEME(darkMode).apply { - setColor(ANNOTATION, colorScheme.background.lighten(0.1f)) - setColor(FUNCTION_NAME, colorScheme.primary.darken(0.2f)) - setColor(IDENTIFIER_NAME, colorScheme.primary.darken(0.1f)) - setColor(IDENTIFIER_VAR, colorScheme.secondary.darken(0.15f)) - setColor(LITERAL, colorScheme.tertiary.lighten(0.2f)) - setColor(OPERATOR, colorScheme.primary.darken(0.3f)) - setColor(COMMENT, colorScheme.outline.darken(0.1f)) - setColor(KEYWORD, colorScheme.secondary.lighten(0.2f)) - setColor(WHOLE_BACKGROUND, colorScheme.background) - setColor(TEXT_NORMAL, colorScheme.onBackground) - setColor(LINE_NUMBER_BACKGROUND, colorScheme.surface.darken(0.05f)) - setColor(LINE_NUMBER, colorScheme.outlineVariant.lighten(0.0465f)) - setColor(LINE_DIVIDER, colorScheme.outlineVariant) - setColor(SCROLL_BAR_THUMB, colorScheme.primary.copy(alpha = 0.4535f)) - setColor( - SCROLL_BAR_THUMB_PRESSED, - colorScheme.primary.darken(0.1f).copy(alpha = 0.4535f) - ) - setColor(SELECTED_TEXT_BACKGROUND, colorScheme.primaryContainer.lighten(0.15f)) - setColor(MATCHED_TEXT_BACKGROUND, colorScheme.secondaryContainer.lighten(0.2f)) - setColor(LINE_NUMBER_CURRENT, colorScheme.primary.darken(0.1f)) - setColor(CURRENT_LINE, colorScheme.surfaceVariant.darken(0.05f)) - setColor(SELECTION_INSERT, colorScheme.primary.lighten(0.1f)) - setColor(SELECTION_HANDLE, colorScheme.primary.darken(0.1f)) - setColor(BLOCK_LINE, colorScheme.outlineVariant.darken(0.05f)) - setColor(BLOCK_LINE_CURRENT, colorScheme.onSurfaceVariant.darken(0.2f)) - setColor(NON_PRINTABLE_CHAR, colorScheme.inverseOnSurface.darken(0.3f)) - setColor(TEXT_SELECTED, colorScheme.onPrimary.darken(0.1f)) - } - LocalFontFamilyResolver - - editor.apply { - setText(content) - setTextSize(textStyle.fontSize.value) - setColorScheme(scheme) - setTypefaceText(typeface) - setHighlightCurrentLine(true) - setEditable(true) - } - - content.addContentListener(contentListener) - } - - init { - init() - } - - private val contentListener - get() = object : ContentListener { - override fun beforeReplace(content: Content) { - isModified = true - } - - override fun afterInsert( - content: Content, - startLine: Int, - startColumn: Int, - endLine: Int, - endColumn: Int, - insertedContent: CharSequence, - ) { - isModified = true - } - - override fun afterDelete( - content: Content, - startLine: Int, - startColumn: Int, - endLine: Int, - endColumn: Int, - deletedContent: CharSequence, - ) { - isModified = true - } - } - - - private inner class FUCK_THIS_SHIT_EDITOR_COLOR_SCHEME( - private val darkMode: Boolean, - ) : EditorColorScheme(darkMode) { - fun Color.darken(fraction: Float) = lerp(this, Color.Black, fraction) - fun Color.lighten(fraction: Float) = lerp(this, Color.White, fraction) - fun setColor(type: Int, color: Color) = super.setColor(type, color.toArgb()) - } -} - -@Composable -fun rememberCodeEditorState( - file: SuFile? = null, - threadSafe: Boolean = true, - colorScheme: ColorScheme = MaterialTheme.colorScheme, - textStyle: TextStyle = LocalTextStyle.current.copy( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp - ), -): CodeEditorState { - val context = LocalContext.current - val prefs = LocalUserPreferences.current - - val typeface by rememberTypefaceFrom(textStyle) - val scope = rememberCoroutineScope() - - return remember(prefs) { - CodeEditorState( - scope = scope, - context = context, - colorScheme = colorScheme, - initialFile = file, - threadSafe = threadSafe, - textStyle = textStyle, - darkMode = prefs.isDarkMode(), - typeface = typeface, - ) - } -} - -@Composable -fun CodeEditor( - modifier: Modifier = Modifier, - state: CodeEditorState, -) { - AndroidView( - factory = { state.editor }, - modifier = modifier, - onRelease = { it.release() } - ) -} - -@Destination -@Composable -fun FileEditorScreen(module: LocalModule, path: String) { - val navigator = LocalDestinationsNavigator.current - val file = remember(path) { path.toSuFile() } - val state = rememberCodeEditorState( - file = file, - ) - - val confirmExit = rememberVisibleState { - ConfirmDialog( - title = "Unsaved", - description = "There'll unsaved changes in your file. Do you want exit?", - onConfirm = { - it.hide() - navigator.popBackStack() - }, - onClose = { - it.hide() - } - ) - } - - val backClick: () -> Unit = remember { - { - if (state.isModified) { - confirmExit.show() - } else { - navigator.popBackStack() - } - } - } - - BackHandler(onBack = backClick) - - Scaffold( - topBar = { - NavigateUpTopBar( - title = { - ToolbarTitle( - title = file.name, - subtitle = module.name - ) - }, - onBack = backClick, - actions = { - IconButton( - enabled = state.isModified, - onClick = { - state.saveFile() - } - ) { - Icon( - painter = painterResource(R.drawable.device_floppy), - contentDescription = "Save" - ) - } - } - ) - }, - contentWindowInsets = WindowInsets.none - ) { innerPadding -> - CodeEditor( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - state = state - ) - } -} - - -interface VisibleState { - val isVisible: Boolean - fun show() - fun hide() -} - -@Composable -fun rememberVisibleState( - initialState: Boolean = false, - content: @Composable (VisibleState) -> Unit, -): VisibleState { - var visible by remember { mutableStateOf(initialState) } - - val obj = remember(visible) { - object : VisibleState { - override val isVisible = visible - override fun show() { - visible = true - } - - override fun hide() { - visible = false - } - } - } - - if (visible) content(obj) - - return obj -} - -@Composable -fun rememberTypefaceFrom(textStyle: TextStyle): State { - val resolver = LocalFontFamilyResolver.current - val family = textStyle.fontFamily - val weight = textStyle.fontWeight ?: FontWeight.Normal - val style = textStyle.fontStyle ?: FontStyle.Normal - val synth = textStyle.fontSynthesis ?: FontSynthesis.All - return remember(family, weight, style, synth) { - resolver.resolve(family, weight, style, synth) - } as State -} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/BehaviorScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/BehaviorScreen.kt index 4124ea70..f56baacd 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/BehaviorScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/BehaviorScreen.kt @@ -1,28 +1,27 @@ package com.dergoogler.mmrl.wx.ui.screens.settings -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource -import com.dergoogler.mmrl.ext.none -import com.dergoogler.mmrl.ui.component.NavigateUpTopBar -import com.dergoogler.mmrl.ui.component.listItem.dsl.List -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.SwitchItem -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Description -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Title -import com.dergoogler.mmrl.ui.component.scaffold.Scaffold import com.dergoogler.mmrl.ui.providable.LocalNavController import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.ui.component.NavigateUpToolbar import com.dergoogler.mmrl.wx.viewmodel.LocalSettings import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import dev.mmrlx.compose.ui.list.List +import dev.mmrlx.compose.ui.list.component.InputDialogItem +import dev.mmrlx.compose.ui.list.component.SwitchItem +import dev.mmrlx.compose.ui.list.component.item.Description +import dev.mmrlx.compose.ui.list.component.item.DialogDescription +import dev.mmrlx.compose.ui.list.component.item.Title +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.toolbar.ToolbarDefaults @Composable @Destination() @@ -30,22 +29,22 @@ fun BehaviorScreen() { val userPreferences = LocalUserPreferences.current val navController = LocalNavController.current val viewModel = LocalSettings.current - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scrollBehavior = ToolbarDefaults.pinnedScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - NavigateUpTopBar( + toolbar = { + NavigateUpToolbar( title = stringResource(R.string.behavior), scrollBehavior = scrollBehavior, navController = navController, ) }, - contentWindowInsets = WindowInsets.none - ) { innerPadding -> + ) { List( modifier = Modifier - .padding(innerPadding) + .scaffoldHazeSource() + .scaffoldPadding() .fillMaxWidth() .verticalScroll(rememberScrollState()) ) { @@ -64,6 +63,17 @@ fun BehaviorScreen() { Title(R.string.settings_force_kill_webui_process) Description(R.string.settings_force_kill_webui_process_desc) } + + InputDialogItem( + value = userPreferences.adbPath, + onConfirm = { + viewModel.setAdbPath(it.value) + }, + ) { + Title(R.string.settings_custom_adb_path) + Description(R.string.settings_custom_adb_path_desc) + DialogDescription(R.string.settings_custom_adb_path_dialog_desc) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/DeveloperScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/DeveloperScreen.kt index f46cccf0..78f973fe 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/DeveloperScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/DeveloperScreen.kt @@ -1,55 +1,48 @@ package com.dergoogler.mmrl.wx.ui.screens.settings -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp import com.dergoogler.mmrl.ext.isLocalWifiUrl import com.dergoogler.mmrl.ext.none -import com.dergoogler.mmrl.ext.takeTrue -import com.dergoogler.mmrl.ui.component.LabelItem -import com.dergoogler.mmrl.ui.component.NavigateUpTopBar -import com.dergoogler.mmrl.ui.component.listItem.dsl.List -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.Item -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.Section -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.SwitchItem -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.TextEditDialogItem -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Description -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.DialogSupportingText -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.End -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Labels -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.LearnMore -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Title -import com.dergoogler.mmrl.ui.component.scaffold.Scaffold import com.dergoogler.mmrl.ui.providable.LocalNavController import com.dergoogler.mmrl.wx.BuildConfig import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.ui.component.BottomNavigation import com.dergoogler.mmrl.wx.ui.component.DeveloperSwitch +import com.dergoogler.mmrl.wx.ui.component.NavigateUpToolbar import com.dergoogler.mmrl.wx.viewmodel.LocalSettings import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import dev.mmrlx.compose.ui.Badge +import dev.mmrlx.compose.ui.BadgeVariant +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.button.Button +import dev.mmrlx.compose.ui.dialog.Content +import dev.mmrlx.compose.ui.dialog.Footer +import dev.mmrlx.compose.ui.dialog.Title +import dev.mmrlx.compose.ui.dialog.rememberDialog +import dev.mmrlx.compose.ui.list.List +import dev.mmrlx.compose.ui.list.component.InputDialogItem +import dev.mmrlx.compose.ui.list.component.Item +import dev.mmrlx.compose.ui.list.component.Section +import dev.mmrlx.compose.ui.list.component.SwitchItem +import dev.mmrlx.compose.ui.list.component.item.Description +import dev.mmrlx.compose.ui.list.component.item.DialogSupportingText +import dev.mmrlx.compose.ui.list.component.item.Supporting +import dev.mmrlx.compose.ui.list.component.item.Title +import dev.mmrlx.compose.ui.list.component.item.VerticalDividerSwitch +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.text.FormatText +import dev.mmrlx.compose.ui.theme.MMRLXTheme +import dev.mmrlx.compose.ui.toolbar.ToolbarDefaults @Destination() @Composable @@ -57,24 +50,30 @@ fun DeveloperScreen() { val userPreferences = LocalUserPreferences.current val navController = LocalNavController.current val viewModel = LocalSettings.current - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scrollBehavior = ToolbarDefaults.pinnedScrollBehavior() + + val remoteDomainDialog = rememberDialog() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - NavigateUpTopBar( + toolbar = { + NavigateUpToolbar( title = stringResource(R.string.developer), scrollBehavior = scrollBehavior, navController = navController, ) }, + bottomBar = { + BottomNavigation() + }, contentWindowInsets = WindowInsets.none - ) { innerPadding -> + ) { List( modifier = Modifier - .padding(innerPadding) + .scaffoldHazeSource() .fillMaxWidth() .verticalScroll(rememberScrollState()) + .scaffoldPadding() ) { Section { SwitchItem( @@ -85,133 +84,96 @@ fun DeveloperScreen() { Description(R.string.settings_developer_mode_desc) } - var webuiRemoteUrlInfo by remember { mutableStateOf(false) } - if (webuiRemoteUrlInfo) AlertDialog( - title = { - Text(text = stringResource(id = R.string.settings_webui_remote_url)) - }, - text = { - Text(text = stringResource(id = R.string.settings_webui_remote_url_alert_desc)) - }, - onDismissRequest = { - webuiRemoteUrlInfo = false - }, - confirmButton = { - TextButton( - onClick = { - webuiRemoteUrlInfo = false - } - ) { - Text(text = stringResource(id = android.R.string.ok)) - } - }, - ) - DeveloperSwitch( enabled = !userPreferences.useWebUiDevUrl, checked = userPreferences.enableErudaConsole && !userPreferences.useWebUiDevUrl, onChange = viewModel::setEnableEruda ) { + // TODO: deprecate eruda Title(R.string.settings_security_inject_eruda) Description(R.string.settings_security_inject_eruda_desc) } + DeveloperSwitch( + enabled = userPreferences.enableErudaConsole, + checked = userPreferences.enableErudaConsole && userPreferences.enableAutoOpenEruda, + onChange = viewModel::setEnableAutoOpenEruda + ) { + // TODO: deprecate eruda + Title(R.string.settings_security_auto_open_eruda) + Description(R.string.settings_security_auto_open_eruda_desc) + } + DeveloperSwitch( checked = userPreferences.enableDevTools, onChange = viewModel::setEnableDevTools ) { - Title(R.string.settings_security_enable_devtools) - Description(R.string.settings_security_enable_devtools_desc) - Labels { - LabelItem(stringResource(R.string.beta)) + Title { + FormatText(stringResource(R.string.settings_security_enable_devtools) + " %y") { + composable { + Badge( + text = stringResource(R.string.beta), + variant = BadgeVariant.Secondary, + ) + } + } } + Description(R.string.settings_security_enable_devtools_desc) } DeveloperSwitch( - enabled = userPreferences.enableErudaConsole, - checked = userPreferences.enableErudaConsole && userPreferences.enableAutoOpenEruda, - onChange = viewModel::setEnableAutoOpenEruda + checked = userPreferences.disableConsoleInterceptor, + onChange = viewModel::setDisableConsoleInterceptor ) { - Title(R.string.settings_security_auto_open_eruda) - Description(R.string.settings_security_auto_open_eruda_desc) + Title { + FormatText(stringResource(R.string.settings_disable_console_interceptor) + " %y") { + composable { + Badge( + text = stringResource(R.string.beta), + variant = BadgeVariant.Secondary, + ) + } + } + } + Description(R.string.settings_disable_console_interceptor_desc) } - TextEditDialogItem( + InputDialogItem( enabled = userPreferences.developerMode, value = userPreferences.webUiDevUrl, onConfirm = { - viewModel.setWebUiDevUrl(it) + viewModel.setWebUiDevUrl(it.value) }, onValid = { it.isLocalWifiUrl() }, ) { Title(R.string.settings_webui_remote_url) Description(R.string.settings_webui_remote_url_desc) - End { - val interactionSource = remember { MutableInteractionSource() } - - Layout( - content = { - VerticalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - thickness = 1.dp - ) - - Switch( - modifier = Modifier - .toggleable( - value = userPreferences.useWebUiDevUrl, - onValueChange = viewModel::setUseWebUiDevUrl, - enabled = userPreferences.developerMode, - role = Role.Switch, - interactionSource = interactionSource, - indication = null - ), - checked = userPreferences.useWebUiDevUrl, - onCheckedChange = null, - interactionSource = interactionSource - ) - } - ) { measurables, constraints -> - val dividerMeasurable = measurables[0] - val switchMeasurable = measurables[1] - - // Measure switch first - val switchPlaceable = switchMeasurable.measure(constraints) - - // Define divider height = switch height + padding - val dividerHeight = switchPlaceable.height + 36 - val dividerPlaceable = dividerMeasurable.measure( - constraints.copy( - minHeight = dividerHeight, - maxHeight = dividerHeight - ) - ) - - val width = dividerPlaceable.width + switchPlaceable.width - val height = maxOf(dividerPlaceable.height, switchPlaceable.height) - - layout(width, height) { - // Center divider vertically relative to the full layout - val dividerY = (height - dividerPlaceable.height) / 2 - val switchY = (height - switchPlaceable.height) / 2 + VerticalDividerSwitch( + checked = userPreferences.useWebUiDevUrl, + onChange = viewModel::setUseWebUiDevUrl, + enabled = userPreferences.developerMode + ) + + Supporting { + Text( + modifier = + Modifier.clickable( + onClick = { + remoteDomainDialog.open() + }, + ), + text = stringResource(R.string.learn_more), + ) - dividerPlaceable.place(0, dividerY) - switchPlaceable.place(dividerPlaceable.width, switchY) - } - } - } - - LearnMore { - webuiRemoteUrlInfo = true } - it.isError.takeTrue { + if (it.isError) { DialogSupportingText { Text( text = stringResource(R.string.invalid_ip), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelSmall + color = MMRLXTheme.colors.destructive, + style = MMRLXTheme.typography.labelSmall ) } } @@ -236,4 +198,24 @@ fun DeveloperScreen() { } } } + + remoteDomainDialog { + Title { + Text(stringResource(R.string.settings_webui_remote_url)) + } + + Content { + Text(stringResource(R.string.settings_webui_remote_url_alert_desc)) + } + + Footer { + Button( + onClick = { + remoteDomainDialog.close() + }, + ) { + Text(stringResource(android.R.string.ok)) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/LicenseScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/LicenseScreen.kt index 2a366d6c..e7f705ee 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/LicenseScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/LicenseScreen.kt @@ -2,58 +2,68 @@ package com.dergoogler.mmrl.wx.ui.screens.settings import android.content.Intent import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.dergoogler.mmrl.ext.LoadData -import com.dergoogler.mmrl.ext.nullable -import com.dergoogler.mmrl.ext.plus -import com.dergoogler.mmrl.ui.component.LabelItem import com.dergoogler.mmrl.ui.component.Loading -import com.dergoogler.mmrl.ui.component.NavigateUpTopBar import com.dergoogler.mmrl.ui.component.PageIndicator -import com.dergoogler.mmrl.ui.component.card.Card -import com.dergoogler.mmrl.ui.component.listItem.dsl.List -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.Item -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Description -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Labels -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Title import com.dergoogler.mmrl.ui.providable.LocalNavController import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.model.license.UiLicense +import com.dergoogler.mmrl.wx.ui.component.BottomNavigation +import com.dergoogler.mmrl.wx.ui.component.NavigateUpToolbar import com.dergoogler.mmrl.wx.viewmodel.LicenseViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import dev.mmrlx.compose.ui.Badge +import dev.mmrlx.compose.ui.BadgeVariant +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.ext.with +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.list.List +import dev.mmrlx.compose.ui.list.ListScope +import dev.mmrlx.compose.ui.list.component.RawItem +import dev.mmrlx.compose.ui.list.component.item.Description +import dev.mmrlx.compose.ui.list.component.item.Supporting +import dev.mmrlx.compose.ui.list.component.item.Title +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.scaffold.ScaffoldScope +import dev.mmrlx.compose.ui.text.FormatText +import dev.mmrlx.compose.ui.toolbar.ToolbarDefaults +import dev.mmrlx.compose.ui.toolbar.ToolbarScrollBehavior @Destination() @Composable fun LicensesScreen( viewModel: LicenseViewModel = hiltViewModel(), ) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scrollBehavior = ToolbarDefaults.pinnedScrollBehavior() val navController = LocalNavController.current Scaffold( - topBar = { + toolbar = { TopBar( navController = navController, scrollBehavior = scrollBehavior ) + }, + bottomBar = { + // TODO: blur is somehow not applied + BottomNavigation() } - ) { contentPadding -> + ) { when (val data = viewModel.data) { LoadData.Pending, LoadData.Loading -> Loading( modifier = Modifier.padding(contentPadding) @@ -61,7 +71,6 @@ fun LicensesScreen( is LoadData.Success> -> LicensesContent( list = data.value, - contentPadding = contentPadding, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) @@ -76,62 +85,75 @@ fun LicensesScreen( } @Composable -private fun LicensesContent( +private fun ScaffoldScope.LicensesContent( list: List, - contentPadding: PaddingValues, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() - - LazyColumn( - modifier = modifier, - state = listState, - contentPadding = PaddingValues(all = 20.dp) + contentPadding, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - items(list) { - LicenseItem(it) + List { + LazyColumn( + state = listState, + modifier = Modifier + .with(this@LicensesContent) { + it.scaffoldHazeSource("licenses") + } + .then(modifier), + contentPadding = this@LicensesContent.contentPadding, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(list) { + this@List.LicenseItem(it) + } } } } @Composable -private fun LicenseItem( +private fun ListScope.LicenseItem( license: UiLicense, ) { val context = LocalContext.current - Card( - onClick = license.hasUrl nullable { - context.startActivity( - Intent.parseUri(license.url, Intent.URI_INTENT_SCHEME) - ) - } - ) { - List( - modifier = Modifier.relative() - ) { - Item { - Title(license.name) - Description(license.dependency) - - Labels { - LabelItem( - text = license.version - ) - - license.spdxLicenses.forEach { - LabelItem( - text = it.name + RawItem( + modifier = Modifier + .let { + if (license.hasUrl) { + it.onClick { + context.startActivity( + Intent.parseUri(license.url, Intent.URI_INTENT_SCHEME) ) } - - license.unknownLicenses.forEach { - LabelItem( - text = it.name.ifEmpty { it.url } + } else Modifier + } + .contentPadding() + ) { + Title { + if (license.hasUrl) { + FormatText(license.name + " %y") { + composable { + Icon( + // Don't ever do that :clown: + modifier = Modifier.size(fontSize.dp), + painter = painterResource(R.drawable.external_link) ) } } + return@Title + } + + Text(license.name) + } + Description(license.dependency) + + Supporting { + Badge(license.version, variant = BadgeVariant.Default) + + license.spdxLicenses.forEach { + Badge(it.name, variant = BadgeVariant.Outline) + } + + license.unknownLicenses.forEach { + Badge(it.name, variant = BadgeVariant.Warning) } } } @@ -140,9 +162,9 @@ private fun LicenseItem( @Composable private fun TopBar( navController: NavController, - scrollBehavior: TopAppBarScrollBehavior, + scrollBehavior: ToolbarScrollBehavior, ) { - NavigateUpTopBar( + NavigateUpToolbar( title = stringResource(R.string.license_title), scrollBehavior = scrollBehavior, navController = navController, diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/SettingsScreen.kt index 93493cd8..6ec0a0d8 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/SettingsScreen.kt @@ -1,34 +1,19 @@ package com.dergoogler.mmrl.wx.ui.screens.settings -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.dergoogler.mmrl.ext.none -import com.dergoogler.mmrl.ext.nullable import com.dergoogler.mmrl.ext.toFormattedDateSafely -import com.dergoogler.mmrl.ui.component.listItem.dsl.List -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.RadioDialogItem -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.Section -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.TextEditDialogItem -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Description -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.DialogDescription -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Icon -import com.dergoogler.mmrl.ui.component.listItem.dsl.component.item.Title -import com.dergoogler.mmrl.ui.component.toolbar.Toolbar -import com.dergoogler.mmrl.ui.component.toolbar.ToolbarTitle import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.datastore.model.WebUIEngine import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences import com.dergoogler.mmrl.wx.model.FeaturedManager +import com.dergoogler.mmrl.wx.ui.component.BottomNavigation import com.dergoogler.mmrl.wx.ui.component.LinkButton import com.dergoogler.mmrl.wx.ui.component.NavButton import com.dergoogler.mmrl.wx.viewmodel.LocalSettings @@ -38,29 +23,44 @@ import com.ramcosta.composedestinations.generated.destinations.AppThemeScreenDes import com.ramcosta.composedestinations.generated.destinations.BehaviorScreenDestination import com.ramcosta.composedestinations.generated.destinations.DeveloperScreenDestination import com.ramcosta.composedestinations.generated.destinations.LicensesScreenDestination +import dev.mmrlx.compose.ui.list.List +import dev.mmrlx.compose.ui.list.component.InputDialogItem +import dev.mmrlx.compose.ui.list.component.RadioDialogItem +import dev.mmrlx.compose.ui.list.component.RadioDialogOption +import dev.mmrlx.compose.ui.list.component.Section +import dev.mmrlx.compose.ui.list.component.item.Description +import dev.mmrlx.compose.ui.list.component.item.DialogDescription +import dev.mmrlx.compose.ui.list.component.item.Icon +import dev.mmrlx.compose.ui.list.component.item.Title +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.toolbar.Toolbar +import dev.mmrlx.compose.ui.toolbar.ToolbarDefaults +import dev.mmrlx.compose.ui.toolbar.ToolbarTitle -@OptIn(ExperimentalMaterial3Api::class) @Destination() @Composable fun SettingsScreen() { val userPreferences = LocalUserPreferences.current val viewModel = LocalSettings.current - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scrollBehavior = ToolbarDefaults.pinnedScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { + toolbar = { Toolbar( title = { ToolbarTitle(title = stringResource(id = R.string.settings)) }, ) }, - contentWindowInsets = WindowInsets.none - ) { innerPadding -> + bottomBar = { + BottomNavigation() + }, + ) { List( modifier = Modifier - .padding(innerPadding) + .scaffoldHazeSource() + .scaffoldPadding() .fillMaxWidth() .verticalScroll(rememberScrollState()) ) { @@ -84,7 +84,7 @@ fun SettingsScreen() { val manager: FeaturedManager? = FeaturedManager.managers.find { userPreferences.workingMode == it.workingMode } - manager.nullable { mng -> + manager?.let { mng -> RadioDialogItem( selection = mng.workingMode, options = FeaturedManager.managers.map { it.toRadioDialogItem() }, @@ -100,21 +100,25 @@ fun SettingsScreen() { } } - /* RadioDialogItem( + RadioDialogItem( selection = userPreferences.webuiEngine, options = listOf( - RadioDialogItem( + RadioDialogOption( value = WebUIEngine.WX, title = stringResource(R.string.settings_webui_engine_wx) ), - RadioDialogItem( - value = WebUIEngine.KSU, - title = stringResource(R.string.settings_webui_engine_ksu) + RadioDialogOption( + value = WebUIEngine.MX, + title = stringResource(R.string.settings_webui_engine_mx) ), - RadioDialogItem( - value = WebUIEngine.PREFER_MODULE, - title = stringResource(R.string.settings_webui_engine_prefer_module) - ) + // RadioDialogOption( + // value = WebUIEngine.KSU, + // title = stringResource(R.string.settings_webui_engine_ksu) + // ), + // RadioDialogOption( + // value = WebUIEngine.PREFER_MODULE, + // title = stringResource(R.string.settings_webui_engine_prefer_module) + // ) ), onConfirm = { viewModel.setWebUIEngine(it.value) @@ -125,12 +129,12 @@ fun SettingsScreen() { ) Title(R.string.settings_webui_engine) Description(R.string.settings_webui_engine_desc) - } */ + } - TextEditDialogItem( + InputDialogItem( value = userPreferences.datePattern, onConfirm = { - viewModel.setDatePattern(it) + viewModel.setDatePattern(it.value) }, onValid = { System.currentTimeMillis() @@ -173,4 +177,133 @@ fun SettingsScreen() { } } } -} \ No newline at end of file +} +// Scaffold( +// modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), +// topBar = { +// Toolbar( +// title = { +// ToolbarTitle(title = stringResource(id = R.string.settings)) +// }, +// ) +// }, +// bottomBar = { +// BottomNavigation() +// }, +// contentWindowInsets = WindowInsets.none +// ) { innerPadding -> +// List( +// modifier = Modifier +// .padding(innerPadding) +// .fillMaxWidth() +// .verticalScroll(rememberScrollState()) +// ) { +// Section( +// title = stringResource(R.string.general) +// ) { +// NavButton( +// route = AppThemeScreenDestination, +// icon = R.drawable.color_swatch, +// title = R.string.settings_app_theme, +// desc = R.string.settings_app_theme_desc +// ) +// +// NavButton( +// route = BehaviorScreenDestination, +// icon = R.drawable.activity, +// title = R.string.behavior, +// desc = R.string.behavior_desc +// ) +// +// val manager: FeaturedManager? = +// FeaturedManager.managers.find { userPreferences.workingMode == it.workingMode } +// +// manager.nullable { mng -> +// RadioDialogItem( +// selection = mng.workingMode, +// options = FeaturedManager.managers.map { it.toRadioDialogItem() }, +// onConfirm = { +// viewModel.setWorkingMode(it.value) +// }, +// ) { +// Icon( +// painter = painterResource(mng.icon) +// ) +// Title(R.string.platform) +// Description(mng.name) +// } +// } +// +// /* RadioDialogItem( +// selection = userPreferences.webuiEngine, +// options = listOf( +// RadioDialogItem( +// value = WebUIEngine.WX, +// title = stringResource(R.string.settings_webui_engine_wx) +// ), +// RadioDialogItem( +// value = WebUIEngine.KSU, +// title = stringResource(R.string.settings_webui_engine_ksu) +// ), +// RadioDialogItem( +// value = WebUIEngine.PREFER_MODULE, +// title = stringResource(R.string.settings_webui_engine_prefer_module) +// ) +// ), +// onConfirm = { +// viewModel.setWebUIEngine(it.value) +// } +// ) { +// Icon( +// painter = painterResource(R.drawable.engine) +// ) +// Title(R.string.settings_webui_engine) +// Description(R.string.settings_webui_engine_desc) +// } */ +// +// TextEditDialogItem( +// value = userPreferences.datePattern, +// onConfirm = { +// viewModel.setDatePattern(it) +// }, +// onValid = { +// System.currentTimeMillis() +// .toFormattedDateSafely(it) != "Invalid date format pattern" +// } +// ) { +// Icon( +// painter = painterResource(R.drawable.calendar_cog) +// ) +// Title(R.string.settings_date_pattern) +// Description(R.string.settings_date_pattern_desc) +// +// val date = System.currentTimeMillis().toFormattedDateSafely(it.value) +// DialogDescription(R.string.settings_date_pattern_dialog_desc, date) +// } +// +// NavButton( +// route = DeveloperScreenDestination, +// icon = R.drawable.bug, +// title = R.string.developer, +// ) +// } +// +// Section( +// title = stringResource(com.dergoogler.mmrl.ui.R.string.learn_more), +// divider = false +// ) { +// LinkButton( +// uri = "https://mmrl.dev/guide/webuix", +// title = R.string.settings_documentation, +// icon = R.drawable.api +// ) +// +// NavButton( +// route = LicensesScreenDestination, +// icon = R.drawable.license, +// title = R.string.setting_licenses, +// desc = R.string.setting_licenses_desc +// ) +// } +// } +// } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/AppThemeScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/AppThemeScreen.kt index 3f1e17c8..33f05352 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/AppThemeScreen.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/AppThemeScreen.kt @@ -4,21 +4,20 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource -import com.dergoogler.mmrl.ext.none -import com.dergoogler.mmrl.ui.component.NavigateUpTopBar +import androidx.compose.ui.unit.dp import com.dergoogler.mmrl.ui.providable.LocalNavController import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.ui.component.InfoAlert +import com.dergoogler.mmrl.wx.ui.component.NavigateUpToolbar import com.dergoogler.mmrl.wx.ui.screens.settings.appTheme.items.DarkModeItem import com.dergoogler.mmrl.wx.ui.screens.settings.appTheme.items.ExampleItem import com.dergoogler.mmrl.wx.ui.screens.settings.appTheme.items.ThemePaletteItem @@ -26,32 +25,41 @@ import com.dergoogler.mmrl.wx.ui.screens.settings.appTheme.items.TitleItem import com.dergoogler.mmrl.wx.viewmodel.LocalSettings import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import dev.mmrlx.compose.ui.scaffold.Scaffold +import dev.mmrlx.compose.ui.toolbar.ToolbarDefaults @Destination() -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppThemeScreen() { val userPreferences = LocalUserPreferences.current val viewModel = LocalSettings.current - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scrollBehavior = ToolbarDefaults.pinnedScrollBehavior() val navController = LocalNavController.current Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - NavigateUpTopBar( + toolbar = { + NavigateUpToolbar( title = stringResource(R.string.settings_app_theme), scrollBehavior = scrollBehavior, navController = navController, ) }, - contentWindowInsets = WindowInsets.none + contentWindowInsets = WindowInsets.systemBars, ) { Column( modifier = Modifier - .padding(it) - .verticalScroll(rememberScrollState()), + .scaffoldHazeSource() + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .scaffoldPadding() ) { + InfoAlert( + modifier = Modifier.padding(16.dp), + title = "Accent Theme", + message = "This page is now only used to configure the application's accent colors and define the WebUI theme." + ) + Column( modifier = Modifier .fillMaxWidth(), diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/DarkModeItem.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/DarkModeItem.kt index 98e374ca..2b92768a 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/DarkModeItem.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/DarkModeItem.kt @@ -15,11 +15,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -31,8 +26,12 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.datastore.model.DarkMode +import com.dergoogler.mmrl.wx.R +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.theme.LocalContentColor +import dev.mmrlx.compose.ui.theme.MMRLXTheme private enum class DarkModeItem( val value: DarkMode, @@ -101,7 +100,7 @@ private fun DarkModeItem( indication = null, interactionSource = remember { MutableInteractionSource() } ) - .background(color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + .background(color = MMRLXTheme.colors.card), contentAlignment = Alignment.Center ){ Row( @@ -127,7 +126,7 @@ private fun DarkModeItem( painter = painterResource(id = item.icon), contentDescription = null, tint = if (selected) { - MaterialTheme.colorScheme.primary + MMRLXTheme.colors.primary } else { LocalContentColor.current } @@ -135,9 +134,9 @@ private fun DarkModeItem( Text( text = stringResource(id = item.text), - style = MaterialTheme.typography.labelLarge, + style = MMRLXTheme.typography.labelLarge, color = if (selected) { - MaterialTheme.colorScheme.primary + MMRLXTheme.colors.primary } else { Color.Unspecified } diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/ExampleItem.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/ExampleItem.kt index 32b7f9cd..738e7d1d 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/ExampleItem.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/ExampleItem.kt @@ -1,5 +1,7 @@ package com.dergoogler.mmrl.wx.ui.screens.settings.appTheme.items +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,17 +12,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.ui.component.Logo +import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.ui.screens.modules.SkeletonModuleItem +import dev.mmrlx.compose.ui.Surface +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.theme.MMRLXTheme @Composable fun ExampleItem() { @@ -29,69 +32,51 @@ fun ExampleItem() { .fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - OutlinedCard( + Column( modifier = Modifier .padding(vertical = 16.dp) + .background(MMRLXTheme.colors.background) + .border(1.dp, MMRLXTheme.colors.border, RoundedCornerShape(15.dp)) + .fillMaxSize(0.5f), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( + Spacer(modifier = Modifier.height(10.dp)) + + Row( modifier = Modifier - .fillMaxSize(0.5f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - Spacer(modifier = Modifier.height(10.dp)) - - Row( + Logo( modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Logo( - modifier = Modifier - .padding(horizontal = 10.dp) - .size(20.dp), - icon = R.drawable.launcher_outline - ) - - Text(text = stringResource(id = R.string.app_name)) - } + .padding(horizontal = 10.dp) + .size(20.dp), + icon = R.drawable.launcher_outline + ) - Surface( - shape = RoundedCornerShape(15.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp - ) { - Spacer( - modifier = Modifier - .height(60.dp) - .fillMaxWidth(0.9f) - ) - } + Text( + text = stringResource(id = R.string.app_name), + style = MMRLXTheme.typography.labelLarge + ) + } - Surface( - shape = RoundedCornerShape(15.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp - ) { - Spacer( - modifier = Modifier - .height(60.dp) - .fillMaxWidth(0.9f) - ) + SkeletonModuleItem( + modifier = Modifier.graphicsLayer { + scaleX = 0.8f + scaleY = 0.8f } + ) - Spacer(modifier = Modifier.height(160.dp)) + Spacer(modifier = Modifier.height(160.dp)) - Surface( - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp - ) { - Spacer( - modifier = Modifier - .height(45.dp) - .fillMaxWidth() - ) - } + Surface( + color = MMRLXTheme.colors.card, + ) { + Spacer( + modifier = Modifier + .height(45.dp) + .fillMaxWidth() + ) } } } diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/ThemePaletteItem.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/ThemePaletteItem.kt index 05681f79..21d56b47 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/ThemePaletteItem.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/ThemePaletteItem.kt @@ -17,8 +17,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -26,8 +24,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.ui.theme.Colors +import com.dergoogler.mmrl.wx.R +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.theme.MMRLXTheme @OptIn(ExperimentalLayoutApi::class) @Composable @@ -83,7 +83,7 @@ private fun ThemeColorItem( interactionSource = remember { MutableInteractionSource() } ) .background( - color = colorScheme.surfaceColorAtElevation(3.dp) + color = MMRLXTheme.colors.card ) .size(60.dp), contentAlignment = Alignment.Center diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/TitleItem.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/TitleItem.kt index 808199a3..cd4d441a 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/TitleItem.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/screens/settings/appTheme/items/TitleItem.kt @@ -1,17 +1,17 @@ package com.dergoogler.mmrl.wx.ui.screens.settings.appTheme.items import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.theme.MMRLXTheme @Composable internal fun TitleItem( text: String, ) = Text( text = text, - style = MaterialTheme.typography.titleSmall, + style = MMRLXTheme.typography.titleSmall, modifier = Modifier.padding(start = 18.dp, top = 18.dp) ) \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/ExtraOptions.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/ExtraOptions.kt new file mode 100644 index 00000000..797a8190 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/ExtraOptions.kt @@ -0,0 +1,112 @@ +package com.dergoogler.mmrl.wx.ui.webui + +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode +import com.dergoogler.mmrl.wx.model.module.Module +import dev.mmrlx.nio.SuFile +import dev.mmrlx.webui.JavaScriptInterface +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.WebUISettings +import dev.mmrlx.webui.extra + +val WebUI.module: Module + get() = settings.extra("module") ?: throw IllegalStateException("Module not set") + +val WebUISettings.enableErudaConsole: Boolean + get() = debug && extra("enableEruda", false) + +val WebUISettings.autoOpenEruda: Boolean + get() = enableErudaConsole && extra("autoOpenEruda", false) + +val WebUISettings.disableGlobalExitConfirm: Boolean + get() = extra("disableGlobalExitConfirm", false) + +val WebUISettings.forceKillWebUIProcess: Boolean + get() = extra("forceKillWebUIProcess", true) + +val WebUISettings.isRootMode: Boolean + get() = extra("isRootMode", false) + +val WebUISettings.workingMode: WorkingMode + get() = extra("workingMode", WorkingMode.MODE_NON_ROOT) + +fun WebUI.sufile(vararg paths: Any): SuFile { + val f = file(*paths) + + if (f !is SuFile) { + throw IllegalArgumentException("Provided file factory is not a `dev.mmrlx.nio.SuFile`") + } + + return f +} + +fun JavaScriptInterface.deprecated(method: String, replaceWith: String? = null) { + console.warn( + "[DEPRECATED] The `$method` method will be removed in future versions.${if (replaceWith != null) " Use `$replaceWith` instead." else ""}", + ) +} + +val Module.sanitizedId: String + get() { + return id.replace(Regex("[^a-zA-Z0-9_]"), "_") + } + +val Module.sanitizedIdWithFile + get(): String { + return "$${ + when { + sanitizedId.length >= 2 -> sanitizedId[0].uppercase() + sanitizedId[1] + sanitizedId.isNotEmpty() -> sanitizedId[0].uppercase() + else -> "" + } + }File" + } + +val Module.sanitizedIdWithFileInputStream get(): String = "${sanitizedIdWithFile}InputStream" +val Module.sanitizedIdWithFileOutputStream get(): String = "${sanitizedIdWithFile}OutputStream" + +fun JavaScriptInterface.runTry( + message: String = "Unknown Error", + default: R, + block: () -> R, +): R = try { + block() +} catch (e: Throwable) { + console.error(message, e) + default +} + +fun JavaScriptInterface.runTry( + message: String = "Unknown Error", + block: () -> R, +): R? = runTry(message, null, block) + +fun JavaScriptInterface.runTryJsWith( + with: T, + message: String = "Unknown Error", + block: T.() -> R, +): R? = runTryJsWith(with, message, null, block) + +fun JavaScriptInterface.runTryJsWith( + with: T, + message: String = "Unknown Error", + default: R, + block: T.() -> R, +): R { + return try { + with(with, block) + } catch (e: Throwable) { + console.error(message, e) + return default + } +} + +val Array.append: Boolean + get() { + val p = this[1] + + if (p is Boolean) { + return p + } + + return false + } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIActivity.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIActivity.kt new file mode 100644 index 00000000..4b1b7162 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIActivity.kt @@ -0,0 +1,55 @@ +package com.dergoogler.mmrl.wx.ui.webui + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.dergoogler.mmrl.ext.exception.BrickException +import com.dergoogler.mmrl.wx.ui.component.ModuleScope +import com.dergoogler.mmrl.wx.util.BaseActivity +import com.dergoogler.mmrl.wx.util.setBaseContent +import com.dergoogler.mmrl.wx.util.setMyCrashHandler +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +@AndroidEntryPoint +class WebUIActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + setMyCrashHandler() + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val moduleId = intent.getStringExtra("MODULE_ID") + ?: throw BrickException("moduleId cannot be null or empty") + + setBaseContent { + var localKey by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + recomposeFlow.collect { + localKey++ + } + } + + ModuleScope(moduleId, toolbar = false) { + key(localKey) { + WebUIScreen() + } + } + } + } + + companion object { + private val _recomposeFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val recomposeFlow = _recomposeFlow.asSharedFlow() + + fun recompose() { + _recomposeFlow.tryEmit(Unit) + } + } +} diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIExit.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIExit.kt new file mode 100644 index 00000000..d5fedd74 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIExit.kt @@ -0,0 +1,85 @@ +package com.dergoogler.mmrl.wx.ui.webui + +import androidx.compose.material3.ColorScheme +import com.dergoogler.mmrl.ui.component.dialog.ConfirmData +import com.dergoogler.mmrl.ui.component.dialog.confirm +import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.model.module.WebrootConfig +import com.dergoogler.mmrl.wx.model.module.backInterceptor +import com.dergoogler.mmrl.wx.model.module.exitConfirm +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.WebUIBackEventType +import kotlin.system.exitProcess + +private fun WebUI.handleNativeExit(colorScheme: ColorScheme, config: WebrootConfig) { + if (webview.canGoBack()) { + webview.goBack() + return + } + + if (settings.disableGlobalExitConfirm) { + exit() + return + } + + with(kontext) { + + if (config.exitConfirm) { + kontext.confirm( + confirmData = ConfirmData( + title = getString(R.string.exit), + description = getString(R.string.exit_desc), + onConfirm = { exit() }, + onClose = {} + ), + colorScheme = colorScheme + ) + return + } + } + + exit() +} + +fun WebUI.backHandlers(colorScheme: ColorScheme): WebUI { + val config = module.webrootConfig + + val backInterceptor = when (config.backInterceptor) { + "native" -> WebUIBackEventType.NATIVE + "javascript" -> WebUIBackEventType.JAVASCRIPT + "javascript-full" -> WebUIBackEventType.JAVASCRIPT_FULL + else -> WebUIBackEventType.NATIVE + } + + return this + .settings { + backEventType = backInterceptor + }.backEvents { + onBackPressed { + when (backInterceptor) { + WebUIBackEventType.NATIVE -> { + handleNativeExit(colorScheme, config) + } + + WebUIBackEventType.JAVASCRIPT -> { + guardWebViewState(::emitBackPressed) + } + + WebUIBackEventType.JAVASCRIPT_FULL -> { + emitBackPressed() + } + } + } + } +} + +fun WebUI.exit() { + if (!settings.forceKillWebUIProcess) { + activity.finish() + return + } + + activity.finish() + android.os.Process.killProcess(android.os.Process.myPid()) + exitProcess(0) +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIScreen.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIScreen.kt new file mode 100644 index 00000000..f4557733 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/WebUIScreen.kt @@ -0,0 +1,203 @@ +package com.dergoogler.mmrl.wx.ui.webui + +import android.net.Uri +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import com.dergoogler.mmrl.ext.managerVersion +import com.dergoogler.mmrl.platform.PlatformManager +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode.Companion.isRoot +import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.ui.component.DraggableFab +import com.dergoogler.mmrl.wx.ui.component.LocalModule +import com.dergoogler.mmrl.wx.ui.webui.devtools.DevTools +import com.dergoogler.mmrl.wx.ui.webui.devtools.LocalDevTools +import com.dergoogler.mmrl.wx.ui.webui.devtools.LocalWebUI +import com.dergoogler.mmrl.wx.ui.webui.interfaces.ApplicationInterface +import com.dergoogler.mmrl.wx.ui.webui.interfaces.FileSystemInterface +import com.dergoogler.mmrl.wx.ui.webui.interfaces.KernelSUInterface +import com.dergoogler.mmrl.wx.ui.webui.interfaces.legacy.FileInputInterface +import com.dergoogler.mmrl.wx.ui.webui.interfaces.legacy.FileOutputInterface +import com.dergoogler.mmrl.wx.ui.webui.interfaces.legacy.ModuleInterface +import com.dergoogler.mmrl.wx.ui.webui.pathHandlers.InternalPathHandler +import com.dergoogler.mmrl.wx.ui.webui.pathHandlers.SuPathHandler +import com.dergoogler.mmrl.wx.ui.webui.pathHandlers.WebrootPathHandler +import com.dergoogler.mmrl.wx.ui.webui.pathHandlers.ksu.IconPathHandler +import com.dergoogler.mmrl.wx.ui.webui.util.luaPlugin +import com.dergoogler.mmrl.wx.viewmodel.DevToolsViewModel +import dev.mmrlx.compose.webui.WebUIView +import dev.mmrlx.compose.webui.rememberWebUIState +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.SuFileInputStream +import dev.mmrlx.nio.SuFileOutputStream +import dev.mmrlx.webui.WebUI + +@Composable +fun WebUIScreen(devToolsViewModel: DevToolsViewModel = hiltViewModel()) { + val module = LocalModule.current + val context = LocalContext.current + val prefs = LocalUserPreferences.current + + val colorScheme = remember { + prefs.colorScheme(context) + } + + val userAgent = remember { + val mmrlVersion = context.managerVersion.second + + val platform = prefs.workingMode.toString + + val platformVersion = PlatformManager.get(-1) { + moduleManager.versionCode + } + + val osVersion = Build.VERSION.RELEASE + val deviceModel = Build.MODEL + + "WebUI X/$mmrlVersion (Linux; Android $osVersion; $deviceModel; $platform/$platformVersion)" + } + + val isDebug = prefs.developerMode + + val domain = remember { + if (isDebug && prefs.useWebUiDevUrl) { + prefs.webUiDevUrl + } else { + "https://mui.kernelsu.org" + } + } + + var openDevTools by remember { + mutableStateOf(false) + } + + val wstate = rememberWebUIState(domain) { + it + .factories { + inputStreamFactory { paths -> + SuFileInputStream(paths.first) + } + + outputStreamFactory { paths -> + val path = paths.first + val append = paths.append + SuFileOutputStream(path, append) + } + + fileFactory { paths -> + SuFile(*paths) + } + } + .settings { + schemeWhitelist += "ksu" + useDefaultApplicationInterface = false + useDefaultFileSystem = false + debug = isDebug + forceKillProcess = + prefs.forceKillWebUIProcess + userAgentString = userAgent + useConsoleInterceptor = + !prefs.disableConsoleInterceptor + darkMode = prefs.isDarkMode() + + extra = mapOf( + "module" to module, + "enableEruda" to prefs.enableErudaConsole, + "autoOpenEruda" to prefs.enableAutoOpenEruda, + "disableGlobalExitConfirm" to prefs.disableGlobalExitConfirm, + "isRootMode" to prefs.workingMode.isRoot, + "workingMode" to prefs.workingMode + ) + } + // legacy interfaces + .registerJavascriptInterface(ModuleInterface::class.java) + .registerJavascriptInterface(FileInputInterface::class.java) + .registerJavascriptInterface(FileOutputInterface::class.java) + .registerJavascriptInterface(com.dergoogler.mmrl.wx.ui.webui.interfaces.ModuleInterface::class.java) + // end + .backHandlers(colorScheme) + .client { } + .chromeClient { } + .luaPlugin() + .registerJavascriptInterface( + KernelSUInterface::class.java + ) + .registerJavascriptInterface( + ApplicationInterface::class.java + ) { + add( + ColorScheme::class.java to colorScheme + ) + } + .registerJavascriptInterface( + FileSystemInterface::class.java + ) + .registerPathHandler( + InternalPathHandler::class.java + ) { + add( + ColorScheme::class.java to colorScheme + ) + } + .registerPathHandler(IconPathHandler::class.java) + .registerSuPathHandler( + "/.${module.id}/", + module.path.moduleDir + ) + .registerSuPathHandler( + "/.adb/", + module.adbPath.baseDir + ) + .registerSuPathHandler( + "/.config/", + module.adbPath.configDir + ) + .registerSuPathHandler( + "/.local/", + module.adbPath.localDir + ) + .registerPathHandler( + WebrootPathHandler::class.java + ) + } + + CompositionLocalProvider( + LocalWebUI provides wstate.webui, + LocalDevTools provides devToolsViewModel + ) { + WebUIView(wstate) + + if (prefs.developerMode { enableDevTools }) { + DraggableFab( + onClick = { openDevTools = true } + ) + + DevTools( + isOpen = openDevTools, + onDismissRequest = { + openDevTools = false + } + ) + } + } +} + +private fun WebUI.registerSuPathHandler( + path: String, + directory: String, + authority: String = baseUri.toString(), +): WebUI { + return this.registerPathHandler(SuPathHandler::class.java) { + add(String::class.java to path) + add(Uri::class.java to authority.toUri()) + add(String::class.java to directory) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/alerts/Confirm.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/alerts/Confirm.kt new file mode 100644 index 00000000..41537914 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/alerts/Confirm.kt @@ -0,0 +1,108 @@ +@file:Suppress("UnusedReceiverParameter") + +package com.dergoogler.mmrl.wx.ui.webui.alerts + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.dergoogler.mmrl.ui.component.dialog.ConfirmDialog +import com.dergoogler.mmrl.wx.ui.webui.interfaces.ApplicationInterface +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.button.Button +import dev.mmrlx.compose.ui.button.ButtonVariant +import dev.mmrlx.compose.ui.dialog.Content +import dev.mmrlx.compose.ui.dialog.Footer +import dev.mmrlx.compose.ui.dialog.Title +import dev.mmrlx.compose.ui.dialog.rememberDialog +import dev.mmrlx.compose.ui.theme.MMRLXTheme + +@Composable +fun ApplicationInterface.Md3Confirm( + title: String, + description: String?, + onConfirm: () -> Unit, + onClose: () -> Unit, + confirmText: String, + cancelText: String, + colorScheme: ColorScheme, +) { + var showDialog by remember { mutableStateOf(true) } + + val done = { + showDialog = false + onConfirm() + } + + val close = { + showDialog = false + onClose() + } + + if (showDialog) { + MaterialTheme(colorScheme = colorScheme) { + ConfirmDialog( + onDismissRequest = close, + closeText = cancelText, + confirmText = confirmText, + title = title, + description = description ?: "", + onClose = close, + onConfirm = done + ) + } + } +} + +@Composable +fun ApplicationInterface.MXConfirm( + title: String, + description: String?, + onConfirm: () -> Unit, + onClose: () -> Unit, + confirmText: String, + cancelText: String, +) { + MMRLXTheme( + darkTheme = isDarkMode + ) { + val dialog = rememberDialog(true) + + val done = { + dialog.close() + onConfirm() + } + + val close = { + dialog.close() + onClose() + } + + dialog { + Title { + Text(title) + } + + Content { + Text(description ?: "") + } + + Footer { + Button( + onClick = close, + variant = ButtonVariant.Outline + ) { + Text(cancelText) + } + Button( + onClick = done, + ) { + Text(confirmText) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/alerts/Prompt.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/alerts/Prompt.kt new file mode 100644 index 00000000..0f23c5f8 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/alerts/Prompt.kt @@ -0,0 +1,294 @@ +@file:Suppress("UnusedReceiverParameter") + +package com.dergoogler.mmrl.wx.ui.webui.alerts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.KeyboardActionHandler +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.dergoogler.mmrl.ui.component.dialog.TextFieldDialog +import com.dergoogler.mmrl.wx.ui.webui.interfaces.ApplicationInterface +import dev.mmrlx.compose.ui.button.Button +import dev.mmrlx.compose.ui.button.ButtonVariant +import dev.mmrlx.compose.ui.dialog.Content +import dev.mmrlx.compose.ui.dialog.Footer +import dev.mmrlx.compose.ui.dialog.Title +import dev.mmrlx.compose.ui.dialog.rememberDialog +import dev.mmrlx.compose.ui.list.DialogItemSlot +import dev.mmrlx.compose.ui.text.OutlinedInput +import dev.mmrlx.compose.ui.text.rememberInputState +import dev.mmrlx.compose.ui.theme.MMRLXTheme + +@Composable +internal fun ApplicationInterface.Md3Prompt( + title: String, + description: String?, + value: String, + onConfirm: (String) -> Unit, + onClose: () -> Unit, + confirmText: String, + cancelText: String, + colorScheme: ColorScheme, + launchKeyboard: Boolean, + imeAction: ImeAction, + keyboardType: KeyboardType, +) { + var text by remember { mutableStateOf(value) } + var showDialog by remember { mutableStateOf(true) } + + val done = remember { + { + showDialog = false + onConfirm(text) + } + } + + val close = { + showDialog = false + onClose() + } + + if (showDialog) { + MaterialTheme( + colorScheme = colorScheme + ) { + TextFieldDialog( + onDismissRequest = close, + title = { + Text(text = title) + }, + confirmButton = { + TextButton( + onClick = done, + enabled = text.isNotBlank(), + ) { + Text(confirmText) + } + }, + dismissButton = { + TextButton( + onClick = close, + ) { + Text(cancelText) + } + }, + launchKeyboard = launchKeyboard + ) { focusRequester -> + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + } + + OutlinedTextField( + modifier = Modifier.focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyLarge, + value = text, + onValueChange = { + text = it + }, + singleLine = false, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = imeAction, + ), + keyboardActions = KeyboardActions { + if (text.isNotBlank()) done() + }, + shape = RoundedCornerShape(15.dp), + ) + } + } + } + } +} + +@Composable +internal fun ApplicationInterface.MXPrompt( + title: String, + description: String?, + value: String, + onConfirm: (String) -> Unit, + onClose: () -> Unit, + confirmText: String, + cancelText: String, + supportingText: String?, + launchKeyboard: Boolean, + imeAction: ImeAction, + keyboardType: KeyboardType, +) { + MMRLXTheme(darkTheme = isDarkMode) { + val dialog = rememberDialog(true) + val state = rememberInputState(value) + + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(focusRequester) { + if (launchKeyboard) { + focusRequester.requestFocus() + keyboardController?.show() + } + } + + val data = + remember(state.text) { + state.text.toString() + } + + dialog.onClose { + state.clearText() + state.setTextAndPlaceCursorAtEnd(value) + } + + val done: () -> Unit = remember(state.text) { + { + onConfirm(data) + dialog.close() + } + } + + val close = { + dialog.close() + onClose() + } + + dialog { + Title { + dev.mmrlx.compose.ui.Text(title) + } + + Content { + Layout( + content = { + if (description != null) { + dev.mmrlx.compose.ui.Text( + modifier = Modifier.layoutId(DialogItemSlot.Description), + text = description + ) + } + + OutlinedInput( + state = state, + modifier = Modifier + .focusRequester(focusRequester) + .layoutId(DialogItemSlot.Input) + .fillMaxWidth(), + textStyle = MMRLXTheme.typography.bodyLarge, + supportingText = supportingText?.let { + { + dev.mmrlx.compose.ui.Text(it) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = imeAction, + ), + onKeyboardAction = KeyboardActionHandler { + if (state.text.isNotBlank()) done() + }, + ) + }, + ) { measurables, constraints -> + val spacing = 16.dp.roundToPx() + + val descriptionPlaceable = + measurables + .firstOrNull { it.layoutId == DialogItemSlot.Description } + ?.measure(constraints) + val textFieldPlaceable = + measurables + .first { it.layoutId == DialogItemSlot.Input } + .measure(constraints) + + val totalHeight = + listOfNotNull( + descriptionPlaceable?.height, + spacing.takeIf { descriptionPlaceable != null }, + textFieldPlaceable.height, + ).sum() + + layout(constraints.maxWidth, totalHeight) { + var y = 0 + + descriptionPlaceable?.let { + it.placeRelative(0, y) + y += it.height + spacing + } + + textFieldPlaceable.placeRelative(0, y) + } + } + } + + Footer { + Button( + onClick = done, + variant = ButtonVariant.Outline + ) { + dev.mmrlx.compose.ui.Text(confirmText) + } + Button( + onClick = close, + ) { + dev.mmrlx.compose.ui.Text(cancelText) + } + } + } + } +} + +internal fun KeyboardType.Companion.fromString(value: String): KeyboardType { + return when(value.lowercase()) { + "ascii" -> KeyboardType.Ascii + "number" -> KeyboardType.Number + "phone" -> KeyboardType.Phone + "uri" -> KeyboardType.Uri + "email" -> KeyboardType.Email + "password" -> KeyboardType.Password + "numberpassword" -> KeyboardType.NumberPassword + "decimal" -> KeyboardType.Decimal + else -> KeyboardType.Text + } +} + +internal fun ImeAction.Companion.fromString(value: String): ImeAction { + return when(value) { + "done" -> ImeAction.Done + "go" -> ImeAction.Go + "next" -> ImeAction.Next + "previous" -> ImeAction.Previous + "search" -> ImeAction.Search + "send" -> ImeAction.Send + else -> ImeAction.Done + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/DevTools.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/DevTools.kt new file mode 100644 index 00000000..3c28e49f --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/DevTools.kt @@ -0,0 +1,79 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools + +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import com.dergoogler.mmrl.wx.ui.webui.devtools.tabs.ConsoleTab +import com.dergoogler.mmrl.wx.ui.webui.devtools.tabs.DomTab +import com.dergoogler.mmrl.wx.ui.webui.devtools.tabs.NetworkTab +import com.dergoogler.mmrl.wx.ui.webui.devtools.tabs.SnippetsTab +import dev.mmrlx.webui.WebUI + +val LocalWebUI = compositionLocalOf { error("No WebUI provided") } + +@Composable +fun DevTools( + isOpen: Boolean, + onDismissRequest: () -> Unit, +) { + val pagerState = rememberPagerState(1) { 4 } + + BoxWithConstraints { + val density = LocalDensity.current + val maxHeightPx = with(density) { maxHeight.toPx() } + val minHeightRatio = 0.3f + val maxHeightRatio = 0.95f + var sheetHeightRatio by rememberSaveable { mutableStateOf(0.55f) } + val sheetHeight = remember(maxHeight, sheetHeightRatio) { + maxHeight * sheetHeightRatio + } + + val dragModifier = Modifier.pointerInput(maxHeightPx) { + detectVerticalDragGestures { change, dragAmount -> + change.consume() + if (maxHeightPx <= 0f) return@detectVerticalDragGestures + + val ratioDelta = dragAmount / maxHeightPx + sheetHeightRatio = (sheetHeightRatio - ratioDelta) + .coerceIn(minHeightRatio, maxHeightRatio) + } + } + + DevToolsContainer( + isVisible = isOpen, + panelHeight = sheetHeight, + onDismissRequest = onDismissRequest, + dragHandle = { modifier -> + ViewTab( + modifier = modifier.then(dragModifier), + state = pagerState, + onDismissRequest = onDismissRequest + ) + } + ) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false + ) { pageIndex -> + when (pageIndex) { + 0 -> DomTab() + 1 -> ConsoleTab() + 2 -> NetworkTab() + 3 -> SnippetsTab() + } + } + } + } +} + diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/DevToolsContainer.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/DevToolsContainer.kt new file mode 100644 index 00000000..8e055d64 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/DevToolsContainer.kt @@ -0,0 +1,95 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.mmrlx.compose.ui.theme.MMRLXTheme + +@Composable +fun DevToolsContainer( + isVisible: Boolean, + panelHeight: Dp, + onDismissRequest: () -> Unit, + dragHandle: @Composable (Modifier) -> Unit, + content: @Composable () -> Unit, +) { + val colors = MMRLXTheme.colors + + Box( + modifier = Modifier + .imePadding() + .fillMaxSize() + ) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismissRequest + ) + ) + } + + AnimatedVisibility( + visible = isVisible, + modifier = Modifier.align(Alignment.BottomCenter), + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(panelHeight) + .drawWithContent { + drawContent() + + val strokeWidth = 0.3.dp.toPx() + val y = strokeWidth / 2f + + drawLine( + color = colors.border, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = strokeWidth + ) + } + .clickable(enabled = false) { }, + color = colors.background, + tonalElevation = 1.dp + ) { + Column { + dragHandle(Modifier) + content() + } + } + } + } +} diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/Style.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/Style.kt new file mode 100644 index 00000000..9eae4092 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/Style.kt @@ -0,0 +1,24 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalAbsoluteTonalElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.dergoogler.mmrl.ui.token.applyTonalElevation +import com.dergoogler.mmrl.wx.viewmodel.DevToolsViewModel + + +val ColorScheme.tonalSurface: Color + @Composable get() { + val absoluteElevation = LocalAbsoluteTonalElevation.current + 1.dp + val color: Color = surface + return applyTonalElevation( + color, + absoluteElevation, + ) + } + + +val LocalDevTools = compositionLocalOf { error("No DevToolsViewModel provided") } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/Tab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/Tab.kt new file mode 100644 index 00000000..6783f053 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/Tab.kt @@ -0,0 +1,94 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.selection.selectable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import dev.mmrlx.compose.ui.ProvideTextStyle +import dev.mmrlx.compose.ui.theme.LocalContentColor +import dev.mmrlx.compose.ui.theme.MMRLXTheme +import dev.mmrlx.compose.ui.theme.ripple + +@Composable +fun Tab( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + selectedContentColor: Color = LocalContentColor.current, + unselectedContentColor: Color = selectedContentColor, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable ColumnScope.() -> Unit, +) { + val ripple = ripple(bounded = false, color = selectedContentColor) + + TabTransition(selectedContentColor, unselectedContentColor, selected) { + ProvideTextStyle(value = MMRLXTheme.typography.titleSmall) { + Column( + modifier = + Modifier + .selectable( + selected = selected, + onClick = onClick, + enabled = enabled, + role = Role.Tab, + interactionSource = interactionSource, + indication = ripple, + ).then(modifier), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + content = content, + ) + } + } +} + +@Composable +private fun TabTransition( + activeColor: Color, + inactiveColor: Color, + selected: Boolean, + content: @Composable () -> Unit, +) { + val transition = updateTransition(selected, label = "Tab") + val color by transition.animateColor( + label = "color", + transitionSpec = { + if (false isTransitioningTo true) { + tween( + durationMillis = TabFadeInAnimationDuration, + delayMillis = TabFadeInAnimationDelay, + easing = LinearEasing, + ) + } else { + tween( + durationMillis = TabFadeOutAnimationDuration, + easing = LinearEasing, + ) + } + }, + ) { + if (it) activeColor else inactiveColor + } + CompositionLocalProvider( + LocalContentColor provides color, + content = content, + ) +} + +private const val TabFadeInAnimationDuration = 150 +private const val TabFadeInAnimationDelay = 100 +private const val TabFadeOutAnimationDuration = 100 diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/TabRow.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/TabRow.kt new file mode 100644 index 00000000..b741f8a9 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/TabRow.kt @@ -0,0 +1,222 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap +import dev.mmrlx.compose.ui.HorizontalDivider +import dev.mmrlx.compose.ui.Surface +import dev.mmrlx.compose.ui.theme.MMRLXTheme + +@Composable +fun DevToolsTabRow( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + containerColor: Color = MMRLXTheme.colors.card, + contentColor: Color = MMRLXTheme.colors.cardForeground, + indicator: @Composable (tabPositions: List) -> Unit = + { tabPositions -> + if (selectedTabIndex < tabPositions.size) { + Box( + Modifier + .tabIndicatorOffset(tabPositions[selectedTabIndex]) + .fillMaxWidth() + .height(3.0.dp) + .background(color = MMRLXTheme.colors.primary) + ) + } + }, + divider: @Composable () -> Unit = { HorizontalDivider() }, + endContent: (@Composable () -> Unit)? = null, + tabs: @Composable () -> Unit, +) { + TabRowWithSubcomposeImpl( + modifier, + containerColor, + contentColor, + indicator, + divider, + tabs, + endContent + ) +} + +private enum class TabSlots { + Tabs, + EndContent, + Divider, + Indicator +} + +@Immutable +class TabPosition internal constructor( + val left: Dp, + val width: Dp, + val contentWidth: Dp, +) { + val right: Dp get() = left + width +} + +@Composable +private fun TabRowWithSubcomposeImpl( + modifier: Modifier, + containerColor: Color, + contentColor: Color, + indicator: @Composable (tabPositions: List) -> Unit, + divider: @Composable () -> Unit, + tabs: @Composable () -> Unit, + endContent: (@Composable () -> Unit)? = null, +) { + Surface( + modifier = modifier.selectableGroup(), + color = containerColor, + contentColor = contentColor + ) { + SubcomposeLayout(Modifier.fillMaxWidth()) { constraints -> + + val paddingPx = 8.dp.roundToPx() + + // Measure end content first + val endPlaceables = endContent?.let { + subcompose(TabSlots.EndContent, it).map { + it.measure(constraints.copy(minWidth = 0)) + } + }.orEmpty() + + val endWidth = endPlaceables.sumOf { it.width } + + // Measure tabs + val tabMeasurables = subcompose(TabSlots.Tabs, tabs) + + val tabPlaceables = tabMeasurables.fastMap { + it.measure( + constraints.copy( + minWidth = 0, + maxWidth = Constraints.Infinity + ) + ) + } + + val tabRowHeight = + maxOf( + tabPlaceables.maxOfOrNull { it.height } ?: 0, + endPlaceables.maxOfOrNull { it.height } ?: 0 + ) + + var xPosition = 0 + + val tabPositions = tabPlaceables.fastMap { placeable -> + + val paddedWidth = placeable.width + paddingPx * 2 + + val position = TabPosition( + left = xPosition.toDp(), + width = paddedWidth.toDp(), + contentWidth = placeable.width.toDp() + ) + + xPosition += paddedWidth + position + } + + val layoutWidth = constraints.maxWidth + + layout(layoutWidth, tabRowHeight) { + + // Tabs from start + var x = 0 + + tabPlaceables.fastForEach { placeable -> + placeable.placeRelative( + x + paddingPx, + 0 + ) + x += placeable.width + paddingPx * 2 + } + + // End content from right + var endX = layoutWidth - endWidth + + endPlaceables.forEach { placeable -> + placeable.placeRelative( + endX, + (tabRowHeight - placeable.height) / 2 + ) + endX += placeable.width + } + + subcompose(TabSlots.Divider, divider).fastForEach { + val placeable = it.measure( + constraints.copy(minHeight = 0) + ) + placeable.placeRelative( + 0, + tabRowHeight - placeable.height + ) + } + + subcompose(TabSlots.Indicator) { + indicator(tabPositions) + }.fastForEach { + it.measure( + Constraints.fixed(layoutWidth, tabRowHeight) + ).placeRelative(0, 0) + } + } + } + } +} + +fun Modifier.tabIndicatorOffset(currentTabPosition: TabPosition): Modifier = + composed( + inspectorInfo = + debugInspectorInfo { + name = "tabIndicatorOffset" + value = currentTabPosition + } + ) { + + val currentTabWidth by animateDpAsState( + targetValue = currentTabPosition.width, + animationSpec = TabRowIndicatorSpec + ) + + val indicatorOffset by animateDpAsState( + targetValue = currentTabPosition.left, + animationSpec = TabRowIndicatorSpec + ) + + fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset { IntOffset(indicatorOffset.roundToPx(), 0) } + .width(currentTabWidth) + } + +private val TabRowIndicatorSpec: AnimationSpec = + tween( + durationMillis = 250, + easing = FastOutSlowInEasing + ) \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/ViewTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/ViewTab.kt new file mode 100644 index 00000000..836f9ea1 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/ViewTab.kt @@ -0,0 +1,162 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools + +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.dergoogler.mmrl.wx.R +import dev.mmrlx.compose.ui.HorizontalDivider +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.icon.IconButton +import dev.mmrlx.compose.ui.theme.MMRLXTheme +import kotlinx.coroutines.launch + +@Composable +fun ViewTab( + modifier: Modifier = Modifier, + state: PagerState, + onDismissRequest: () -> Unit, +) { + val scope = rememberCoroutineScope() + + val pages = remember { + listOf( + R.string.dom, + R.string.console, + R.string.network, + R.string.snippets, + ) + } + + DevToolsTabRow( + modifier = modifier + .background(MMRLXTheme.colors.card), + selectedTabIndex = state.currentPage, + indicator = { tabPositions -> + AnimatedIndicator( + tabPositions = tabPositions, + selectedTabIndex = state.currentPage + ) + }, + divider = { + HorizontalDivider( + thickness = 0.3.dp, + ) + }, + endContent = { + IconButton( + modifier = Modifier.padding(horizontal = 8.dp), + onClick = onDismissRequest + ) { + Icon( + painter = painterResource(R.drawable.x), + contentDescription = "Close DevTools", + modifier = Modifier.size(20.dp) + ) + } + } + ) { + pages.forEachIndexed { index, text -> + Tab( + modifier = Modifier.padding(vertical = 12.dp), + selected = state.currentPage == index, + onClick = { + scope.launch { + state.animateScrollToPage(index) + } + }, + selectedContentColor = MMRLXTheme.colors.primary, + unselectedContentColor = MMRLXTheme.colors.mutedForeground + ) { + Text( + text = stringResource(text), + style = MMRLXTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun Indicator( + color: Color, + modifier: Modifier = Modifier, +) { + Canvas( + modifier = modifier.defaultMinSize(minHeight = 3.dp), + ) { + val width = size.width / 4 + + drawLine( + color = color, + start = Offset(width, size.height), + end = Offset(width * 3, size.height), + strokeWidth = size.height * 2, + cap = StrokeCap.Round + ) + } +} + +@Composable +private fun AnimatedIndicator(tabPositions: List, selectedTabIndex: Int) { + val transition = updateTransition(selectedTabIndex, label = "Indicator") + val indicatorStart by transition.animateDp( + transitionSpec = { + if (initialState < targetState) { + spring(dampingRatio = 1f, stiffness = 50f) + } else { + spring(dampingRatio = 1f, stiffness = 1000f) + } + }, + label = "Indicator" + ) { + tabPositions[it].left + } + + val indicatorEnd by transition.animateDp( + transitionSpec = { + if (initialState < targetState) { + spring(dampingRatio = 1f, stiffness = 1000f) + } else { + spring(dampingRatio = 1f, stiffness = 50f) + } + }, + label = "Indicator" + ) { + tabPositions[it].right + } + + Indicator( + color = MMRLXTheme.colors.primary, + modifier = Modifier + .wrapContentSize(align = Alignment.BottomStart) + .offset(x = indicatorStart) + .width(indicatorEnd - indicatorStart) + .height(3.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/ConsoleTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/ConsoleTab.kt new file mode 100644 index 00000000..f6fd9c23 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/ConsoleTab.kt @@ -0,0 +1,892 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools.tabs + +import android.webkit.ConsoleMessage +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.ui.webui.devtools.LocalWebUI +import com.dergoogler.mmrl.wx.util.badgeDebugBackground +import com.dergoogler.mmrl.wx.util.badgeDebugForeground +import com.dergoogler.mmrl.wx.util.badgeErrorBackground +import com.dergoogler.mmrl.wx.util.badgeErrorForeground +import com.dergoogler.mmrl.wx.util.badgeInfoBackground +import com.dergoogler.mmrl.wx.util.badgeInfoForeground +import com.dergoogler.mmrl.wx.util.badgeWarnBackground +import com.dergoogler.mmrl.wx.util.badgeWarnForeground +import dev.mmrlx.compose.ui.HorizontalDivider +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.VerticalDivider +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.icon.IconButton +import dev.mmrlx.compose.ui.theme.LocalContentColor +import dev.mmrlx.compose.ui.theme.MMRLXTheme +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.console.ConsoleEntry +import dev.mmrlx.webui.console.PrimitiveKind +import dev.mmrlx.webui.console.ResultNode +import dev.mmrlx.webui.console.wrapConsoleEvalResult +import org.json.JSONObject + +private sealed class LogEntry { + abstract val timestamp: Long + + data class WebMessage(val entry: ConsoleEntry) : LogEntry() { + override val timestamp get() = entry.timestamp + } + + data class EvalInput( + val code: String, + override val timestamp: Long = System.currentTimeMillis(), + ) : LogEntry() + + sealed class EvalResult : LogEntry() { + data class Parsed( + val root: ResultNode, + override val timestamp: Long = System.currentTimeMillis(), + ) : EvalResult() + + data class Error( + val message: String, + override val timestamp: Long = System.currentTimeMillis(), + ) : EvalResult() + } +} + +private sealed class FlatRow { + data class PrimitiveRow(val node: ResultNode.Primitive) : FlatRow() + data class OpenRow(val node: ResultNode.Expandable, val isCollapsed: Boolean) : FlatRow() + data class CloseRow(val token: String, val depth: Int, val parentId: String) : FlatRow() +} + +@Composable +fun ConsoleTab() { + val webui = LocalWebUI.current + val colors = MMRLXTheme.colors + + val richLogs by webui.console.flow.collectAsState() + val evalEntries = remember { mutableStateListOf() } + val listState = rememberLazyListState() + + var filterLevel by remember { mutableStateOf(null) } + var searchQuery by remember { mutableStateOf("") } + var jsInput by remember { mutableStateOf("") } + + val allEntries: List = remember(richLogs.size, evalEntries.size) { + (richLogs.map { LogEntry.WebMessage(it) } + evalEntries) + .sortedBy { it.timestamp } + } + + val filtered: List = remember(allEntries.size, filterLevel, searchQuery) { + allEntries.filter { entry -> + when (entry) { + is LogEntry.WebMessage -> { + val levelMatch = filterLevel == null || entry.entry.level == filterLevel + val queryMatch = searchQuery.isBlank() || entry.entry.args.any { node -> + node is ResultNode.Primitive && + node.value.contains(searchQuery, ignoreCase = true) + } + levelMatch && queryMatch + } + + is LogEntry.EvalInput -> + searchQuery.isBlank() || entry.code.contains(searchQuery, ignoreCase = true) + + is LogEntry.EvalResult.Parsed -> true + is LogEntry.EvalResult.Error -> + searchQuery.isBlank() || entry.message.contains(searchQuery, ignoreCase = true) + } + } + } + + val errorCount = remember(richLogs.size) { + richLogs.count { it.level == ConsoleMessage.MessageLevel.ERROR } + } + val warnCount = remember(richLogs.size) { + richLogs.count { it.level == ConsoleMessage.MessageLevel.WARNING } + } + + LaunchedEffect(filtered.size) { + if (filtered.isNotEmpty()) listState.animateScrollToItem(filtered.size - 1) + } + + Column(modifier = Modifier.fillMaxSize()) { + ConsoleToolbar( + searchQuery = searchQuery, + onSearchChange = { searchQuery = it }, + filterLevel = filterLevel, + onFilterChange = { filterLevel = it }, + errorCount = errorCount, + warnCount = warnCount, + onClear = { + webui.console.clear() + evalEntries.clear() + } + ) + + HorizontalDivider(thickness = 0.5.dp, color = colors.border) + + if (filtered.isEmpty()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = if (allEntries.isEmpty()) "No console output." + else "No results for current filter.", + style = MMRLXTheme.typography.bodySmall, + color = colors.foreground + ) + } + } else { + LazyColumn(state = listState, modifier = Modifier.weight(1f)) { + itemsIndexed(filtered) { _, entry -> + when (entry) { + is LogEntry.WebMessage -> ConsoleRow(entry = entry.entry) + is LogEntry.EvalInput -> EvalInputRow(code = entry.code) + is LogEntry.EvalResult.Error -> EvalErrorRow(message = entry.message) + is LogEntry.EvalResult.Parsed -> EvalTreeRow(root = entry.root) + } + HorizontalDivider( + thickness = 0.3.dp, + color = colors.border.copy(alpha = 0.5f) + ) + } + } + } + + HorizontalDivider(thickness = 0.5.dp, color = colors.border) + + JsInputBar( + value = jsInput, + onValueChange = { jsInput = it }, + onRun = { + val code = jsInput.trim() + if (code.isNotEmpty()) { + submitJs(code, webui, evalEntries) + jsInput = "" + } + } + ) + } +} + +private fun submitJs(code: String, webui: WebUI, evalEntries: MutableList) { + val inputTime = System.currentTimeMillis() + evalEntries.add(LogEntry.EvalInput(code, timestamp = inputTime)) + + webui.runJs(wrapConsoleEvalResult((code))) { raw -> + val resultTime = System.currentTimeMillis() + if (raw == null || raw == "null") { + evalEntries.add( + LogEntry.EvalResult.Parsed( + root = ResultNode.Primitive(null, "null", PrimitiveKind.NULL_UNDEFINED, 0), + timestamp = resultTime + ) + ) + return@runJs + } + try { + val outer = JSONObject("{\"v\":$raw}") + val inner = JSONObject(outer.getString("v")) + val ok = inner.getBoolean("ok") + if (!ok) { + evalEntries.add( + LogEntry.EvalResult.Error( + message = inner.getString("value"), + timestamp = resultTime + ) + ) + return@runJs + } + val tree = ResultNode(key = null, depth = 0).parseJsonObject(inner.getJSONObject("value")) + evalEntries.add(LogEntry.EvalResult.Parsed(root = tree, timestamp = resultTime)) + } catch (e: Exception) { + evalEntries.add( + LogEntry.EvalResult.Error( + message = "Parse error: ${e.message}", + timestamp = resultTime + ) + ) + } + } +} + +private fun escapeJsString(code: String): String { + val escaped = code + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + return "\"$escaped\"" +} + +@Composable +private fun ConsoleRow(entry: ConsoleEntry) { + val colors = consoleColors() + val rowStyle = when (entry.level) { + ConsoleMessage.MessageLevel.ERROR -> ConsoleRowStyle( + colors.error.second, colors.error.first, + colors.error.first, R.drawable.exclamation_circle + ) + + ConsoleMessage.MessageLevel.WARNING -> ConsoleRowStyle( + colors.warn.second, colors.warn.first, + colors.warn.first, R.drawable.alert_triangle_filled + ) + + ConsoleMessage.MessageLevel.TIP -> ConsoleRowStyle( + colors.tip.second, colors.tip.first, + colors.tip.first, R.drawable.bulb + ) + + ConsoleMessage.MessageLevel.DEBUG -> ConsoleRowStyle( + colors.debug.second, colors.debug.first, + colors.debug.first, R.drawable.bug + ) + + else -> ConsoleRowStyle( + Color.Transparent, MMRLXTheme.colors.cardForeground, + Color.Transparent, null + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(rowStyle.bg) + .drawLeftBorder(rowStyle.borderColor, 2.dp) + .padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top + ) { + rowStyle.icon?.let { + Icon( + painter = painterResource(it), + contentDescription = entry.level.name, + modifier = Modifier + .size(14.dp) + .padding(top = 2.dp), + tint = rowStyle.textColor.copy(alpha = 0.8f) + ) + } + + Column(modifier = Modifier.weight(1f)) { + entry.args.forEach { node -> + when (node) { + is ResultNode.Primitive -> { + Text( + text = node.value, + style = MMRLXTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = rowStyle.textColor, + softWrap = true + ) + } + + is ResultNode.Expandable -> { + EvalTreeRow(root = node) + } + } + } + + if (entry.source.isNotBlank()) { + Text( + text = "${entry.source.substringAfterLast("/")}:${entry.line}", + style = MMRLXTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace + ), + color = rowStyle.textColor.copy(alpha = 0.45f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun EvalInputRow(code: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MMRLXTheme.colors.card.copy(alpha = 0.06f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = ">", + style = MMRLXTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = MMRLXTheme.colors.cardForeground + ) + Text( + text = code, + style = MMRLXTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = MMRLXTheme.colors.mutedForeground, + softWrap = true + ) + } +} + +@Composable +private fun EvalErrorRow(message: String) { + val colors = consoleColors() + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.error.second) + .drawLeftBorder(colors.error.first, 2.dp) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(R.drawable.exclamation_circle), + contentDescription = null, + modifier = Modifier + .size(12.dp) + .padding(top = 2.dp), + tint = colors.error.second.copy(alpha = 0.7f) + ) + Text( + text = "✗ $message", + style = MMRLXTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + lineHeight = 16.sp + ), + color = colors.error.first, + softWrap = true + ) + } +} + +@Composable +internal fun EvalTreeRow(root: ResultNode) { + val collapsedIds = remember { mutableStateListOf() } + + val rows = remember(root, collapsedIds.toList()) { + buildList { flattenNode(root, this, collapsedIds) } + } + + Column(modifier = Modifier.fillMaxWidth()) { + rows.forEach { row -> + EvalNodeRow( + node = row, + onToggle = { id -> + if (collapsedIds.contains(id)) collapsedIds.remove(id) + else collapsedIds.add(id) + } + ) + } + } +} + +private fun flattenNode( + node: ResultNode, + out: MutableList, + collapsedIds: List, +) { + when (node) { + is ResultNode.Primitive -> out.add(FlatRow.PrimitiveRow(node)) + is ResultNode.Expandable -> { + val collapsed = collapsedIds.contains(node.id) + out.add(FlatRow.OpenRow(node, collapsed)) + if (!collapsed) { + node.children.forEach { flattenNode(it, out, collapsedIds) } + out.add(FlatRow.CloseRow(node.closeToken, node.depth, node.id)) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun EvalNodeRow( + node: FlatRow, + onToggle: (String) -> Unit, +) { + val indentPerLevel = 12.dp + val stringColor = Color(0xFF22C55E) + val numberColor = Color(0xFF60A5FA) + val boolColor = Color(0xFFF59E0B) + val nullColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + val functionColor = MaterialTheme.colorScheme.tertiary + val keyColor = MaterialTheme.colorScheme.onSurfaceVariant + val punctColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + val labelColor = MaterialTheme.colorScheme.primary + + when (node) { + is FlatRow.PrimitiveRow -> { + val p = node.node + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = indentPerLevel * p.depth + 4.dp + 14.dp, + end = 8.dp, + top = 2.dp, + bottom = 2.dp + ) + ) { + Text( + text = buildAnnotatedString { + if (p.key != null) { + withStyle( + SpanStyle( + color = keyColor, + fontFamily = FontFamily.Monospace + ) + ) { + append(p.key) + } + withStyle(SpanStyle(color = punctColor)) { append(": ") } + } + val valueColor = when (p.kind) { + PrimitiveKind.STRING -> stringColor + PrimitiveKind.NUMBER -> numberColor + PrimitiveKind.BOOLEAN -> boolColor + PrimitiveKind.NULL_UNDEFINED -> nullColor + PrimitiveKind.FUNCTION -> functionColor + PrimitiveKind.OTHER -> MaterialTheme.colorScheme.onSurface + } + val displayValue = if (p.kind == PrimitiveKind.STRING && p.key != null) + "\"${p.value}\"" else p.value + withStyle( + SpanStyle( + color = valueColor, + fontFamily = FontFamily.Monospace + ) + ) { + append(displayValue) + } + }, + style = MMRLXTheme.typography.bodySmall.copy(fontSize = 11.sp), + softWrap = true + ) + } + } + + is FlatRow.OpenRow -> { + val e = node.node + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { onToggle(e.id) }, + onLongClick = {} + ) + .padding( + start = indentPerLevel * e.depth + 4.dp, + end = 8.dp, + top = 2.dp, + bottom = 2.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painter = painterResource( + if (node.isCollapsed) R.drawable.chevron_right + else R.drawable.chevron_down + ), + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + Text( + text = buildAnnotatedString { + if (e.key != null) { + withStyle( + SpanStyle( + color = keyColor, + fontFamily = FontFamily.Monospace + ) + ) { + append(e.key) + } + withStyle(SpanStyle(color = punctColor)) { append(": ") } + } + if (e.label.isNotEmpty()) { + withStyle( + SpanStyle( + color = labelColor, + fontFamily = FontFamily.Monospace + ) + ) { + append(e.label) + } + append(" ") + } + val openToken = if (e.closeToken == "]") "[" else "{" + withStyle(SpanStyle(color = punctColor)) { append(openToken) } + if (node.isCollapsed) { + withStyle(SpanStyle(color = punctColor)) { append("…") } + withStyle(SpanStyle(color = punctColor)) { append(e.closeToken) } + } + }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + softWrap = false + ) + } + } + + is FlatRow.CloseRow -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = indentPerLevel * node.depth + 4.dp + 14.dp, + end = 8.dp, + top = 2.dp, + bottom = 2.dp + ) + ) { + Text( + text = node.token, + style = MMRLXTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = punctColor + ) + ) + } + } + } +} + +@Composable +private fun ConsoleToolbar( + searchQuery: String, + onSearchChange: (String) -> Unit, + filterLevel: ConsoleMessage.MessageLevel?, + onFilterChange: (ConsoleMessage.MessageLevel?) -> Unit, + errorCount: Int, + warnCount: Int, + onClear: () -> Unit, +) { + val colors = MMRLXTheme.colors + val consoleColors = consoleColors() + + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.card) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton(onClick = onClear, modifier = Modifier.size(28.dp)) { + Icon( + painter = painterResource(R.drawable.x), + contentDescription = "Clear console", + modifier = Modifier.size(16.dp), + tint = colors.primary + ) + } + + VerticalDivider(modifier = Modifier.height(16.dp), color = colors.border) + + BasicTextField( + value = searchQuery, + onValueChange = onSearchChange, + modifier = Modifier + .weight(1f) + .background( + colors.muted, + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + singleLine = true, + textStyle = MMRLXTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = colors.mutedForeground + ), + decorationBox = { inner -> + if (searchQuery.isEmpty()) { + Text( + "Filter", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = colors.mutedForeground + ) + } + inner() + } + ) + + VerticalDivider(modifier = Modifier.height(16.dp), color = colors.border) + + LevelFilterChip( + iconRes = R.drawable.exclamation_circle, + label = if (errorCount > 0) "$errorCount" else null, + selected = filterLevel == ConsoleMessage.MessageLevel.ERROR, + selectedFgColor = consoleColors.error.first, + selectedBgColor = consoleColors.error.second, + onClick = { + onFilterChange( + if (filterLevel == ConsoleMessage.MessageLevel.ERROR) null + else ConsoleMessage.MessageLevel.ERROR + ) + } + ) + LevelFilterChip( + iconRes = R.drawable.alert_triangle_filled, + label = if (warnCount > 0) "$warnCount" else null, + selected = filterLevel == ConsoleMessage.MessageLevel.WARNING, + selectedFgColor = consoleColors.warn.first, + selectedBgColor = consoleColors.warn.second, + onClick = { + onFilterChange( + if (filterLevel == ConsoleMessage.MessageLevel.WARNING) null + else ConsoleMessage.MessageLevel.WARNING + ) + } + ) + LevelFilterChip( + iconRes = R.drawable.info_circle, + label = null, + selected = filterLevel == ConsoleMessage.MessageLevel.LOG, + selectedFgColor = colors.mutedForeground, + selectedBgColor = colors.muted, + onClick = { + onFilterChange( + if (filterLevel == ConsoleMessage.MessageLevel.LOG) null + else ConsoleMessage.MessageLevel.LOG + ) + } + ) + LevelFilterChip( + iconRes = R.drawable.bulb, + label = null, + selected = filterLevel == ConsoleMessage.MessageLevel.TIP, + selectedFgColor = consoleColors.tip.first, + selectedBgColor = consoleColors.tip.second, + onClick = { + onFilterChange( + if (filterLevel == ConsoleMessage.MessageLevel.TIP) null + else ConsoleMessage.MessageLevel.TIP + ) + } + ) + LevelFilterChip( + iconRes = R.drawable.bug, + label = null, + selected = filterLevel == ConsoleMessage.MessageLevel.DEBUG, + selectedFgColor = consoleColors.debug.first, + selectedBgColor = consoleColors.debug.second, + onClick = { + onFilterChange( + if (filterLevel == ConsoleMessage.MessageLevel.DEBUG) null + else ConsoleMessage.MessageLevel.DEBUG + ) + } + ) + } +} + +@Composable +private fun LevelFilterChip( + @DrawableRes iconRes: Int, + label: String?, + selected: Boolean, + selectedFgColor: Color, + selectedBgColor: Color, + onClick: () -> Unit, +) { + val bg = if (selected) selectedBgColor else Color.Transparent + val tint = if (selected) selectedFgColor else MMRLXTheme.colors.cardForeground + Row( + modifier = Modifier + .background(bg, shape = MMRLXTheme.shapes.small) + .padding(horizontal = 2.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + CompositionLocalProvider(LocalContentColor provides tint) { + IconButton(onClick = onClick, modifier = Modifier.size(22.dp)) { + Icon( + painter = painterResource(iconRes), + tint = tint, + modifier = Modifier.size(13.dp) + ) + } + } + if (label != null) { + Text( + text = label, + style = MMRLXTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = tint, + modifier = Modifier.padding(end = 4.dp) + ) + } + } +} + +@Composable +private fun JsInputBar( + value: String, + onValueChange: (String) -> Unit, + onRun: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MMRLXTheme.colors.card) + .navigationBarsPadding() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = ">", + style = MMRLXTheme.typography.bodySmall.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ), + color = MMRLXTheme.colors.mutedForeground + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .weight(1f) + .padding(vertical = 2.dp), + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Unspecified, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { onRun() }), + textStyle = MMRLXTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = MMRLXTheme.colors.cardForeground + ), + decorationBox = { inner -> + if (value.isEmpty()) { + Text( + text = "Evaluate JavaScript...", + style = MMRLXTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = MMRLXTheme.colors.cardForeground.copy(alpha = 0.6f) + ) + } + inner() + } + ) + IconButton( + onClick = onRun, + modifier = Modifier.size(26.dp), + enabled = value.trim().isNotEmpty() + ) { + Icon( + painter = painterResource(R.drawable.arrow_badge_right), + contentDescription = "Run", + modifier = Modifier.size(14.dp), + tint = if (value.trim().isNotEmpty()) + MMRLXTheme.colors.primary + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + } + } +} + +private data class ConsoleRowStyle( + val bg: Color, + val textColor: Color, + val borderColor: Color, + @get:DrawableRes val icon: Int?, +) + +private data class ConsoleColorTokens( + val error: Pair, + val warn: Pair, + val tip: Pair, + val debug: Pair, +) + +@Composable +private fun consoleColors() = ConsoleColorTokens( + error = MMRLXTheme.colors.badgeErrorForeground to MMRLXTheme.colors.badgeErrorBackground, + warn = MMRLXTheme.colors.badgeWarnForeground to MMRLXTheme.colors.badgeWarnBackground, + tip = MMRLXTheme.colors.badgeInfoForeground to MMRLXTheme.colors.badgeInfoBackground, + debug = MMRLXTheme.colors.badgeDebugForeground to MMRLXTheme.colors.badgeDebugBackground, +) + +private fun Modifier.drawLeftBorder(color: Color, width: Dp): Modifier = + this.drawWithContent { + drawContent() + if (color != Color.Transparent) { + drawLine( + color = color, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = width.toPx() + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/DOMTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/DOMTab.kt new file mode 100644 index 00000000..3d2a4c42 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/DOMTab.kt @@ -0,0 +1,793 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools.tabs + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.ui.webui.devtools.LocalWebUI +import dev.mmrlx.compose.ui.theme.MMRLXTheme +import org.json.JSONObject +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.TextNode as JsoupTextNode + +private data class DomNode( + val id: Int, + val jsoupId: String, + val parentId: Int?, + val tag: String, + val attributes: List>, + val depth: Int, + val hasChildren: Boolean, +) + +private sealed class RenderEntry { + abstract val stableId: String + data class Open(val node: DomNode) : RenderEntry() { + override val stableId = "open:${node.id}" + } + data class Close(val nodeId: Int, val tag: String, val depth: Int) : RenderEntry() { + override val stableId = "close:$nodeId:$depth" + } + data class TextNode(val text: String, val depth: Int, val parentId: Int, val index: Int) : RenderEntry() { + override val stableId = "text:$parentId:$depth:$index" + } +} + +private sealed class RawEntry { + data class Element(val node: DomNode) : RawEntry() + data class Text(val text: String, val depth: Int, val parentId: Int?) : RawEntry() +} + +private sealed class DomAction { + data class EditAttribute(val node: DomNode) : DomAction() + data class AddAttribute(val node: DomNode) : DomAction() + data class RemoveAttribute(val node: DomNode, val attrName: String) : DomAction() + data class EditText(val node: DomNode) : DomAction() + data class AddChild(val node: DomNode) : DomAction() + data class RemoveNode(val node: DomNode) : DomAction() + data class EditStyle(val node: DomNode) : DomAction() + data class AddClass(val node: DomNode) : DomAction() + data class RemoveClass(val node: DomNode) : DomAction() +} + +private val STAMP_AND_DUMP_JS = """ +(function () { + var id = 0; + (function stamp(el) { + el.setAttribute('data-devtools-id', id++); + for (var i = 0; i < el.children.length; i++) stamp(el.children[i]); + })(document.documentElement); + return document.documentElement.outerHTML; +})() +""".trimIndent() + +private fun jsNodeById(jsoupId: String) = + "document.querySelector('[data-devtools-id=\"$jsoupId\"]')" + +private fun parseElement( + element: Element, + parentId: Int?, + depth: Int, + nodes: MutableList, + rawEntries: MutableList, + counter: IntArray, +) { + val myId = counter[0]++ + + val jsoupId = element.attr("data-devtools-id").ifEmpty { myId.toString() } + + val attrs = element.attributes() + .map { it.key to it.value } + .filter { it.first != "data-devtools-id" } + + val hasChildren = element.childNodes().any { child -> + when (child) { + is Element -> true + is JsoupTextNode -> child.text().isNotBlank() + else -> false + } + } + + val node = DomNode( + id = myId, + jsoupId = jsoupId, + parentId = parentId, + tag = element.tagName().lowercase(), + attributes = attrs, + depth = depth, + hasChildren = hasChildren, + ) + nodes.add(node) + rawEntries.add(RawEntry.Element(node)) + + for (child in element.childNodes()) { + when (child) { + is Element -> parseElement(child, myId, depth + 1, nodes, rawEntries, counter) + is JsoupTextNode -> { + val trimmed = child.text().trim() + if (trimmed.isNotEmpty()) { + rawEntries.add( + RawEntry.Text( + text = trimmed.take(120), + depth = depth + 1, + parentId = myId, + ) + ) + } + } + } + } +} + +private fun unwrapJsString(raw: String): String { + if (raw == "null") return "" + return try { + JSONObject("""{"v":$raw}""").getString("v") + } catch (_: Exception) { + raw + } +} + +@Composable +fun DomTab() { + val webui = LocalWebUI.current + + var nodes by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val collapsedIds = remember { mutableStateListOf() } + var rawEntries by remember { mutableStateOf>(emptyList()) } + + var contextNode by remember { mutableStateOf(null) } + var activeAction by remember { mutableStateOf(null) } + + fun runJs(script: String) = webui.runJs(script) + + fun fetchDomFull() { + isLoading = true + error = null + nodes = emptyList() + rawEntries = emptyList() + + webui.runJs(STAMP_AND_DUMP_JS) { raw -> + isLoading = false + if (raw == null || raw == "null") { + error = "Failed to evaluate DOM." + return@runJs + } + + val html = unwrapJsString(raw) + if (html.isBlank()) { + error = "Empty HTML returned." + return@runJs + } + + try { + val parsedNodes = mutableListOf() + val parsedRaw = mutableListOf() + val counter = intArrayOf(0) + + val doc = Jsoup.parse(html) + val root = doc.child(0) ?: doc.children().firstOrNull() + + if (root == null) { + error = "No root element found." + return@runJs + } + + parseElement( + element = root, + parentId = null, + depth = 0, + nodes = parsedNodes, + rawEntries = parsedRaw, + counter = counter, + ) + + nodes = parsedNodes + rawEntries = parsedRaw + } catch (e: Exception) { + error = "Parse error: ${e.message}" + } + } + } + + LaunchedEffect(Unit) { fetchDomFull() } + + val nodeMap = remember(nodes) { nodes.associateBy { it.id } } + val renderList = remember(rawEntries, collapsedIds.toList()) { + buildRenderList(rawEntries, nodeMap, collapsedIds) + } + + activeAction?.let { action -> + when (action) { + is DomAction.EditAttribute -> EditAttributeDialog( + node = action.node, + onDismiss = { activeAction = null }, + onConfirm = { attr, value -> + runJs("${jsNodeById(action.node.jsoupId)}.setAttribute('$attr','${value.replace("'", "\\'")}');") + activeAction = null; fetchDomFull() + } + ) + is DomAction.AddAttribute -> AddAttributeDialog( + onDismiss = { activeAction = null }, + onConfirm = { attr, value -> + runJs("${jsNodeById(action.node.jsoupId)}.setAttribute('$attr','${value.replace("'", "\\'")}');") + activeAction = null; fetchDomFull() + } + ) + is DomAction.EditText -> EditTextDialog( + node = action.node, + onDismiss = { activeAction = null }, + onConfirm = { newText -> + runJs(""" + (function(){ + var el=${jsNodeById(action.node.jsoupId)}; + var tn=Array.from(el.childNodes).find(function(n){return n.nodeType===3;}); + if(tn)tn.nodeValue='${newText.replace("'","\\'")}'; + else el.insertBefore(document.createTextNode('${newText.replace("'","\\'")}'),el.firstChild); + })() + """.trimIndent()) + activeAction = null; fetchDomFull() + } + ) + is DomAction.AddChild -> AddChildDialog( + onDismiss = { activeAction = null }, + onConfirm = { tag, text -> + runJs(""" + (function(){ + var el=${jsNodeById(action.node.jsoupId)}; + var child=document.createElement('$tag'); + if('$text'.length>0)child.textContent='${text.replace("'","\\'")}'; + el.appendChild(child); + })() + """.trimIndent()) + activeAction = null; fetchDomFull() + } + ) + is DomAction.RemoveNode -> ConfirmDialog( + title = "Remove <${action.node.tag}>?", + message = "This will remove the element and all its children from the DOM.", + onDismiss = { activeAction = null }, + onConfirm = { + runJs("${jsNodeById(action.node.jsoupId)}.remove();") + activeAction = null; fetchDomFull() + } + ) + is DomAction.EditStyle -> EditStyleDialog( + node = action.node, + onDismiss = { activeAction = null }, + onConfirm = { css -> + runJs("${jsNodeById(action.node.jsoupId)}.style.cssText='${css.replace("'","\\'")}';") + activeAction = null; fetchDomFull() + } + ) + is DomAction.AddClass -> AddClassDialog( + onDismiss = { activeAction = null }, + onConfirm = { cls -> + runJs("${jsNodeById(action.node.jsoupId)}.classList.add('${cls.replace("'","\\'")}');") + activeAction = null; fetchDomFull() + } + ) + is DomAction.RemoveClass -> RemoveClassDialog( + node = action.node, + onDismiss = { activeAction = null }, + onConfirm = { cls -> + runJs("${jsNodeById(action.node.jsoupId)}.classList.remove('${cls.replace("'","\\'")}');") + activeAction = null; fetchDomFull() + } + ) + else -> {} + } + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MMRLXTheme.colors.card) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton(onClick = { fetchDomFull() }, modifier = Modifier.size(28.dp)) { + Icon( + painter = painterResource(com.dergoogler.mmrl.webui.R.drawable.refresh), + contentDescription = "Refresh DOM", + modifier = Modifier.size(16.dp), + tint = MMRLXTheme.colors.primary + ) + } + Text( + text = "${nodes.size} nodes", + style = MMRLXTheme.typography.labelSmall.copy(fontSize = 10.sp, fontFamily = FontFamily.Monospace), + color = MMRLXTheme.colors.cardForeground + ) + } + + HorizontalDivider(thickness = 0.5.dp, color = MMRLXTheme.colors.border) + + when { + isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } + error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(error!!, style = MMRLXTheme.typography.bodySmall, color = MMRLXTheme.colors.destructive) + } + renderList.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No DOM nodes found.", style = MMRLXTheme.typography.bodySmall, color = MMRLXTheme.colors.foreground) + } + else -> LazyColumn(modifier = Modifier.fillMaxSize()) { + items(renderList, key = { it.stableId }) { entry -> + RenderEntryRow( + entry = entry, + collapsedIds = collapsedIds, + onToggle = { id -> + if (collapsedIds.contains(id)) collapsedIds.remove(id) + else collapsedIds.add(id) + }, + onLongPress = { node -> contextNode = node } + ) + HorizontalDivider(thickness = 0.3.dp, color = MMRLXTheme.colors.border.copy(alpha = 0.3f)) + } + } + } + } + + contextNode?.let { node -> + DomContextMenu( + node = node, + onDismiss = { contextNode = null }, + onAction = { action -> contextNode = null; activeAction = action } + ) + } +} + +private fun buildRenderList( + raw: List, + nodeMap: Map, + collapsedIds: List, +): List { + if (raw.isEmpty()) return emptyList() + val result = mutableListOf() + val stack = ArrayDeque() + val textCounters = mutableMapOf() + + for (entry in raw) { + val currentDepth = when (entry) { + is RawEntry.Element -> entry.node.depth + is RawEntry.Text -> entry.depth + } + while (stack.isNotEmpty() && stack.last().depth >= currentDepth) { + val closing = stack.removeLast() + if (!collapsedIds.contains(closing.id)) + result.add(RenderEntry.Close(closing.id, closing.tag, closing.depth)) + } + when (entry) { + is RawEntry.Element -> { + val node = entry.node + if (isAncestorCollapsed(node.parentId, nodeMap, collapsedIds)) continue + result.add(RenderEntry.Open(node)) + if (node.hasChildren && !collapsedIds.contains(node.id)) stack.addLast(node) + } + is RawEntry.Text -> { + if (entry.parentId != null && collapsedIds.contains(entry.parentId)) continue + if (entry.parentId != null && isAncestorCollapsed(entry.parentId, nodeMap, collapsedIds)) continue + val pid = entry.parentId ?: -1 + val counterKey = (pid.toLong() shl 32) or (entry.depth.toLong() and 0xFFFFFFFFL) + val idx = textCounters.getOrDefault(counterKey, 0) + textCounters[counterKey] = idx + 1 + result.add(RenderEntry.TextNode(entry.text, entry.depth, pid, idx)) + } + } + } + while (stack.isNotEmpty()) { + val closing = stack.removeLast() + if (!collapsedIds.contains(closing.id)) + result.add(RenderEntry.Close(closing.id, closing.tag, closing.depth)) + } + return result +} + +private fun isAncestorCollapsed(parentId: Int?, nodeMap: Map, collapsedIds: List): Boolean { + var id = parentId + while (id != null) { + if (collapsedIds.contains(id)) return true + id = nodeMap[id]?.parentId + } + return false +} + +@Composable +private fun DomContextMenu(node: DomNode, onDismiss: () -> Unit, onAction: (DomAction) -> Unit) { + val hasClass = node.attributes.any { it.first == "class" && it.second.isNotBlank() } + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Column(modifier = Modifier.width(260.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Text( + text = "<${node.tag}>", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.primary + ) + } + HorizontalDivider() + val items = buildList { + add("Edit attribute" to DomAction.EditAttribute(node)) + add("Add attribute" to DomAction.AddAttribute(node)) + node.attributes.forEach { (name, _) -> + add("Remove attr: $name" to DomAction.RemoveAttribute(node, name)) + } + add("Edit inline style" to DomAction.EditStyle(node)) + add("Add class" to DomAction.AddClass(node)) + if (hasClass) add("Remove class" to DomAction.RemoveClass(node)) + add("Edit text content" to DomAction.EditText(node)) + add("Append child element" to DomAction.AddChild(node)) + add("Remove node" to DomAction.RemoveNode(node)) + } + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + items.forEachIndexed { index, (label, action) -> + val isDestructive = action is DomAction.RemoveNode || action is DomAction.RemoveAttribute + DropdownMenuItem( + text = { + Text( + text = label, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = if (isDestructive) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { onAction(action) } + ) + if (index < items.lastIndex) + HorizontalDivider(thickness = 0.3.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + } + } + } + } +} + +@Composable +private fun EditAttributeDialog(node: DomNode, onDismiss: () -> Unit, onConfirm: (attr: String, value: String) -> Unit) { + var selectedAttr by remember { mutableStateOf(node.attributes.firstOrNull()?.first ?: "") } + var attrValue by remember { mutableStateOf(node.attributes.firstOrNull { it.first == selectedAttr }?.second ?: "") } + DevToolsDialog(title = "Edit Attribute", onDismiss = onDismiss, onConfirm = { onConfirm(selectedAttr, attrValue) }) { + if (node.attributes.isEmpty()) { + Text("No attributes on this element.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + Text("Attribute", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + node.attributes.forEach { (name, _) -> + val selected = name == selectedAttr + Text( + text = name, + modifier = Modifier + .fillMaxWidth() + .background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, RoundedCornerShape(4.dp)) + .combinedClickable(onClick = { + selectedAttr = name + attrValue = node.attributes.first { it.first == name }.second + }) + .padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp), + color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + ) + } + } + Spacer(Modifier.height(8.dp)) + DevToolsTextField(label = "Value", value = attrValue, onValueChange = { attrValue = it }) + } + } +} + +@Composable +private fun AddAttributeDialog(onDismiss: () -> Unit, onConfirm: (attr: String, value: String) -> Unit) { + var attr by remember { mutableStateOf("") } + var value by remember { mutableStateOf("") } + DevToolsDialog(title = "Add Attribute", onDismiss = onDismiss, onConfirm = { onConfirm(attr, value) }) { + DevToolsTextField(label = "Attribute name", value = attr, onValueChange = { attr = it }) + Spacer(Modifier.height(8.dp)) + DevToolsTextField(label = "Value", value = value, onValueChange = { value = it }) + } +} + +@Composable +private fun EditTextDialog(node: DomNode, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var text by remember { mutableStateOf("") } + DevToolsDialog(title = "Edit Text Content", onDismiss = onDismiss, onConfirm = { onConfirm(text) }) { + DevToolsTextField(label = "Text", value = text, onValueChange = { text = it }, singleLine = false) + } +} + +@Composable +private fun AddChildDialog(onDismiss: () -> Unit, onConfirm: (tag: String, text: String) -> Unit) { + var tag by remember { mutableStateOf("div") } + var text by remember { mutableStateOf("") } + DevToolsDialog(title = "Append Child", onDismiss = onDismiss, onConfirm = { onConfirm(tag, text) }) { + DevToolsTextField(label = "Tag name", value = tag, onValueChange = { tag = it }) + Spacer(Modifier.height(8.dp)) + DevToolsTextField(label = "Text content (optional)", value = text, onValueChange = { text = it }) + } +} + +@Composable +private fun EditStyleDialog(node: DomNode, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + val existing = node.attributes.firstOrNull { it.first == "style" }?.second ?: "" + var css by remember { mutableStateOf(existing) } + DevToolsDialog(title = "Edit Inline Style", onDismiss = onDismiss, onConfirm = { onConfirm(css) }) { + DevToolsTextField(label = "CSS (e.g. color:red; font-size:14px)", value = css, onValueChange = { css = it }, singleLine = false) + } +} + +@Composable +private fun AddClassDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var cls by remember { mutableStateOf("") } + DevToolsDialog(title = "Add Class", onDismiss = onDismiss, onConfirm = { onConfirm(cls) }) { + DevToolsTextField(label = "Class name", value = cls, onValueChange = { cls = it }) + } +} + +@Composable +private fun RemoveClassDialog(node: DomNode, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + val classes = remember { + node.attributes.firstOrNull { it.first == "class" }?.second + ?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() + } + var selected by remember { mutableStateOf(classes.firstOrNull() ?: "") } + DevToolsDialog(title = "Remove Class", onDismiss = onDismiss, onConfirm = { onConfirm(selected) }) { + if (classes.isEmpty()) { + Text("No classes on this element.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + classes.forEach { cls -> + Text( + text = cls, + modifier = Modifier + .fillMaxWidth() + .background(if (cls == selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, RoundedCornerShape(4.dp)) + .combinedClickable(onClick = { selected = cls }) + .padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp), + color = if (cls == selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} + +@Composable +private fun ConfirmDialog(title: String, message: String, onDismiss: () -> Unit, onConfirm: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Card(shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onConfirm) { Text("Remove", color = MaterialTheme.colorScheme.error) } + } + } + } + } +} + +@Composable +private fun DevToolsDialog( + title: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Column( + modifier = Modifier.padding(20.dp).width(280.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(title, style = MaterialTheme.typography.titleSmall) + HorizontalDivider() + content() + HorizontalDivider() + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onConfirm) { Text("Apply") } + } + } + } + } +} + +@Composable +private fun DevToolsTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + singleLine: Boolean = true, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = singleLine, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(6.dp)) + .padding(horizontal = 10.dp, vertical = 7.dp) + .then(if (!singleLine) Modifier.height(80.dp) else Modifier), + textStyle = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface + ) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RenderEntryRow( + entry: RenderEntry, + collapsedIds: List, + onToggle: (Int) -> Unit, + onLongPress: (DomNode) -> Unit, +) { + val darkMode = MMRLXTheme.colors.isDark + val indentPerLevel = 12.dp + + val tagColor = Color(if (darkMode) 0xFF7EE787 else 0xFF166534) + val attrNameColor = Color(if (darkMode) 0xFFFFA657 else 0xFF9A6700) + val attrValueColor = Color(if (darkMode) 0xFFA5D6FF else 0xFF1155A3) + val punctColor = Color(if (darkMode) 0xFFC9D1D9 else 0xFF374151) + val textNodeColor = Color(if (darkMode) 0xFFA5D6FF else 0xFF1155A3) + + when (entry) { + is RenderEntry.Open -> { + val node = entry.node + val isCollapsed = collapsedIds.contains(node.id) + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { if (node.hasChildren) onToggle(node.id) }, + onLongClick = { onLongPress(node) } + ) + .padding(start = indentPerLevel * node.depth + 4.dp, end = 8.dp, top = 3.dp, bottom = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (node.hasChildren) { + Icon( + painter = painterResource(if (isCollapsed) R.drawable.chevron_right else R.drawable.chevron_down), + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = punctColor + ) + } else { + Spacer(modifier = Modifier.size(10.dp)) + } + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = punctColor)) { append("<") } + withStyle(SpanStyle(color = tagColor, fontFamily = FontFamily.Monospace)) { append(node.tag) } + node.attributes.forEach { (name, value) -> + append(" ") + withStyle(SpanStyle(color = attrNameColor, fontFamily = FontFamily.Monospace)) { append(name) } + withStyle(SpanStyle(color = punctColor)) { append("=\"") } + withStyle(SpanStyle(color = attrValueColor, fontFamily = FontFamily.Monospace)) { + append(if (value.length > 60) value.take(60) + "…" else value) + } + withStyle(SpanStyle(color = punctColor)) { append("\"") } + } + when { + !node.hasChildren -> withStyle(SpanStyle(color = punctColor)) { append("/>") } + isCollapsed -> { + withStyle(SpanStyle(color = punctColor)) { append(">…") } + } + else -> withStyle(SpanStyle(color = punctColor)) { append(">") } + } + }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + softWrap = true + ) + } + } + is RenderEntry.Close -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = indentPerLevel * entry.depth + 4.dp + 14.dp, end = 8.dp, top = 3.dp, bottom = 3.dp) + ) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = punctColor)) { append("") } + }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp) + ) + } + } + is RenderEntry.TextNode -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = indentPerLevel * entry.depth + 4.dp + 14.dp, end = 8.dp, top = 2.dp, bottom = 2.dp) + ) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = textNodeColor, fontFamily = FontFamily.Monospace)) { + append(if (entry.text.length > 120) entry.text.take(120) + "…" else entry.text) + } + }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + softWrap = true + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/NetworkTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/NetworkTab.kt new file mode 100644 index 00000000..c468d693 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/NetworkTab.kt @@ -0,0 +1,343 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools.tabs + +import android.webkit.WebResourceRequest +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TabRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dergoogler.mmrl.ui.component.Tab +import com.dergoogler.mmrl.wx.R +import com.dergoogler.mmrl.wx.ui.webui.devtools.LocalWebUI +import com.dergoogler.mmrl.wx.ui.webui.devtools.tonalSurface +import dev.mmrlx.compose.ui.HorizontalDivider +import dev.mmrlx.compose.ui.Text +import dev.mmrlx.compose.ui.icon.Icon +import dev.mmrlx.compose.ui.icon.IconButton + +@Composable +fun NetworkTab() { + val webui = LocalWebUI.current + + val requests = webui.networkRequests + + val columns = listOf( + ColumnDef("Name", 220.dp), + ColumnDef("Method", 70.dp), + ColumnDef("Type", 100.dp), + ColumnDef("Initiator", 200.dp), + ColumnDef("URL", 300.dp), + ) + + val headerBg = MaterialTheme.colorScheme.tonalSurface + val rowEvenBg = MaterialTheme.colorScheme.surface + val rowOddBg = MaterialTheme.colorScheme.tonalSurface.copy(alpha = 0.3f) + val textColor = MaterialTheme.colorScheme.onSurface + val mutedColor = MaterialTheme.colorScheme.onSurfaceVariant + val dividerColor = MaterialTheme.colorScheme.outlineVariant + val selectedBg = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + + val scrollState = rememberScrollState() + var selectedRequest by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .horizontalScroll(scrollState) + .background(headerBg) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + columns.forEach { col -> + Text( + text = col.label, + modifier = Modifier + .width(col.width) + .padding(horizontal = 4.dp), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = mutedColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + HorizontalDivider(thickness = 0.5.dp, color = dividerColor) + + if (requests.all.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No network requests recorded.", + style = MaterialTheme.typography.bodySmall, + color = mutedColor + ) + } + } else { + // Split view when an item is selected + val listWeight = if (selectedRequest != null) 0.5f else 1f + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.weight(listWeight)) { + itemsIndexed(requests.all) { index, request -> + val url = request.url + val name = url.lastPathSegment ?: url.host ?: url.toString() + val method = request.method ?: "GET" + val type = guessType(url.toString()) + val initiator = url.host ?: "Other" + val fullUrl = url.toString() + val isSelected = selectedRequest == request + + Row( + modifier = Modifier + .horizontalScroll(scrollState) + .background( + when { + isSelected -> selectedBg + index % 2 == 0 -> rowEvenBg + else -> rowOddBg + } + ) + .clickable { + selectedRequest = if (isSelected) null else request + } + .padding(horizontal = 8.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + NetworkCell(name, columns[0].width, textColor) + NetworkCell(method, columns[1].width, mutedColor) + NetworkCell(type, columns[2].width, mutedColor) + NetworkCell(initiator, columns[3].width, mutedColor) + NetworkCell(fullUrl, columns[4].width, mutedColor) + } + + HorizontalDivider(thickness = 0.3.dp, color = dividerColor) + } + } + + // Inspector panel + if (selectedRequest != null) { + HorizontalDivider(thickness = 1.dp, color = dividerColor) + RequestInspector( + request = selectedRequest!!, + onClose = { selectedRequest = null }, + modifier = Modifier.weight(0.5f) + ) + } + } + } + } +} + +@Composable +private fun RequestInspector( + request: WebResourceRequest, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val mutedColor = MaterialTheme.colorScheme.onSurfaceVariant + val dividerColor = MaterialTheme.colorScheme.outlineVariant + val headerBg = MaterialTheme.colorScheme.surfaceVariant + + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = listOf("General", "Headers") + + Column(modifier = modifier.fillMaxWidth()) { + // Inspector top bar + Row( + modifier = Modifier + .fillMaxWidth() + .background(headerBg) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = request.url.lastPathSegment ?: request.url.toString(), + style = MaterialTheme.typography.labelMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onClose, modifier = Modifier.size(24.dp)) { + Icon( + painter = painterResource(R.drawable.x), + contentDescription = "Close inspector", + modifier = Modifier.size(16.dp), + tint = mutedColor + ) + } + } + + HorizontalDivider(thickness = 0.5.dp, color = dividerColor) + + // Tabs + TabRow( + selectedTabIndex = selectedTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.height(36.dp) + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + modifier = Modifier.height(36.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), + ) + } + } + } + + HorizontalDivider(thickness = 0.5.dp, color = dividerColor) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + when (selectedTab) { + 0 -> { + // General tab + val generalItems = listOf( + "Request URL" to request.url.toString(), + "Request Method" to (request.method ?: "GET"), + "Resource Type" to guessType(request.url.toString()), + "Is For Main Frame" to request.isForMainFrame.toString(), + "Has Gesture" to request.hasGesture().toString(), + ) + items(generalItems) { (key, value) -> + InspectorRow(key = key, value = value) + } + } + 1 -> { + // Headers tab + val headers = request.requestHeaders + if (headers.isEmpty()) { + item { + Text( + text = "No headers available.", + style = MaterialTheme.typography.bodySmall, + color = mutedColor + ) + } + } else { + items(headers.entries.toList()) { (key, value) -> + InspectorRow(key = key, value = value) + } + } + } + } + } + } +} + +@Composable +private fun InspectorRow(key: String, value: String) { + val dividerColor = MaterialTheme.colorScheme.outlineVariant + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "$key:", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(140.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + softWrap = true + ) + } + HorizontalDivider(thickness = 0.3.dp, color = dividerColor) + } +} + +@Composable +private fun NetworkCell(text: String, width: Dp, color: Color) { + Text( + text = text, + modifier = Modifier + .width(width) + .padding(horizontal = 4.dp), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ), + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) +} + +private fun guessType(url: String): String = when { + url.endsWith(".js") -> "script" + url.endsWith(".css") -> "stylesheet" + url.endsWith(".html") || url.endsWith(".htm") -> "document" + url.endsWith(".png") || url.endsWith(".jpg") || + url.endsWith(".jpeg") || url.endsWith(".gif") || + url.endsWith(".webp") -> "image" + url.endsWith(".woff") || url.endsWith(".woff2") || + url.endsWith(".ttf") -> "font" + url.contains("/api/") || url.contains(".json") -> "fetch" + else -> "other" +} + +private data class ColumnDef(val label: String, val width: Dp) diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/SnippetsTab.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/SnippetsTab.kt new file mode 100644 index 00000000..28ddbee8 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/devtools/tabs/SnippetsTab.kt @@ -0,0 +1,100 @@ +package com.dergoogler.mmrl.wx.ui.webui.devtools.tabs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.dergoogler.mmrl.ext.toCssValue +import com.dergoogler.mmrl.wx.ui.webui.WebUIActivity +import com.dergoogler.mmrl.wx.ui.webui.devtools.LocalDevTools +import com.dergoogler.mmrl.wx.ui.webui.devtools.LocalWebUI +import dev.mmrlx.compose.ui.Badge +import dev.mmrlx.compose.ui.BadgeVariant +import dev.mmrlx.compose.ui.list.List +import dev.mmrlx.compose.ui.list.ListScope +import dev.mmrlx.compose.ui.list.component.RawItem +import dev.mmrlx.compose.ui.list.component.item.Description +import dev.mmrlx.compose.ui.list.component.item.Supporting +import dev.mmrlx.compose.ui.list.component.item.Title +import dev.mmrlx.webui.console.iife + +@Composable +fun SnippetsTab() { + val webui = LocalWebUI.current + val devTools = LocalDevTools.current + val colorScheme = MaterialTheme.colorScheme + + List( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + val placement = 1 + SnippetBox( + active = devTools.borderAllState != null, + name = "Border All", + description = "Add color borders to all elements" + ) { + if (devTools.borderAllState == null) { + devTools.borderAllState = + webui.runCss( + "wx-devtools-border-all", + "* { outline: ${placement}px dashed ${colorScheme.primary.toCssValue()}; outline-offset: -${placement + 1}px; }" + ) + } else { + devTools.borderAllState?.remove() + devTools.borderAllState = null + return@SnippetBox + } + } + + SnippetBox( + name = "Refresh Page", + description = "Refresh the Website" + ) { + webui.runJs("location.reload()") + } + + SnippetBox( + name = "Reload WebUI", + description = "Completely reloads the WebUI" + ) { + WebUIActivity.recompose() + } + + SnippetBox( + active = devTools.editContentState != null && devTools.editContentState == true, + name = "Edit Page", + description = "Toggle body contentEditable" + ) { + webui.runJs("var state = document.body.contentEditable !== 'true';document.body.contentEditable = state;return state;".iife) { + devTools.editContentState = it.toBooleanStrictOrNull() + } + } + } +} + +@Composable +fun ListScope.SnippetBox( + active: Boolean = false, + name: String, + description: String, + action: () -> Unit, +) { + RawItem( + modifier = Modifier + .clickable(onClick = action) + .contentPadding() + ) { + Title(name) + Description(description) + if (active) { + Supporting { + Badge("Active", variant = BadgeVariant.Default) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/ApplicationInterface.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/ApplicationInterface.kt new file mode 100644 index 00000000..5300c118 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/ApplicationInterface.kt @@ -0,0 +1,167 @@ +@file:Suppress("unused") + +package com.dergoogler.mmrl.wx.ui.webui.interfaces + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.dergoogler.mmrl.wx.ui.webui.alerts.MXConfirm +import com.dergoogler.mmrl.wx.ui.webui.alerts.MXPrompt +import com.dergoogler.mmrl.wx.ui.webui.alerts.Md3Confirm +import com.dergoogler.mmrl.wx.ui.webui.alerts.Md3Prompt +import com.dergoogler.mmrl.wx.ui.webui.alerts.fromString +import com.dergoogler.mmrl.wx.ui.webui.workingMode +import dev.mmrlx.compose.layout.addOverlayView +import dev.mmrlx.utilities.json.getAs +import dev.mmrlx.utilities.json.getByPathOrDefault +import dev.mmrlx.utilities.json.jsonObject +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.interfaces.prebuilt.WebUIApplicationInterface +import dev.mmrlx.webui.javascript.annotation.ExportMethod +import kotlinx.coroutines.Dispatchers +import org.json.JSONObject + +class ApplicationInterface( + webui: WebUI, + private val colorScheme: ColorScheme, +) : WebUIApplicationInterface(webui) { + + @ExportMethod + fun getCurrentRootManager(): JSONObject { + return jsonObject { + "name" to settings.workingMode.toString + "versionName" to "-1" + "versionCode" to -1 + } + } + + @ExportMethod + suspend fun prompt( + options: JSONObject?, + ): Promise { + return Promise(Dispatchers.Main) { + val theme = options.getAs("theme", "md3") + val title = options.getAs("title", "Confirm") + val launchKeyboard = options.getAs("launchKeyboard", true) + val confirmText = options.getByPathOrDefault("buttons.confirmText", "Confirm") + val cancelText = options.getByPathOrDefault("buttons.cancelText", "Cancel") + val defaultValue = options.getAs("defaultValue", "") + val supportingText = options.getAs("supportingText", null) + val message = options.getAs("message", null) + + val keyboardType = options.getAs("keyboardType", "done").let { + KeyboardType.fromString(it) + } + val imeAction = options.getAs("imeAction", "text").let { + ImeAction.fromString(it) + } + + if (message == null) { + reject(Exception("Message must not null")) + return@Promise + } + + if (theme == "md3") { + activity.addOverlayView { + this@ApplicationInterface.Md3Prompt( + title = title, + description = message, + value = defaultValue, + onConfirm = { + resolve(it) + }, + onClose = { + resolve(null) + }, + colorScheme = colorScheme, + confirmText = confirmText, + cancelText = cancelText, + launchKeyboard = launchKeyboard, + keyboardType = keyboardType, + imeAction = imeAction + ) + } + + return@Promise + } + + if (theme == "mmrlx") { + activity.addOverlayView { + this@ApplicationInterface.MXPrompt( + title = title, + description = message, + value = defaultValue, + onConfirm = { + resolve(it) + }, + onClose = { + resolve(null) + }, + confirmText = confirmText, + cancelText = cancelText, + launchKeyboard = launchKeyboard, + supportingText = supportingText, + keyboardType = keyboardType, + imeAction = imeAction + ) + } + + return@Promise + } + + reject(Exception("Unsupported theme: $theme")) + } + } + + @ExportMethod + suspend fun confirm(options: JSONObject?): Promise { + return Promise(Dispatchers.Main) { + val theme = options.getAs("theme", "md3") + val title = options.getAs("title", "Confirm") + val confirmText = options.getByPathOrDefault("buttons.confirmText", "Confirm") + val cancelText = options.getByPathOrDefault("buttons.cancelText", "Cancel") + val message = options.getAs("message", null) + + if (theme == "md3") { + activity.addOverlayView { + this@ApplicationInterface.Md3Confirm( + title = title, + description = message, + onConfirm = { + resolve(true) + }, + onClose = { + resolve(false) + }, + colorScheme = colorScheme, + confirmText = confirmText, + cancelText = cancelText, + ) + } + + return@Promise + } + + if (theme == "mmrlx") { + activity.addOverlayView { + this@ApplicationInterface.MXConfirm( + title = title, + description = message, + onConfirm = { + resolve(true) + }, + onClose = { + resolve(false) + }, + confirmText = confirmText, + cancelText = cancelText, + + ) + } + return@Promise + } + + reject(Exception("Unsupported theme: $theme")) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/FileSystemInterface.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/FileSystemInterface.kt new file mode 100644 index 00000000..87a62ded --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/FileSystemInterface.kt @@ -0,0 +1,368 @@ +@file:Suppress("unused", "PropertyName") + +package com.dergoogler.mmrl.wx.ui.webui.interfaces + +import android.os.Build +import android.system.OsConstants +import com.dergoogler.mmrl.platform.file.SuFile +import com.dergoogler.mmrl.platform.file.SuFileInputStream +import com.dergoogler.mmrl.platform.file.SuFileOutputStream +import com.dergoogler.mmrl.platform.file.inputStream +import com.dergoogler.mmrl.wx.util.PermissionParser +import dev.mmrlx.utilities.json.getAs +import dev.mmrlx.utilities.json.toByteArray +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.interfaces.prebuilt.WebUIFileSystemInterface +import dev.mmrlx.webui.javascript.annotation.ExportMethod +import dev.mmrlx.webui.javascript.annotation.ExportVariable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.Charset +import java.nio.charset.CharsetEncoder +import java.nio.charset.CodingErrorAction +import kotlin.math.ceil + +// TODO: migrate from com.dergoogler.mmrl.platform.file.SuFile to dev.mmrlx.nio.SuFile +// TODO: Not yet implemented, requires file system re-work. +class FileSystemInterface(webui: WebUI) : WebUIFileSystemInterface(webui) { + // Hidden, sadly + // val O_DIRECT: Int = OsConstants.O_DIRECT + + // TODO: support pure for variables + // @ExportVariable(pure = true) + @ExportVariable + val O_EXCL: Int = OsConstants.O_EXCL + + @ExportVariable + val O_NOCTTY: Int = OsConstants.O_NOCTTY + + @ExportVariable + val O_NOFOLLOW: Int = OsConstants.O_NOFOLLOW + + @ExportVariable + val O_NONBLOCK: Int = OsConstants.O_NONBLOCK + + @ExportVariable + val O_RDONLY: Int = OsConstants.O_RDONLY + + @ExportVariable + val O_RDWR: Int = OsConstants.O_RDWR + + @ExportVariable + val O_SYNC: Int = OsConstants.O_SYNC + + @ExportVariable + val O_DSYNC: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + OsConstants.O_DSYNC + } else { + 0 + } + + @ExportVariable + val O_TRUNC: Int = OsConstants.O_TRUNC + + @ExportVariable + val O_WRONLY: Int = OsConstants.O_WRONLY + + @ExportVariable + val O_ACCMODE: Int = OsConstants.O_ACCMODE + + @ExportVariable + val O_APPEND: Int = OsConstants.O_APPEND + + @ExportVariable + val O_CLOEXEC: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + OsConstants.O_CLOEXEC + } else { + 0 + } + + @ExportVariable + val O_CREAT: Int = OsConstants.O_CREAT + + // TODO: better handle + private class JSInputStream : JSObject { + private var stream: SuFileInputStream + + constructor(path: String, flags: Int, mode: Int) { + stream = SuFileInputStream(SuFile(path), flags, mode) + } + + @ExportMethod + suspend fun read(): Int = withContext(Dispatchers.IO) { + return@withContext stream.read() + } + + @ExportMethod + suspend fun read(b: JSONArray): Int = withContext(Dispatchers.IO) { + return@withContext stream.read(b.toByteArray()) + } + + @ExportMethod + suspend fun read(b: JSONArray, off: Int, len: Int): Int = + withContext(Dispatchers.IO) { + return@withContext stream.read(b.toByteArray(), off, len) + } + + @ExportMethod + suspend fun skip(n: Long): Long = withContext(Dispatchers.IO) { + return@withContext stream.skip(n) + } + + @ExportMethod + fun mark(readLimit: Int) { + stream.mark(readLimit) + } + + @ExportMethod + suspend fun reset() = + withContext(Dispatchers.IO) { + stream.reset() + } + + @ExportMethod + fun markSupported() = stream.markSupported() + + @ExportMethod + suspend fun available(): Int = withContext(Dispatchers.IO) { + stream.available() + } + + @ExportMethod + suspend fun close() { + withContext(Dispatchers.IO) { + stream.close() + } + } + } + + @ExportMethod + suspend fun inputstream(path: String, options: JSONObject?): Promise { + val flags = options.getAs("flags", O_RDONLY) + val rawMode = options?.opt("mode") ?: 0 + val mode = PermissionParser.parse(rawMode) + + return Promise(Dispatchers.Main) { + try { + resolve(JSInputStream(path, flags, mode)) + } catch (e: Exception) { + reject(e) + } + } + } + + @ExportMethod + suspend fun outputstream(path: String, options: JSONObject): Promise { + return Promise { + try { + val fos = FileOutputStream(path, false) // overwrite (use true for append) + + val writer = object : JSObject { + @ExportMethod + fun write(chunk: JSONArray) { + val byteArray = ByteArray(chunk.length()) { i -> + chunk.getInt(i).toByte() + } + fos.write(byteArray) + } + + @ExportMethod + fun close() { + fos.flush() + fos.close() + } + } + + resolve(writer) + } catch (e: Throwable) { + reject(e) + } + } + } + + @ExportMethod + suspend fun readFile( + path: String, + options: JSONObject?, + ): Promise { + val charset: Charset? = Charset.forName(options.getAs("encoding", "UTF-8")) + val flags = options.getAs("flags", O_RDONLY) + val rawMode = options?.opt("mode") ?: 0 + val mode = PermissionParser.parse(rawMode) + + return Promise { + if (charset == null) { + reject(Error("Invalid charset")) + return@Promise + } + + try { + val file = SuFile(path) + val stream = file.inputStream(flags, mode) + val reader = stream.reader(charset) + val text = reader.use { it.readText() } + resolve(text) + } catch (e: Exception) { + reject(e) + } + } + } + + @ExportMethod + fun readFileSync( + path: String, + options: JSONObject?, + ): String? { + val charset: Charset? = Charset.forName(options.getAs("encoding", "UTF-8")) + val flags = options.getAs("flags", O_RDONLY) + val rawMode = options?.opt("mode") ?: 0 + val mode = PermissionParser.parse(rawMode) + + if (charset == null) { + console.error(Error("Invalid charset")) + return null + } + + try { + val file = SuFile(path) + val stream = file.inputStream(flags, mode) + val reader = stream.reader(charset) + val text = reader.use { it.readText() } + return text + } catch (e: Exception) { + console.error(e) + return null + } + } + + @ExportMethod + suspend fun writeFile( + path: String, + data: String, + options: JSONObject?, + ): Promise { + val charset: Charset? = Charset.forName(options.getAs("encoding", "UTF-8")) + val flags = options.getAs("flags", O_CREAT or O_WRONLY or O_TRUNC) + val rawMode = options?.opt("mode") ?: 438 + val mode = PermissionParser.parse(rawMode) + + return Promise { + if (charset == null) { + reject(Error("Invalid charset")) + return@Promise + } + + try { + SuFile(path).writeNIOText(data, flags, mode, charset) + resolve(Unit) + } catch (e: Exception) { + reject(e) + } + } + } +// TODO: implement it with Os.access(path, flags) +// +// @ExportMethod +// suspend fun access( +// path: String, +// mode: Int, +// ): Promise { +// return Promise { +// val accessMode = AccessMode.from(mode) +// +// if (accessMode == null) { +// reject(Error("Invalid access mode")) +// return@Promise +// } +// +// try { +// val success = when (accessMode) { +// AccessMode.F_OK -> { +// SuFile(path).exists() +// } +// +// AccessMode.R_OK -> { +// SuFile(path).canRead() +// } +// +// AccessMode.W_OK -> { +// SuFile(path).canWrite() +// } +// +// AccessMode.X_OK -> { +// SuFile(path).canExecute() +// } +// } +// +// resolve(success) +// } catch (e: Exception) { +// reject(e) +// } +// } +// } + + @Throws(IOException::class) + private fun SuFile.writeNIOText( + text: String, + flags: Int, + mode: Int, + charset: Charset = Charsets.UTF_8, + ): Unit = + SuFileOutputStream(this, flags, mode).use { it.writeTextImpl(text, charset) } + + private fun Charset.newReplaceEncoder() = newEncoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + + private fun byteBufferForEncoding(chunkSize: Int, encoder: CharsetEncoder): ByteBuffer { + val maxBytesPerChar = + ceil(encoder.maxBytesPerChar()).toInt() // including replacement sequence + return ByteBuffer.allocate(chunkSize * maxBytesPerChar) + } + + private fun OutputStream.writeTextImpl(text: String, charset: Charset) { + val chunkSize = DEFAULT_BUFFER_SIZE + + if (text.length < 2 * chunkSize) { + this.write(text.toByteArray(charset)) + return + } + + val encoder = charset.newReplaceEncoder() + val charBuffer = CharBuffer.allocate(chunkSize) + val byteBuffer = byteBufferForEncoding(chunkSize, encoder) + + var startIndex = 0 + var leftover = 0 + + while (startIndex < text.length) { + val copyLength = minOf(chunkSize - leftover, text.length - startIndex) + val endIndex = startIndex + copyLength + + text.toCharArray(charBuffer.array(), leftover, startIndex, endIndex) + charBuffer.limit(copyLength + leftover) + encoder.encode(charBuffer, byteBuffer, /*endOfInput = */endIndex == text.length) + .also { check(it.isUnderflow) } + this.write(byteBuffer.array(), 0, byteBuffer.position()) + + if (charBuffer.position() != charBuffer.limit()) { + charBuffer.put(0, charBuffer.get()) // the last char is a high surrogate + leftover = 1 + } else { + leftover = 0 + } + + charBuffer.clear() + byteBuffer.clear() + startIndex = endIndex + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/KernelSUInterface.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/KernelSUInterface.kt new file mode 100644 index 00000000..006f2fc9 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/KernelSUInterface.kt @@ -0,0 +1,339 @@ +package com.dergoogler.mmrl.wx.ui.webui.interfaces + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import android.text.TextUtils +import android.view.Window +import android.webkit.JavascriptInterface +import android.widget.Toast +import androidx.core.content.pm.PackageInfoCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.dergoogler.mmrl.platform.PlatformManager +import com.dergoogler.mmrl.wx.model.module.killShellWhenBackground +import com.dergoogler.mmrl.wx.ui.webui.isRootMode +import com.dergoogler.mmrl.wx.ui.webui.module +import com.dergoogler.mmrl.wx.ui.webui.util.packages +import com.topjohnwu.superuser.CallbackList +import com.topjohnwu.superuser.Shell +import com.topjohnwu.superuser.ShellUtils +import com.topjohnwu.superuser.internal.WaitRunnable +import dev.mmrlx.webui.PureJavaScriptInterface +import dev.mmrlx.webui.WebUI +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.CompletableFuture + +class KernelSUInterface(webui: WebUI) : PureJavaScriptInterface(webui) { + override var id: String = "ksu" + + private val config get() = module.webrootConfig + + private val commands = if (!settings.isRootMode) arrayOf("sh") else arrayOf("su") + + private var shell: Shell = Shell.getShell() + + private inline fun withNewRootShell( + globalMnt: Boolean = false, + block: Shell.() -> T, + ): T { + return createRootShell(globalMnt).use(block) + } + + private fun createRootShell( + globalMnt: Boolean = false, + ): Shell { + Shell.enableVerboseLogging = settings.debug + val builder = Shell.Builder.create() + if (globalMnt) { + builder.setFlags(Shell.FLAG_MOUNT_MASTER) + } + shell = builder.build(*commands) + return shell + } + + @JavascriptInterface + fun mmrl(): Boolean { + return true + } + + @JavascriptInterface + fun toast(msg: String) { + webview.post { + Toast.makeText(kontext, msg, Toast.LENGTH_SHORT).show() + } + } + + @JavascriptInterface + fun fullScreen(enable: Boolean) { + mainThread { + if (enable) { + hideSystemUI(activity.window) + } else { + showSystemUI(activity.window) + } + } + } + + @JavascriptInterface + fun exec(cmd: String): String { + return withNewRootShell { ShellUtils.fastCmd(this, cmd) } + } + + @JavascriptInterface + fun execBool(cmd: String): Boolean { + return withNewRootShell { ShellUtils.fastCmdResult(this, 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) + + supervisorScope.launch(Dispatchers.IO) { + val result = withNewRootShell( + globalMnt = true, + ) { + newJob().add(finalCommand.toString()).to(ArrayList(), ArrayList()).exec() + } + + val stdout = result.out.joinToString(separator = "\n") + val stderr = result.err.joinToString(separator = "\n") + + val jsCode = + "(function() { try { ${callbackFunc}(${result.code}, ${ + JSONObject.quote( + stdout + ) + }, ${JSONObject.quote(stderr)}); } catch(e) { console.error(e); } })();" + + runJs(jsCode) + } + } + + // ensure it really runs on the ui thread + private fun runAndWait(r: Runnable) { + if (ShellUtils.onMainThread()) { + r.run() + } else { + val wr = WaitRunnable(r) + Handler(Looper.getMainLooper()).post(wr) + wr.waitUntilDone() + } + } + + @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( + globalMnt = true, + ) + + val emitData = fun(name: String, data: String) { + val jsCode = + "(function() { try { ${callbackFunc}.${name}.emit('data', ${ + JSONObject.quote( + data + ) + }); } catch(e) { console.error('emitData', e); } })();" + + runJs(jsCode) + } + + val stdout = object : CallbackList(::runAndWait) { + override fun onAddElement(s: String) { + emitData("stdout", s) + } + } + + val stderr = object : CallbackList(::runAndWait) { + override fun onAddElement(s: String) { + emitData("stderr", s) + } + } + + supervisorScope.launch(Dispatchers.IO) { + val future = shell.newJob().add(finalCommand.toString()).to(stdout, stderr).enqueue() + val completableFuture = CompletableFuture.supplyAsync { + future.get() + } + + completableFuture.thenAccept { result -> + val emitExitCode = + "(function() { try { ${callbackFunc}.emit('exit', ${result.code}); } catch(e) { console.error(`emitExit error: \${e}`); } })();" + runJs(emitExitCode) + + + if (result.code != 0) { + val emitErrCode = + "(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); } })();" + runJs(emitErrCode) + } + }.whenComplete { _, _ -> + runJsCatching { shell.close() } + } + } + } + + @JavascriptInterface + fun moduleInfo(): String { + val moduleInfos = JSONArray(PlatformManager.moduleManager.modules) + val currentModuleInfo = JSONObject() + currentModuleInfo.put("moduleDir", module.path.moduleDir) + for (i in 0 until moduleInfos.length()) { + val currentInfo = moduleInfos.getJSONObject(i) + + if (currentInfo.getString("id") != module.id) { + continue + } + + val keys = currentInfo.keys() + for (key in keys) { + currentModuleInfo.put(key, currentInfo[key]) + } + break + } + return currentModuleInfo.toString() + } + + private val pm get(): PackageManager = kontext.packageManager + + @JavascriptInterface + fun listPackages(type: String): String { + val packageNames = packages + .filter { appInfo -> + val flags = appInfo.applicationInfo?.flags ?: 0 + when (type.lowercase()) { + "system" -> (flags and ApplicationInfo.FLAG_SYSTEM) != 0 + "user" -> (flags and ApplicationInfo.FLAG_SYSTEM) == 0 + else -> true + } + } + .map { it.packageName } + .sorted() + + val jsonArray = JSONArray() + for (pkgName in packageNames) { + jsonArray.put(pkgName) + } + return jsonArray.toString() + } + + @JavascriptInterface + fun getPackagesInfo(packageNamesJson: String): String { + val packageNames = JSONArray(packageNamesJson) + val jsonArray = JSONArray() + val appMap = packages.associateBy { it.packageName } + for (i in 0 until packageNames.length()) { + val pkgName = packageNames.getString(i) + val appInfo = appMap[pkgName] + if (appInfo != null) { + val app = appInfo.applicationInfo + val obj = JSONObject() + obj.put("packageName", appInfo.packageName) + obj.put("versionName", appInfo.versionName ?: "") + obj.put("versionCode", PackageInfoCompat.getLongVersionCode(appInfo)) + obj.put("appLabel", pm.getApplicationLabel(appInfo.applicationInfo!!)) + obj.put( + "isSystem", + if (app != null) ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) else JSONObject.NULL + ) + obj.put("uid", app?.uid ?: JSONObject.NULL) + jsonArray.put(obj) + } else { + val obj = JSONObject() + obj.put("packageName", pkgName) + obj.put("error", "Package not found or inaccessible") + jsonArray.put(obj) + } + } + return jsonArray.toString() + } + + override fun onStop() { + super.onStop() + + if (config.killShellWhenBackground) { + shell.close() + } + } + + override fun onDestroy() { + shell.close() + super.onDestroy() + } + + override fun onResume() { + super.onResume() + + if (config.killShellWhenBackground) { + shell = createRootShell(true) + } + } + + private fun hideSystemUI(window: Window) = + 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) = + WindowInsetsControllerCompat( + window, + window.decorView + ).show(WindowInsetsCompat.Type.systemBars()) +} + diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/ModuleInterface.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/ModuleInterface.kt new file mode 100644 index 00000000..b981a1e3 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/ModuleInterface.kt @@ -0,0 +1,37 @@ +package com.dergoogler.mmrl.wx.ui.webui.interfaces + +import com.dergoogler.mmrl.wx.ui.webui.module +import dev.mmrlx.webui.JavaScriptInterface +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.javascript.annotation.ExportVariable + +class ModuleInterface( + webui: WebUI, +) : JavaScriptInterface(webui) { + override val prototypeClass = "Module" + override val propertyName = "mod" + + @ExportVariable + val adbPath = module.adbPath.toJSONObject() + + @ExportVariable + val path = module.path.toJSONObject() + + @ExportVariable + override val id = module.id + + @ExportVariable + val name = module.name + + @ExportVariable + val author = module.author + + @ExportVariable + val version = module.version + + @ExportVariable + val versionCode = module.versionCode + + @ExportVariable + val description = module.description +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/FileInputInterface.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/FileInputInterface.kt new file mode 100644 index 00000000..2139bb62 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/FileInputInterface.kt @@ -0,0 +1,78 @@ +package com.dergoogler.mmrl.wx.ui.webui.interfaces.legacy + +import android.webkit.JavascriptInterface +import com.dergoogler.mmrl.wx.ui.webui.module +import com.dergoogler.mmrl.wx.ui.webui.sanitizedIdWithFileInputStream +import dev.mmrlx.utilities.json.toJSONArray +import dev.mmrlx.webui.JavaScriptInterface +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.javascript.annotation.ExportMethod +import org.json.JSONArray +import java.io.BufferedInputStream +import java.io.InputStream + +class FileInputInterface( + webui: WebUI, +) : JavaScriptInterface(webui) { + override val prototypeClass = "FileInputInterface" + override val propertyName = module.sanitizedIdWithFileInputStream + + @ExportMethod + fun open(path: String): JSObject? = + try { + val stream = inputStream(path) + FileInputInterfaceStream(this, stream) + } catch (e: Exception) { + console.error(e) + null + } +} + +class FileInputInterfaceStream( + webui: WebUI, + inputStream: InputStream, +) : JavaScriptInterface.JSObject, WebUI by webui { + private val bufferedInputStream = BufferedInputStream(inputStream) + + fun getStream(): InputStream = bufferedInputStream + + @ExportMethod + fun read(): Int { + return try { + bufferedInputStream.read() + } catch (e: Exception) { + console.error("Failed to read byte", e) + -1 + } + } + + @ExportMethod + fun readChunk(chunkSize: Int): JSONArray? { + val buffer = ByteArray(chunkSize) + val bytesRead = bufferedInputStream.read(buffer) + return if (bytesRead > 0) { + buffer.copyOf(bytesRead).toJSONArray() + } else { + null + } + } + + @JavascriptInterface + fun close() { + try { + bufferedInputStream.close() + } catch (e: Exception) { + console.error("Failed to close stream", e) + } + } + + @JavascriptInterface + fun skip(n: Long): Long { + return try { + bufferedInputStream.skip(n) + } catch (e: Exception) { + console.error("Failed to skip bytes", e) + -1 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/FileOutputInterface.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/FileOutputInterface.kt new file mode 100644 index 00000000..f9c28317 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/FileOutputInterface.kt @@ -0,0 +1,65 @@ +package com.dergoogler.mmrl.wx.ui.webui.interfaces.legacy + +import com.dergoogler.mmrl.wx.ui.webui.module +import com.dergoogler.mmrl.wx.ui.webui.sanitizedIdWithFileOutputStream +import dev.mmrlx.webui.JavaScriptInterface +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.javascript.annotation.ExportMethod +import java.io.BufferedOutputStream +import java.io.OutputStream + +class FileOutputInterface( + webui: WebUI, +) : JavaScriptInterface(webui) { + override val prototypeClass = "FileOutputInterface" + override val propertyName = module.sanitizedIdWithFileOutputStream + + @ExportMethod + fun open(path: String, append: Boolean): JSObject? = + try { + val stream = outputStream(path, append) + FileOutputInterfaceStream(this, stream) + } catch (e: Exception) { + console.error(e) + null + } + + @ExportMethod + fun open(path: String): JSObject? = open(path, false) +} + +class FileOutputInterfaceStream( + webui: WebUI, + outputStream: OutputStream, +) : JavaScriptInterface.JSObject, WebUI by webui { + private val bufferedOutputStream = BufferedOutputStream(outputStream) + + fun getStream(): OutputStream = bufferedOutputStream + + @ExportMethod + fun write(b: Int) { + try { + bufferedOutputStream.write(b) + } catch (e: Exception) { + console.error("Failed to write byte", e) + } + } + + @ExportMethod + fun flush() { + try { + bufferedOutputStream.flush() + } catch (e: Exception) { + console.error("Failed to flush stream", e) + } + } + + @ExportMethod + fun close() { + try { + bufferedOutputStream.close() + } catch (e: Exception) { + console.error("Failed to close stream", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/ModuleInterface.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/ModuleInterface.kt new file mode 100644 index 00000000..ed4a5d1f --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/interfaces/legacy/ModuleInterface.kt @@ -0,0 +1,162 @@ +package com.dergoogler.mmrl.wx.ui.webui.interfaces.legacy + +import android.app.Activity +import android.os.Build +import androidx.core.app.ShareCompat +import androidx.core.content.pm.PackageInfoCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.dergoogler.mmrl.wx.ui.webui.deprecated +import com.dergoogler.mmrl.wx.ui.webui.module +import com.dergoogler.mmrl.wx.ui.webui.sanitizedId +import com.dergoogler.mmrl.wx.ui.webui.workingMode +import com.squareup.moshi.JsonClass +import dev.mmrlx.utilities.json.jsonObject +import dev.mmrlx.webui.JavaScriptInterface +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.javascript.annotation.ExportMethod +import dev.mmrlx.webui.javascript.annotation.ExportVariable +import org.json.JSONObject + +@JsonClass(generateAdapter = true) +internal data class Manager( + val name: String, + val versionName: String, + val versionCode: Int, +) + +class ModuleInterface( + webui: WebUI, +) : JavaScriptInterface(webui) { + + override val prototypeClass = "ModuleInterface" + override val propertyName = "$${module.sanitizedId}" + + private fun getWindowInsetsController(activity: Activity): WindowInsetsControllerCompat = + WindowCompat.getInsetsController( + activity.window, + webview + ) + + init { + with(activity) { + getWindowInsetsController(this).systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + @ExportMethod + fun getManager(): JSONObject { + deprecated("$propertyName.getManager()", "webui.getCurrentRootManager()") + + return jsonObject { + "name" to settings.workingMode.toString + "versionName" to "-1" + "versionCode" to -1 + } + } + + @ExportMethod + fun getMmrl(): JSONObject { + deprecated("$propertyName.getMmrl()", "webui.getCurrentApplication()") + + val packageInfo = kontext.packageManager.getPackageInfo(kontext.packageName, 0) + val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) + val versionName = packageInfo.versionName ?: "unknown" + + return jsonObject { + "name" to packageInfo.packageName + "versionName" to versionName + "versionCode" to versionCode + } + } + + @Deprecated("Use window.getComputedStyle(document.body).getPropertyValue('--window-inset-top') instead") + @ExportMethod + fun getWindowTopInset(): Int { + deprecated( + "$propertyName.getWindowTopInset()", + "window.getComputedStyle(document.body).getPropertyValue('--window-inset-top')" + ) + return 0 + } + + @Deprecated("Use window.getComputedStyle(document.body).getPropertyValue('--window-inset-bottom') instead") + @ExportMethod + fun getWindowBottomInset(): Int { + deprecated( + "$propertyName.getWindowBottomInset()", + "window.getComputedStyle(document.body).getPropertyValue('--window-inset-bottom')" + ) + return 0 + } + + @Deprecated("Use window.getComputedStyle(document.body).getPropertyValue('--window-inset-left') instead") + @ExportMethod + fun getWindowLeftInset(): Int { + deprecated( + "$propertyName.getWindowLeftInset()", + "window.getComputedStyle(document.body).getPropertyValue('--window-inset-left')" + ) + return 0 + } + + @Deprecated("Use window.getComputedStyle(document.body).getPropertyValue('--window-inset-right') instead") + @ExportMethod + fun getWindowRightInset(): Int { + deprecated( + "$propertyName.getWindowRightInset()", + "window.getComputedStyle(document.body).getPropertyValue('--window-inset-right')" + ) + return 0 + } + + @ExportVariable + val isLightNavigationBars: Boolean + get() = with(activity) { + getWindowInsetsController(this).isAppearanceLightNavigationBars + } + + @ExportVariable + val isDarkMode: Boolean + get() = settings.darkMode + + @ExportMethod + fun setLightNavigationBars(isLight: Boolean) = webview.post { + with(activity) { + getWindowInsetsController(this).isAppearanceLightNavigationBars = isLight + } + } + + @ExportVariable + val isLightStatusBars: Boolean + get() = with(activity) { + getWindowInsetsController(this).isAppearanceLightStatusBars + } + + @ExportMethod + fun setLightStatusBars(isLight: Boolean) = webview.post { + with(activity) { + getWindowInsetsController(this).isAppearanceLightStatusBars = isLight + } + } + + @ExportMethod + fun getSdk(): Int = Build.VERSION.SDK_INT + + @ExportMethod + fun shareText(text: String) { + ShareCompat.IntentBuilder(kontext) + .setType("text/plain") + .setText(text) + .startChooser() + } + + @ExportMethod + fun shareText(text: String, type: String) { + ShareCompat.IntentBuilder(kontext) + .setType(type) + .setText(text) + .startChooser() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/AssetsPathHandler.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/AssetsPathHandler.kt new file mode 100644 index 00000000..6307d9f2 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/AssetsPathHandler.kt @@ -0,0 +1,32 @@ +package com.dergoogler.mmrl.wx.ui.webui.pathHandlers + +import android.webkit.WebResourceResponse +import com.dergoogler.mmrl.webui.MimeUtil +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.WebUIResourceRequest +import dev.mmrlx.webui.PathHandler +import java.io.IOException + +class AssetsPathHandler( + webui: WebUI, +) : PathHandler(webui) { + override val id = "" + + private val assets get() = kontext.assets + + override fun handle( + request: WebUIResourceRequest, + ): WebResourceResponse { + val path = request.path + + try { + val inputStream = assets.open(path.removePrefix("/")) + val mimeType = MimeUtil.getMimeFromFileName(path) + return WebResourceResponse(mimeType, null, inputStream) + } catch (e: IOException) { + console.debugError("Error opening asset path: $path", e) + return notFoundResponse + } + } +} + diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/InternalPathHandler.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/InternalPathHandler.kt new file mode 100644 index 00000000..2dd0f247 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/InternalPathHandler.kt @@ -0,0 +1,54 @@ +package com.dergoogler.mmrl.wx.ui.webui.pathHandlers + +import android.webkit.WebResourceResponse +import androidx.compose.material3.ColorScheme +import com.dergoogler.mmrl.webui.model.WebColors +import dev.mmrlx.webui.PathHandler +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.WebUIResourceRequest +import java.io.IOException + +class InternalPathHandler( + webui: WebUI, + private val colorScheme: ColorScheme, +) : PathHandler(webui) { + override val id = "/internal/" + + val webColors get() = WebColors(colorScheme) + val assetsPathHandler get() = AssetsPathHandler(this) + + override fun handle( + request: WebUIResourceRequest, + ): WebResourceResponse { + val path = request.path + + try { + if (path.matches(Regex("^assets(/.*)?$"))) { + return assetsPathHandler.handle( + WebUIResourceRequest( + method = request.method, + isForMainFrame = request.isForMainFrame(), + url = request.url, + path = path.removePrefix("assets/"), + requestHeaders = request.getRequestHeaders(), + isRedirect = request.isRedirect(), + hasGesture = request.hasGesture() + ) + ) + } + + if (path.matches(Regex("insets\\.css"))) { + return insets.css.asStyleResponse() + } + + if (path.matches(Regex("colors\\.css"))) { + return webColors.allCssColors.asStyleResponse() + } + + return notFoundResponse + } catch (e: IOException) { + console.debugError("Error opening mmrl asset path: $path", e) + return notFoundResponse + } + } +} diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/SuPathHandler.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/SuPathHandler.kt new file mode 100644 index 00000000..d893ca37 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/SuPathHandler.kt @@ -0,0 +1,35 @@ +package com.dergoogler.mmrl.wx.ui.webui.pathHandlers + +import android.net.Uri +import android.webkit.WebResourceResponse +import com.dergoogler.mmrl.platform.file.SuFile +import com.dergoogler.mmrl.webui.asResponse +import dev.mmrlx.webui.PathHandler +import dev.mmrlx.webui.ResponseStatus +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.WebUIResourceRequest +import java.io.IOException + +class SuPathHandler( + webui: WebUI, + override val id: String, + override val url: Uri, + private val directory: String, +) : PathHandler(webui) { + + override fun handle( + request: WebUIResourceRequest, + ): WebResourceResponse { + val path = request.path + + return try { + SuFile(directory, path).asResponse() + } catch (e: IOException) { + console.debugError("Error opening su path: $path", e) + return response( + status = ResponseStatus.BAD_REQUEST, + data = null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/WebrootPathHandler.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/WebrootPathHandler.kt new file mode 100644 index 00000000..85b12156 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/WebrootPathHandler.kt @@ -0,0 +1,234 @@ +package com.dergoogler.mmrl.wx.ui.webui.pathHandlers + +import android.util.Log +import android.webkit.WebResourceResponse +import com.dergoogler.mmrl.ext.isNotNullOrBlank +import com.dergoogler.mmrl.wx.model.module.DEFAULT_CSP +import com.dergoogler.mmrl.wx.model.module.autoStatusBarsStyle +import com.dergoogler.mmrl.wx.model.module.caching +import com.dergoogler.mmrl.wx.model.module.cachingMaxAge +import com.dergoogler.mmrl.wx.model.module.contentSecurityPolicy +import com.dergoogler.mmrl.wx.model.module.historyFallback +import com.dergoogler.mmrl.wx.model.module.historyFallbackFile +import com.dergoogler.mmrl.wx.ui.webui.autoOpenEruda +import com.dergoogler.mmrl.wx.ui.webui.enableErudaConsole +import com.dergoogler.mmrl.wx.ui.webui.module +import com.dergoogler.mmrl.wx.ui.webui.sufile +import com.dergoogler.mmrl.wx.ui.webui.util.Injection +import com.dergoogler.mmrl.wx.ui.webui.util.InjectionType +import com.dergoogler.mmrl.wx.ui.webui.util.addInjection +import com.dergoogler.mmrl.wx.ui.webui.util.asResponse +import com.dergoogler.mmrl.wx.ui.webui.util.errorResponse +import dev.mmrlx.nio.SuFile +import dev.mmrlx.utilities.security.ContentSecurityPolicyManager +import dev.mmrlx.webui.PathHandler +import dev.mmrlx.webui.ResponseStatus +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.WebUIInsets +import dev.mmrlx.webui.WebUIResourceRequest +import java.io.IOException + +private const val DefaultContentSecurityPolicy: String = + "default-src 'self' data: blob: {domain}; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval' {domain}; " + + "style-src 'self' 'unsafe-inline' {domain}; connect-src *" + +class WebrootPathHandler( + webui: WebUI, +) : PathHandler(webui) { + override val id = "/" + + private val configBase get() = module.path.configDir + private val configStyleBase get() = sufile(configBase, "style") + private val configJsBase get() = sufile(configBase, "js") + private val customJsHead get() = sufile(configJsBase, "head") + private val customJsBody get() = sufile(configJsBase, "body") + + private val config get() = module.webrootConfig + + private val directory get() = sufile(sufile(module.path.webrootDir).getCanonicalDirPath()) + + init { + SuFile.createDirectories(customJsHead, customJsBody, configStyleBase) + } + + private val reversedPaths = listOf( + "mmrl/", "internal/", ".adb/", ".local/", ".config/", ".${module.id}/", "__root__/" + ) + + private val jsExtensionRegex = Regex("^[cm]?js$") + private val staticExtensions = + listOf("js", "cjs", "mjs", "css", "png", "jpg", "jpeg", "gif", "svg", "woff", "woff2") + + private fun MutableList.addScriptInjections( + dir: SuFile, + type: InjectionType, + urlBase: String, + ) { + dir.exists { d -> + d.list().map { sufile(d, it) } + .filter { it.exists() && jsExtensionRegex.matches(it.extension) }.forEach { + addInjection(type) { + append("\n") + } + } + } + } + + override fun handle( + request: WebUIResourceRequest, + ): WebResourceResponse { + val path = request.path.ifEmpty { "index.html" } + + reversedPaths.forEach { + if (path.endsWith(it)) return response( + status = ResponseStatus.UNAUTHORIZED, + data = null + ) + } + + val contentSecurityPolicy = ContentSecurityPolicyManager(DEFAULT_CSP, baseUri.toString()) + val mergedCsp = contentSecurityPolicy.mergeToString(config.contentSecurityPolicy) + + if (path.endsWith("favicon.ico") || path.startsWith("favicon.ico")) return notFoundResponse + + Log.d("CSP", mergedCsp) + + try { + val file = directory.getCanonicalFileIfChild(path) ?: run { + Log.e( + "webrootPathHandler", + "The requested file: $path is outside the mounted directory: $directory", + ) + return forbiddenResponse + } + + if (!file.exists() && config.historyFallback) { + val fallbackFile = sufile(directory, config.historyFallbackFile) + val fallbackResponse = fallbackFile.asResponse() + + if (mergedCsp.isNotNullOrBlank()) { + fallbackResponse.setResponseHeaders( + mapOf( + "Content-Security-Policy" to mergedCsp + ) + ) + } + + return fallbackResponse + } + + val injections = buildList { + if (settings.enableErudaConsole) { + addInjection { + appendLine("") + } + } + + if (config.autoStatusBarsStyle) { + addInjection { + appendLine("") + } + } + +// if (config.backHandler == true && config.backInterceptor == "native") { +// addInjection { +// appendLine( +// """""".trimIndent() +// ) +// } +// } + + configStyleBase.exists { + it.listFiles { f -> f.exists() && f.extension == "css" }?.forEach { css -> + addInjection { + appendLine("") + } + } + } + +// addInjection(InjectionType.BODY) { +// appendLine("") +// +// if (options.config.pullToRefresh && options.config.useNativeRefreshInterceptor && options.config.pullToRefreshHelper) { +// appendLine("") +// } +// } + + addScriptInjections( + customJsHead, + InjectionType.HEAD, + "https://mui.kernelsu.org/.adb/.config/${module.id}/js/head" + ) + addScriptInjections( + customJsBody, + InjectionType.BODY, + "https://mui.kernelsu.org/.adb/.config/${module.id}/js/body" + ) + + addInjection(insets.cssInject) + } + + val ext = file.extension + val isHtml = ext == "html" || ext == "htm" + val response = if (isHtml) file.asResponse(injections) else file.asResponse() + + val headers = mutableMapOf() + + if (isHtml && mergedCsp.isNotNullOrBlank()) { + headers["Content-Security-Policy"] = mergedCsp + } + + if (ext in staticExtensions && config.caching) { + headers["Cache-Control"] = "public, max-age=${config.cachingMaxAge}" + } + + headers["ETag"] = "${file.length()}-${file.lastModified()}" + response.setResponseHeaders(headers) + + return response + } catch (e: IOException) { + console.debugError("Error opening webroot path: $path", e) + return errorResponse( + title = "Failed", + + description = { + +e.message.toString() + }, + errorCode = "FILE_NOT_FOUND", + ) + } + } +} + +val WebUIInsets.cssInject + get() = buildString { + val sdg = css + .replace(Regex("\t"), "\t\t") + .replace(Regex("\n\\}"), "\n\t}") + + appendLine("") + appendLine("") + } diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/ksu/IconPathHandler.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/ksu/IconPathHandler.kt new file mode 100644 index 00000000..82064ab4 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/pathHandlers/ksu/IconPathHandler.kt @@ -0,0 +1,50 @@ +package com.dergoogler.mmrl.wx.ui.webui.pathHandlers.ksu + +import android.webkit.WebResourceResponse +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import com.dergoogler.mmrl.wx.ui.webui.util.packages +import dev.mmrlx.webui.PathHandler +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.WebUIResourceRequest + +class IconPathHandler( + webui: WebUI, +) : PathHandler(webui) { + override val id = "/" + override val url = "ksu://icon".toUri() + + override fun handle(request: WebUIResourceRequest): WebResourceResponse { + val path = request.path + return requestIcon(path) + } + + private fun requestIcon(packageName: String): WebResourceResponse { + val appInfo = packages + .find { it.packageName == packageName } + ?.applicationInfo + if (appInfo != null) { + val drawable = appInfo.loadIcon(kontext.packageManager) + val bitmap = drawable.toBitmap() + val stream = java.io.ByteArrayOutputStream() + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream) + return WebResourceResponse( + "image/png", null, 200, "OK", + mapOf("Access-Control-Allow-Origin" to "*"), + java.io.ByteArrayInputStream(stream.toByteArray()) + ) + } else { + val errorMsg = "No such package" + val errorStream = + java.io.ByteArrayInputStream(errorMsg.toByteArray(Charsets.UTF_8)) + return WebResourceResponse( + "text/plain", + "utf-8", + 404, + "Not Found", + mapOf("Access-Control-Allow-Origin" to "*"), + errorStream + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/ErrorPage.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/ErrorPage.kt new file mode 100644 index 00000000..9f61b6ff --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/ErrorPage.kt @@ -0,0 +1,105 @@ +package com.dergoogler.mmrl.wx.ui.webui.util + +import android.webkit.WebResourceResponse +import com.dergoogler.mmrl.ext.nullable +import com.dergoogler.mmrl.webui.R +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.PathHandler +import kotlinx.html.DIV +import kotlinx.html.body +import kotlinx.html.button +import kotlinx.html.div +import kotlinx.html.head +import kotlinx.html.html +import kotlinx.html.lang +import kotlinx.html.li +import kotlinx.html.link +import kotlinx.html.meta +import kotlinx.html.onClick +import kotlinx.html.span +import kotlinx.html.stream.appendHTML +import kotlinx.html.title +import kotlinx.html.ul + +internal fun PathHandler.errorResponse( + title: String, + description: (DIV.(WebUI) -> Unit)? = null, + tryFollowing: List = emptyList(), + errorCode: String = "UNDEFINED", + errorSvgIcon: (DIV.(WebUI) -> Unit)? = null, + extraButtons: (DIV.(WebUI) -> Unit)? = null, +): WebResourceResponse { + val str = buildString { + with(kontext) { + appendHTML().html { + lang = "en" + head { + meta { + name = "viewport" + content = + "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" + } + link { + rel = "stylesheet" + href = "${baseUri}/internal/insets.css" + } + link { + rel = "stylesheet" + href = "${baseUri}/internal/colors.css" + } + link { + rel = "stylesheet" + href = "${baseUri}/internal/assets/error-page.css" + } + title { +title } + } + body { + div(classes = "container") { + div(classes = "content") { + errorSvgIcon.nullable { + div(classes = "error-icon") { + it(this@errorResponse) + } + } + + div(classes = "title") { +title } + + description.nullable { + div(classes = "description") { + it(this@errorResponse) + } + } + + if (tryFollowing.isNotEmpty()) { + div(classes = "list") { + span { +getString(R.string.requireNewVersion_try_the_following) } + ul { + tryFollowing.forEach { item -> + li { + +item + } + } + } + } + } + + div(classes = "code") { +errorCode } + div(classes = "buttons") { + button(classes = "refresh") { + onClick = "location.reload();" + +getString(R.string.requireNewVersion_refresh) + } + + extraButtons.nullable { + it(this@errorResponse) + } + } + } + } + } + } + } + } + + return htmlResponse(str) +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/LuaPlugin.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/LuaPlugin.kt new file mode 100644 index 00000000..4d611080 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/LuaPlugin.kt @@ -0,0 +1,18 @@ +package com.dergoogler.mmrl.wx.ui.webui.util + +import com.dergoogler.mmrl.wx.ui.webui.module +import dev.mmrlx.webui.WebUI +import dev.mmrlx.webui.registerLuaPlugin + +// TODO: expose new module paths data class with WebUISettings.extra() +fun WebUI.luaPlugin(): WebUI { + // Only register the Lua plugin when the `index.lua` file exists + val indexLuaFile = file(module.path.webrootLuaIndex) + + if (!indexLuaFile.exists()) return this + + return registerLuaPlugin { + plugin(indexLuaFile.path) + global["mod"] = module.toLuaTable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/PackageManager.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/PackageManager.kt new file mode 100644 index 00000000..c53ea1f8 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/PackageManager.kt @@ -0,0 +1,46 @@ +package com.dergoogler.mmrl.wx.ui.webui.util + +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.Build +import com.dergoogler.mmrl.wx.BuildConfig +import dev.mmrlx.webui.WebUI + +private fun PackageManager.getInstalledPackagesCompat(): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getInstalledPackages( + PackageManager.PackageInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + getInstalledPackages(0) + } +} + +private fun PackageManager.getLaunchableApps(): List { + val intent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities( + intent, + PackageManager.ResolveInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + queryIntentActivities(intent, 0) + } +} + +val WebUI.packages: List + get() = with(kontext) { + if (BuildConfig.IS_GOOGLE_PLAY_BUILD) { + return packageManager.getLaunchableApps() + .map { packageManager.getPackageInfo(it.activityInfo.packageName, 0) } + } + + return packageManager.getInstalledPackagesCompat() + } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/Responses.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/Responses.kt new file mode 100644 index 00000000..dc793746 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/Responses.kt @@ -0,0 +1,189 @@ +package com.dergoogler.mmrl.wx.ui.webui.util + +import android.webkit.WebResourceResponse +import com.dergoogler.mmrl.webui.MimeUtil +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.inputStream +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.util.zip.GZIPInputStream + +enum class ResponseStatus(val code: Int, val reasonPhrase: String) { + OK(200, "OK"), + NOT_FOUND(404, "Not Found"), + FORBIDDEN(403, "Forbidden"), +} + +private fun SuFile.checkStatus(): ResponseStatus { + if (!exists()) return ResponseStatus.NOT_FOUND + + return ResponseStatus.OK +} + +const val encoding = "UTF-8" + +val headers + get() = mapOf( + "Client-Via" to "MMRL WebUI", + "Access-Control-Allow-Origin" to "*", + ) + + +fun InputStream.inject(fromTag: (ByteArray) -> Int, code: String): InputStream { + val cssBytes = code.toByteArray() + + val outputStream = ByteArrayOutputStream() + val buffer = ByteArray(1024) + var bytesRead: Int + while (this.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + + val modifiedHtml = outputStream.toByteArray() + val index = fromTag(modifiedHtml) + + return if (index != -1) { + ByteArrayInputStream( + modifiedHtml.copyOfRange( + 0, + index + ) + cssBytes + modifiedHtml.copyOfRange(index, modifiedHtml.size) + ) + } else { + ByteArrayInputStream(modifiedHtml) + } +} + +fun InputStream.headInject(code: String): InputStream = inject(::findHeadTag, code) +fun InputStream.bodyInject(code: String): InputStream = inject(::findBodyTag, code) + +private fun findHeadTag(htmlBytes: ByteArray): Int { + val headTag = "".toByteArray() + for (i in 0..htmlBytes.size - headTag.size) { + if (htmlBytes.copyOfRange(i, i + headTag.size).contentEquals(headTag)) { + return i + } + } + return -1 +} + +private fun findBodyTag(htmlBytes: ByteArray): Int { + val bodyTag = "".toByteArray() + for (i in 0..htmlBytes.size - bodyTag.size) { + if (htmlBytes.copyOfRange(i, i + bodyTag.size).contentEquals(bodyTag)) { + return i + } + } + return -1 +} + +enum class InjectionType { + HEAD, BODY +} + +fun MutableList.addInjection( + type: InjectionType = InjectionType.HEAD, + code: StringBuilder.() -> Unit, +) = addInjection(buildString(code), type) + +fun MutableList.addInjection( + code: String, + type: InjectionType = InjectionType.HEAD, +) = add(Injection(type, code)) + +data class Injection( + val type: InjectionType, + val code: String, +) + +@Throws(IOException::class) +fun SuFile.asResponse(injects: List? = null): WebResourceResponse { + val mimeType = MimeUtil.getMimeFromFileName(path) + val status = checkStatus() + + val err = WebResourceResponse( + null, + encoding, + status.code, + status.reasonPhrase, + headers, + null + ) + + return when (status) { + ResponseStatus.FORBIDDEN -> err + + ResponseStatus.OK -> { + val stream = inputStream() as InputStream + val modifiedStream = injects?.fold(stream) { currentStream, inject -> + when (inject.type) { + InjectionType.HEAD -> currentStream.headInject(inject.code) + InjectionType.BODY -> currentStream.bodyInject(inject.code) + } + } ?: stream + + val `is` = handleSvgzStream(modifiedStream) + + WebResourceResponse( + mimeType, + encoding, + status.code, + status.reasonPhrase, + headers, + `is` + ) + } + + ResponseStatus.NOT_FOUND -> err + } +} + +@Throws(IOException::class) +fun SuFile.handleSvgzStream( + stream: InputStream, +): InputStream { + return if (extension == "svgz") GZIPInputStream(stream) else stream +} + +fun String.asStyleResponse(): WebResourceResponse { + val inputStream: InputStream = + ByteArrayInputStream(this.toByteArray(StandardCharsets.UTF_8)) + + return WebResourceResponse( + "text/css", + "UTF-8", + inputStream + ) +} + +fun String.asScriptResponse(): WebResourceResponse { + val inputStream: InputStream = + ByteArrayInputStream(this.toByteArray(StandardCharsets.UTF_8)) + + return WebResourceResponse( + "text/javascript", + "UTF-8", + inputStream + ) +} + +val notFoundResponse = WebResourceResponse( + null, + encoding, + ResponseStatus.NOT_FOUND.code, + ResponseStatus.NOT_FOUND.reasonPhrase, + headers, + null +) + +val forbiddenResponse = WebResourceResponse( + null, + encoding, + ResponseStatus.FORBIDDEN.code, + ResponseStatus.FORBIDDEN.reasonPhrase, + headers, + null +) \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/producePlatformState.kt b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/producePlatformState.kt new file mode 100644 index 00000000..70ef1253 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/ui/webui/util/producePlatformState.kt @@ -0,0 +1,61 @@ +package com.dergoogler.mmrl.wx.ui.webui.util + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import com.dergoogler.mmrl.platform.PlatformManager +import com.dergoogler.mmrl.platform.TIMEOUT_MILLIS +import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.util.initPlatform +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.cancellation.CancellationException + +@Composable +fun isPlatformAlive(): State { + val context = LocalContext.current + val prefs = LocalUserPreferences.current + return produceState(false) { + value = initPlatform(context, prefs.workingMode.toPlatform()) + } +} + +/** + * Only collects, no init + */ +@Composable +fun producePlatformState( + fallback: T, + vararg keys: Any?, + timeoutMillis: Long = TIMEOUT_MILLIS, + block: suspend CoroutineScope.() -> T, +): State { + return produceState(initialValue = fallback, keys = arrayOf(*keys, PlatformManager)) { + PlatformManager.isAliveFlow.collectLatest { isAlive -> + if (isAlive) { + Log.d(PlatformManager.TAG, "Platform is alive, executing block.") + try { + val result = withTimeoutOrNull(timeoutMillis) { + block() + } + + if (result != null) { + value = result + } else { + Log.w(PlatformManager.TAG, "Block execution timed out.") + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(PlatformManager.TAG, "Error executing block.", e) + } + } else { + Log.d(PlatformManager.TAG, "Platform not alive; using fallback.") + value = fallback + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/util/BaseActivity.kt b/app/src/main/java/com/dergoogler/mmrl/wx/util/BaseActivity.kt index 13087f28..f32c4794 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/util/BaseActivity.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/util/BaseActivity.kt @@ -26,6 +26,7 @@ import com.dergoogler.mmrl.wx.viewmodel.LocalSettings import com.dergoogler.mmrl.wx.viewmodel.SettingsViewModel import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator import dagger.hilt.android.AndroidEntryPoint +import dev.mmrlx.compose.ui.theme.MMRLXTheme import javax.inject.Inject @AndroidEntryPoint @@ -79,18 +80,22 @@ fun BaseActivity.setBaseContent( checkNotNull(userPreferences) } - MMRLAppTheme( - darkMode = preferences.isDarkMode(), - navController = navController, - themeColor = preferences.themeColor, - providerValues = arrayOf( - LocalUserPreferences provides preferences, - LocalNavController provides navController, - LocalSettings provides settings, - LocalDestinationsNavigator provides navigator - ), - content = content - ) + MMRLXTheme( + darkTheme = preferences.isDarkMode() + ) { + MMRLAppTheme( + darkMode = preferences.isDarkMode(), + navController = navController, + themeColor = preferences.themeColor, + providerValues = arrayOf( + LocalUserPreferences provides preferences, + LocalNavController provides navController, + LocalSettings provides settings, + LocalDestinationsNavigator provides navigator + ), + content = content + ) + } } fun ComponentActivity.initPlatform(userPreferences: UserPreferences) { diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/util/Extensions.kt b/app/src/main/java/com/dergoogler/mmrl/wx/util/Extensions.kt index 169259ac..5a3824e2 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/util/Extensions.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/util/Extensions.kt @@ -14,7 +14,6 @@ import com.dergoogler.mmrl.ext.navigateSingleTopTo import com.dergoogler.mmrl.ext.toFormattedDateSafely import com.dergoogler.mmrl.platform.Platform import com.dergoogler.mmrl.platform.PlatformManager -import com.dergoogler.mmrl.platform.content.LocalModule import com.dergoogler.mmrl.platform.file.SuFile import com.dergoogler.mmrl.platform.model.ModId import com.dergoogler.mmrl.platform.model.ModId.Companion.putBaseDir @@ -24,12 +23,23 @@ import com.dergoogler.mmrl.webui.activity.WXActivity.Companion.launchWebUIX import com.dergoogler.mmrl.webui.interfaces.WXInterface import com.dergoogler.mmrl.wx.datastore.model.UserPreferences import com.dergoogler.mmrl.wx.datastore.providable.LocalUserPreferences +import com.dergoogler.mmrl.wx.model.module.Module import com.dergoogler.mmrl.wx.ui.activity.modconf.ModConfActivity import com.dergoogler.mmrl.wx.ui.activity.webui.WebUIActivity +import dev.mmrlx.nio.Path +import dev.mmrlx.thread.RootArgs +import dev.mmrlx.thread.RootCallable +import dev.mmrlx.thread.RootThread import kotlinx.coroutines.CoroutineScope +import org.luaj.LuaTable +import org.luaj.LuaValue +import org.luaj.Varargs +import org.luaj.lib.VarArgFunction import java.io.BufferedInputStream import java.io.File +import java.io.FileInputStream import java.io.FileOutputStream +import java.io.InputStream import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import kotlin.reflect.full.memberProperties @@ -39,6 +49,8 @@ fun Context.extractZipFromAssets( assetName: String, outputDir: File, ) { + if (outputDir.exists()) return + if (!outputDir.exists()) { outputDir.mkdirs() } @@ -70,7 +82,7 @@ fun Context.extractZipFromAssets( } } -val LocalModule.versionDisplay +val Module.versionDisplay get(): String { val included = "\\(.*?${versionCode}.*?\\)".toRegex() .containsMatchIn(version) @@ -109,7 +121,7 @@ private suspend fun init( platform: Platform, context: Context, self: PlatformManager, -): IServiceManager? { +): IServiceManager { if (platform.isNonRoot) { return self.from( NonRootProvider( @@ -182,6 +194,10 @@ inline fun Map?.getProp(key: String, def: T): T { inline fun Map?.getPropOrNull(key: String): T? = getProp(key, null) +@Throws(BrickException::class) +fun Context.getNonRootBaseDir(): File = + getExternalFilesDir(null) ?: throw BrickException("Failed to get filesDir") + fun Context.getBaseDir( platform: Platform = PlatformManager.platform, ): SuFile { @@ -248,4 +264,33 @@ inline fun Map.toDataClass(): T { inline fun WXInterface.scrambleClassName(): String { val className = T::class.simpleName ?: name return className.toList().shuffled().joinToString("") +} + +fun rootSync(args: Map? = null, block: RootCallable): T { + if (args == null) return RootThread.submit(block).get() + val mArgs = RootArgs.of(args) + return RootThread.submit(block, mArgs).get() +} + +fun RootCallable.sync(args: Map? = null): T = rootSync(args, this) + +fun File.inputStream0(): InputStream = + rootSync { FileInputStream(this).use { readBytes() } }.inputStream() + +fun LuaTable.set(key: String, value: Boolean) = set(key, LuaValue.valueOf(value)) + +inline fun Varargs.map(transform: (LuaValue) -> T): List = + (1..this.narg()).map { transform(this.arg(it)) } + +inline fun Varargs.mapNotLuaNil(transform: (LuaValue) -> T?): List = + (1..this.narg()) + .map { this.arg(it) } + .filterNot { it.isnil() } + .mapNotNull { transform(it) } + +class PathVarArgFunction(private val basePath: String) : VarArgFunction() { + override fun invoke(args: Varargs): LuaValue { + val elements = args.mapNotLuaNil { it.checkjstring() }.toTypedArray() + return valueOf(Path.parse(basePath, *elements)) + } } \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/util/IoExt.kt b/app/src/main/java/com/dergoogler/mmrl/wx/util/IoExt.kt new file mode 100644 index 00000000..0bb552eb --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/util/IoExt.kt @@ -0,0 +1,22 @@ +package com.dergoogler.mmrl.wx.util + +import android.graphics.BitmapFactory +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.inputStream + +@Composable +fun SuFile.toPainter(): BitmapPainter { + val bitmap = remember(absolutePath, lastModified()) { + inputStream().use { + BitmapFactory.decodeStream(it).asImageBitmap() + } + } + + return remember(bitmap) { + BitmapPainter(bitmap) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/util/MXExt.kt b/app/src/main/java/com/dergoogler/mmrl/wx/util/MXExt.kt new file mode 100644 index 00000000..bcfea31c --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/util/MXExt.kt @@ -0,0 +1,47 @@ +@file:Suppress("UnusedReceiverParameter") + +package com.dergoogler.mmrl.wx.util + +import androidx.compose.ui.graphics.Color +import dev.mmrlx.compose.ui.theme.Colors +import dev.mmrlx.compose.ui.theme.Oklch + +val Colors.badgeErrorForeground: Color + get() = if (isDark) { + Oklch(0.71, 0.19, 13.43) + } else { + Oklch(0.59, 0.25, 17.59) + }.toColor() + +val Colors.badgeErrorBackground: Color + get() = Oklch(0.65, 0.25, 16.44).mixWithTransparent(0.15f).toColor() + +val Colors.badgeWarnBackground: Color + get() = Oklch(0.77, 0.19, 70.08).mixWithTransparent(0.15f).toColor() + +val Colors.badgeInfoBackground: Color + get() = Oklch(0.68, 0.17, 237.32).mixWithTransparent(0.15f).toColor() + +val Colors.badgeDebugBackground: Color + get() = Oklch(0.61, 0.25, 292.72).mixWithTransparent(0.15f).toColor() + +val Colors.badgeWarnForeground: Color + get() = if (isDark) { + Oklch(0.83, 0.19, 84.43) + } else { + Oklch(0.67, 0.18, 58.32) + }.toColor() + +val Colors.badgeDebugForeground: Color + get() = if (isDark) { + Oklch(0.7, 0.18, 293.54) + } else { + Oklch(0.54, 0.28, 293.01) + }.toColor() + +val Colors.badgeInfoForeground: Color + get() = if (isDark) { + Oklch(0.75, 0.16, 232.65) + } else { + Oklch(0.59, 0.16, 241.96) + }.toColor() diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/util/PermissionParser.kt b/app/src/main/java/com/dergoogler/mmrl/wx/util/PermissionParser.kt new file mode 100644 index 00000000..d3e11f40 --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/util/PermissionParser.kt @@ -0,0 +1,84 @@ +package com.dergoogler.mmrl.wx.util + +object PermissionParser { + @Throws(IllegalArgumentException::class) + fun parse(input: Any): Int { + return when (input) { + + is Int -> { + validateMode(input) + input + } + + is String -> { + val trimmed = input.trim() + + val mode = when { + // symbolic form: rwxr-xr-x + trimmed.length == 9 && trimmed.all { it in "rwx-" } -> + symbolicToInt(trimmed) + + // octal string: "0755" or "644" + trimmed.all { it in '0'..'7' } -> + octalStringToInt(trimmed) + + else -> error("Unknown permission format: $input") + } + + validateMode(mode) + mode + } + + else -> error("Unsupported type: ${input::class}") + } + } + + private fun symbolicToInt(symbolic: String): Int { + require(symbolic.length == 9) + + var result = 0 + + for (i in 0 until 3) { + val chunk = symbolic.substring(i * 3, i * 3 + 3) + + var digit = 0 + if (chunk[0] == 'r') digit += 4 + if (chunk[1] == 'w') digit += 2 + if (chunk[2] == 'x') digit += 1 + + result = (result shl 3) or digit + } + + return result + } + + private fun octalStringToInt(octal: String): Int { + require(octal.all { it in '0'..'7' }) + return octal.toInt(8) + } + + private fun validateMode(mode: Int) { + require(mode in 0..0xFFF) { + "Invalid Unix mode: $mode" + } + } + + fun toOctal(mode: Int): String = + mode.toString(8).padStart(4, '0') + + fun toSymbolic(mode: Int): String { + var result = "" + + for (i in 2 downTo 0) { + val d = (mode shr (i * 3)) and 0b111 + + result += buildString { + append(if (d and 4 != 0) 'r' else '-') + append(if (d and 2 != 0) 'w' else '-') + append(if (d and 1 != 0) 'x' else '-') + } + } + + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/DevToolsViewModel.kt b/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/DevToolsViewModel.kt new file mode 100644 index 00000000..b07306ac --- /dev/null +++ b/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/DevToolsViewModel.kt @@ -0,0 +1,18 @@ +package com.dergoogler.mmrl.wx.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.dergoogler.mmrl.wx.datastore.UserPreferencesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.mmrlx.webui.CSSActions +import javax.inject.Inject + +@HiltViewModel +class DevToolsViewModel @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository, +) : ViewModel() { + var borderAllState by mutableStateOf(null) + var editContentState by mutableStateOf(null) +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/FileExplorerViewModel.kt b/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/FileExplorerViewModel.kt index 54a25f5e..d5f888ff 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/FileExplorerViewModel.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/FileExplorerViewModel.kt @@ -8,15 +8,15 @@ import androidx.annotation.DrawableRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dergoogler.mmrl.compat.MediaStoreCompat.getPathForUri -import com.dergoogler.mmrl.platform.file.SuFile -import com.dergoogler.mmrl.platform.file.SuFile.Companion.toSuFile -import com.dergoogler.mmrl.platform.file.SuFileInputStream -import com.dergoogler.mmrl.platform.file.SuFileOutputStream -import com.dergoogler.mmrl.wx.R import com.dergoogler.mmrl.wx.datastore.UserPreferencesRepository import com.dergoogler.mmrl.wx.util.wxContentResolver import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import dev.mmrlx.compose.ui.filetree.material.MaterialIconResolver +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.SuFile.Companion.toSuFile +import dev.mmrlx.nio.SuFileInputStream +import dev.mmrlx.nio.SuFileOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -59,11 +59,15 @@ class FileExplorerViewModel @Inject constructor( val state: StateFlow = _state.asStateFlow() fun initialize(initialPath: SuFile) { - _state.value = _state.value.copy( - currentPath = initialPath, - isLoading = true - ) - loadFiles(initialPath) + viewModelScope.launch { + _state.value = _state.value.copy( + currentPath = initialPath, + isLoading = true, + errorMessage = null, + successMessage = null + ) + loadFiles(initialPath) + } } fun navigateToDirectory(directory: SuFile) { @@ -75,7 +79,13 @@ class FileExplorerViewModel @Inject constructor( } val currentState = _state.value - val currentPath = currentState.currentPath ?: return + val currentPath = currentState.currentPath + if (currentPath == null) { + _state.value = _state.value.copy( + errorMessage = "Current path is invalid" + ) + return + } _state.value = currentState.copy( currentPath = directory, @@ -90,7 +100,12 @@ class FileExplorerViewModel @Inject constructor( fun navigateBack() { val currentState = _state.value - if (currentState.pathHistory.isEmpty()) return + if (currentState.pathHistory.isEmpty()) { + _state.value = _state.value.copy( + errorMessage = "Cannot navigate back - already at root" + ) + return + } val previousPath = currentState.pathHistory.last() val newHistory = currentState.pathHistory.dropLast(1) @@ -109,7 +124,13 @@ class FileExplorerViewModel @Inject constructor( fun navigateToPath(path: SuFile) { val currentState = _state.value - val currentPath = currentState.currentPath ?: return + val currentPath = currentState.currentPath + if (currentPath == null) { + _state.value = _state.value.copy( + errorMessage = "Current path is invalid" + ) + return + } _state.value = currentState.copy( currentPath = path, @@ -156,7 +177,7 @@ class FileExplorerViewModel @Inject constructor( val result = withContext(Dispatchers.IO) { try { - val newFolder = SuFile(currentPath, folderName) + val newFolder = SuFile.async(currentPath, folderName) if (newFolder.exists()) { FileOperationResult.Error("Folder already exists") } else if (newFolder.mkdirs()) { @@ -202,7 +223,7 @@ class FileExplorerViewModel @Inject constructor( val result = withContext(Dispatchers.IO) { try { - val newFile = SuFile(currentPath, fileName) + val newFile = SuFile.async(currentPath, fileName) if (newFile.exists()) { FileOperationResult.Error("File already exists") } else { @@ -555,23 +576,17 @@ class FileExplorerViewModel @Inject constructor( @DrawableRes private fun getFileIcon(file: SuFile): Int { - return if (file.isDirectory()) { - R.drawable.folder + val fallbackRes = when { + file.isDirectory -> dev.mmrlx.ui.R.drawable.folder + else -> dev.mmrlx.ui.R.drawable.file + } + + val resId = if (file.isDirectory) { + MaterialIconResolver.resolveFolderDrawable(file.name, false) } else { - when (file.extension.lowercase()) { - "jpg", "jpeg", "png", "gif", "bmp", "webp" -> R.drawable.photo - "mp4", "avi", "mkv", "mov", "wmv", "flv" -> R.drawable.movie - "mp3", "wav", "flac", "aac", "ogg", "m4a" -> R.drawable.headphones - "pdf" -> R.drawable.file_type_pdf - "mjs", "cjs", "js" -> R.drawable.file_type_js - "htm", "html", "htmlx" -> R.drawable.file_type_html - "bash", "sh" -> R.drawable.terminal - "css" -> R.drawable.file_type_css - "txt", "md", "log" -> R.drawable.file_text - "zip", "rar", "7z", "tar", "gz" -> R.drawable.file_zip - "apk" -> com.dergoogler.mmrl.ui.R.drawable.brand_android - else -> R.drawable.file - } + MaterialIconResolver.resolveFileDrawable(file.name) } + + return resId ?: fallbackRes } } diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/ModulesViewModel.kt b/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/ModulesViewModel.kt index 90b0c575..9b161906 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/ModulesViewModel.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/ModulesViewModel.kt @@ -1,238 +1,197 @@ +// ModulesViewModel.kt package com.dergoogler.mmrl.wx.viewmodel +import android.app.Application +import android.content.Context import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.dergoogler.mmrl.datastore.model.ModulesMenu import com.dergoogler.mmrl.datastore.model.Option -import com.dergoogler.mmrl.platform.Platform -import com.dergoogler.mmrl.platform.PlatformManager -import com.dergoogler.mmrl.platform.content.LocalModule -import com.dergoogler.mmrl.platform.content.LocalModule.Companion.hasAction -import com.dergoogler.mmrl.platform.content.LocalModule.Companion.hasWebUI import com.dergoogler.mmrl.platform.content.State import com.dergoogler.mmrl.wx.datastore.UserPreferencesRepository +import com.dergoogler.mmrl.wx.model.module.AdbPath +import com.dergoogler.mmrl.wx.model.module.Module import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope +import dev.mmrlx.nio.SuFile +import dev.mmrlx.nio.SuFileSystemManager +import dev.mmrlx.nio.inputStream +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import javax.inject.Inject -data class ModulesScreenState( - val items: List = listOf(), - val isRefreshing: Boolean = false, -) - @HiltViewModel class ModulesViewModel @Inject constructor( + private val application: Application, private val userPreferencesRepository: UserPreferencesRepository, -) : ViewModel() { +) : AndroidViewModel(application) { - val isProviderAlive get() = PlatformManager.isAlive + val context: Context get() = application.applicationContext - val platform - get() = PlatformManager.get(Platform.Unknown) { - platform - } + private val sourceFlow = MutableStateFlow>(emptyList()) - private val sourceFlow = MutableStateFlow(listOf()) - private val cacheFlow = MutableStateFlow(listOf()) - private val localFlow = MutableStateFlow(listOf()) - val local get() = localFlow.asStateFlow() + private val _isLoaded = MutableStateFlow(false) + val isLoaded: StateFlow = _isLoaded.asStateFlow() - private val modulesMenu - get() = userPreferencesRepository.data.map { it.modulesMenu } + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() - var isSearch by mutableStateOf(false) - private set + private val _refreshDone = Channel(Channel.CONFLATED) + val refreshDone = _refreshDone.receiveAsFlow() private val keyFlow = MutableStateFlow("") - val query get() = keyFlow.asStateFlow() - - private val isLoadingFlow = MutableStateFlow(false) - val isLoading get() = isLoadingFlow.asStateFlow() - - init { - providerObserver() - dataObserver() - keyObserver() - } - - private fun providerObserver() { - viewModelScope.launch { - with(PlatformManager) { - if (platform.isNonRoot) { - try { - getLocalAll() - } catch (e: Exception) { - Log.e(TAG, "Initial load failed", e) + val query: StateFlow = keyFlow.asStateFlow() + + private val _isSearch = MutableStateFlow(false) + val isSearch: StateFlow = _isSearch.asStateFlow() + + private val modulesMenu = userPreferencesRepository.data.map { it.modulesMenu } + + val local: StateFlow> = keyFlow + .combine(sourceFlow) { key, source -> key to source } + .combine(modulesMenu) { (key, source), menu -> + val sorted = source + .sortedWith(comparator(menu.option, menu.descending)) + .let { if (menu.pinEnabled) it.sortedByDescending { m -> m.state == State.ENABLE } else it } + .let { if (menu.pinWebUI) it.sortedByDescending { m -> m.hasWebUI } else it } + + if (key.isBlank()) sorted + else { + val (newKey, prefix) = parseSearchKey(key) + sorted.filter { module -> + when (prefix) { + "id" -> module.id.equals(newKey, ignoreCase = true) + "name" -> module.name.equals(newKey, ignoreCase = true) + "author" -> module.author.equals(newKey, ignoreCase = true) + else -> module.name.contains(key, ignoreCase = true) || + module.author.contains(key, ignoreCase = true) || + module.description.contains(key, ignoreCase = true) } } - - isAliveFlow - .onEach { - if (it) getLocalAll() - - }.launchIn(viewModelScope) } } - } - - private fun dataObserver() { - sourceFlow - .combine(modulesMenu) { list, menu -> - if (list.isEmpty()) { - isLoadingFlow.update { false } - return@combine - } - - cacheFlow.value = list.sortedWith( - comparator(menu.option, menu.descending) - ).let { v -> - val a = if (menu.pinEnabled) { - v.sortedByDescending { it.state == State.ENABLE } - } else v - - val b = if (menu.pinAction) { - a.sortedByDescending { it.hasAction } - } else a - - if (menu.pinWebUI) { - b.sortedByDescending { it.hasWebUI } - } else b - } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) - isLoadingFlow.update { false } - } + init { + viewModelScope.launch { SuFile.AutoInit(context) } + SuFileSystemManager.isAliveFlow + .onEach { if (it) getLocalAll() } .launchIn(viewModelScope) } - private fun keyObserver() { - keyFlow.combine(cacheFlow) { key, source -> - val newKey = when { - key.startsWith("id:", ignoreCase = true) -> key.removePrefix("id:") - key.startsWith("name:", ignoreCase = true) -> key.removePrefix("name:") - key.startsWith("author:", ignoreCase = true) -> key.removePrefix("author:") - else -> key - }.trim() - - localFlow.value = source.filter { - if (key.isNotBlank() || newKey.isNotBlank()) { - when { - key.startsWith("id:", ignoreCase = true) -> - it.id.equals(newKey, ignoreCase = true) - - key.startsWith("name:", ignoreCase = true) -> - it.name.equals(newKey, ignoreCase = true) - - key.startsWith("author:", ignoreCase = true) -> - it.author.equals(newKey, ignoreCase = true) - - else -> - it.name.contains(key, ignoreCase = true) || - it.author.contains(key, ignoreCase = true) || - it.description.contains(key, ignoreCase = true) - } - } else { - true - } - } - }.launchIn(viewModelScope) - } - fun search(key: String) { keyFlow.value = key } fun openSearch() { - isSearch = true + _isSearch.value = true } fun closeSearch() { - isSearch = false - keyFlow.value = "" + _isSearch.value = false; keyFlow.value = "" } - private fun comparator(option: Option, descending: Boolean): Comparator = - if (descending) { - when (option) { - Option.Name -> compareByDescending { it.name.lowercase() } - Option.UpdatedTime -> compareBy { it.lastUpdated } - Option.Size -> compareBy { it.size } - } - } else { - when (option) { - Option.Name -> compareBy { it.name.lowercase() } - Option.UpdatedTime -> compareByDescending { it.lastUpdated } - Option.Size -> compareByDescending { it.size } - } - } - fun setModulesMenu(value: ModulesMenu) { - viewModelScope.launch { - userPreferencesRepository.setModulesMenu(value) - } + viewModelScope.launch { userPreferencesRepository.setModulesMenu(value) } } - private inline fun T.refreshing(callback: T.() -> Unit) { - isLoadingFlow.update { true } - callback() - isLoadingFlow.update { false } + fun refreshModules() { + viewModelScope.launch { getLocalAll() } } - private fun getDefaultList() = if (PlatformManager.platform.isNonRoot) { - PlatformManager.moduleManager.modules - } else { - emptyList() + private suspend fun getLocalAll() { + _isRefreshing.value = true + try { + runCatching { getLocalModules() } + .onSuccess { sourceFlow.value = it } + .onFailure { Log.e(TAG, "Error fetching modules", it) } + } finally { + _isRefreshing.value = false + _isLoaded.value = true + _refreshDone.trySend(Unit) + } } - fun getModules() = PlatformManager.getAsyncDeferred( - viewModelScope, - getDefaultList() - ) { - with(moduleManager) { - modules - } + private fun parseSearchKey(raw: String): Pair { + val prefix = listOf("id", "name", "author") + .firstOrNull { raw.startsWith("$it:", ignoreCase = true) } + return if (prefix != null) raw.removePrefix("$prefix:").trim() to prefix + else raw.trim() to null } - fun getLocalAll(scope: CoroutineScope = viewModelScope) = scope.launch { - refreshing { - try { - val modules = getModules() - sourceFlow.value = modules.await() - } catch (e: Exception) { - Log.e(TAG, "Error fetching modules", e) - } + private fun comparator(option: Option, descending: Boolean): Comparator { + val base: Comparator = when (option) { + Option.Name -> compareBy { it.name.lowercase() } + Option.UpdatedTime -> compareByDescending { it.lastUpdated } + Option.Size -> compareByDescending { it.size } } + return if (descending) base.reversed() else base } - private fun getLocalAllAsFlow(): StateFlow> { - return sourceFlow - } + val adbPath: AdbPath + get() = runBlocking { + val prefs = userPreferencesRepository.data.first() + return@runBlocking AdbPath(prefs.getAdbPath(context)) + } + + suspend fun getLocalModules(): List { + val prefs = userPreferencesRepository.data.first() + val basePath = prefs.getAdbPath(context) + val adbPath = AdbPath(basePath) + + Log.d(TAG, "BasePath=$basePath") +// Log.d(TAG, "ModulesDir=${adbPath.modulesDir}") + + val modulesDir = SuFile(adbPath.modulesDir) - val screenState: StateFlow = getLocalAllAsFlow() - .combine(isLoadingFlow) { items, isRefreshing -> - ModulesScreenState(items = items, isRefreshing = isRefreshing) + if (!modulesDir.exists()) { + Log.e(TAG, "Modules directory does not exist") + return emptyList() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = ModulesScreenState() - ) + + Log.d(TAG, "ModulesDir=${modulesDir}") + + val dirs = modulesDir.listFiles() ?: run { + Log.e(TAG, "listFiles() returned null") + return emptyList() + } + + Log.d(TAG, "Dirs=${dirs}") + + + return dirs.mapNotNull { dir -> + runCatching { + val propFile = SuFile(dir, "module.prop") + if (!propFile.exists()) { + Log.w(TAG, "Missing module.prop in ${dir.path}") + return@mapNotNull null + } + Module(adbPath, Module.readProps(propFile.inputStream())) + }.onFailure { + Log.e(TAG, "Failed parsing module ${dir.path}", it) + }.getOrNull() + } + } + + fun findById(id: String): Module? = local.value.find { it.id == id } companion object { private const val TAG = "ModulesViewModel" } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/SettingsViewModel.kt index 1e443719..1dd4337c 100644 --- a/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/dergoogler/mmrl/wx/viewmodel/SettingsViewModel.kt @@ -4,10 +4,10 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dergoogler.mmrl.datastore.model.DarkMode -import com.dergoogler.mmrl.datastore.model.WorkingMode import com.dergoogler.mmrl.platform.PlatformManager import com.dergoogler.mmrl.wx.datastore.UserPreferencesRepository import com.dergoogler.mmrl.wx.datastore.model.WebUIEngine +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -54,6 +54,12 @@ class SettingsViewModel @Inject constructor( } } + fun setAdbPath(value: String) { + viewModelScope.launch { + userPreferencesRepository.setAdbPath(value) + } + } + fun setWebUiDevUrl(value: String) { viewModelScope.launch { userPreferencesRepository.setWebUiDevUrl(value) @@ -102,6 +108,12 @@ class SettingsViewModel @Inject constructor( } } + fun setDisableConsoleInterceptor(value: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setDisableConsoleInterceptor(value) + } + } + fun setWebUIEngine(value: WebUIEngine) { viewModelScope.launch { userPreferencesRepository.setWebUIEngine(value) diff --git a/app/src/main/res/drawable/files.xml b/app/src/main/res/drawable/files.xml new file mode 100644 index 00000000..774d543b --- /dev/null +++ b/app/src/main/res/drawable/files.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/git_branch.xml b/app/src/main/res/drawable/git_branch.xml new file mode 100644 index 00000000..42d19fbd --- /dev/null +++ b/app/src/main/res/drawable/git_branch.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/git_commit.xml b/app/src/main/res/drawable/git_commit.xml new file mode 100644 index 00000000..a2346cc1 --- /dev/null +++ b/app/src/main/res/drawable/git_commit.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/launcher_explorer_foreground.xml b/app/src/main/res/drawable/launcher_explorer_foreground.xml new file mode 100644 index 00000000..9aa186cf --- /dev/null +++ b/app/src/main/res/drawable/launcher_explorer_foreground.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/launcher_explorer.xml b/app/src/main/res/mipmap-anydpi-v26/launcher_explorer.xml new file mode 100644 index 00000000..36aa0227 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/launcher_explorer.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 366a919a..16491f4b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,11 +49,14 @@ Show Anti-Features Injecting Eruda cannot be used with WebUI Remote URL Add Shortcut + Create Shortcut + Make you own shortcut for this module Remove Shortcut Non-Root WebUI Engine Select your preferred engine WebUI X + WebUI MX KSU WebUI Defined by the Module Config Undefined @@ -64,8 +67,12 @@ The date pattern will be parsed as:\n[color=primary]%1$s[/color] Disable Exit Confirm Disables the confirmation dialog when exiting the WebUI. - Controlled by Native - Controlled by JavaScript + Native + Handled by the native app + JavaScript + Controlled by JavaScript while still respecting WebView navigation history + JavaScript Full + Fully controlled by JavaScript without WebView history protection Delete Globally Disabled WebUI Config @@ -139,6 +146,34 @@ Network DevTools Unlike Eruda, this debugging console can be used even with remote hosts. + Disable Console Interceptor + Stop capturing and redirecting console logs to prevent the app from overriding default output. Useful when using Chrome DevTools since logs may not appear there otherwise. Beta %s\\n… %d more + Cancel + Confirm + Learn more + Unsupported engine + Shortcut name + Name shown on your launcher for this module shortcut. + Shortcut icon + Icon file path relative to the module webroot (for example: icon.png). + Pick icon + Reset icon + Using selected icon + Using module default icon + Please select a WebUI engine first + Engine selection is required before creating the shortcut. + Name and icon path are required + Shortcut creation requested + Your launcher does not support pinned shortcuts + A shortcut with this setup already exists + Icon file not found + Failed to decode icon image + Exit + Are you sure that you want to exit this WebUI? + Custom ADB Path + Select a custom ADB Path where modules should be loaded from + The new ADB Path must contain a `modules` folder in order to load modules from there + Snippets diff --git a/app/src/playstore/AndroidManifest.xml b/app/src/playstore/AndroidManifest.xml new file mode 100644 index 00000000..4900bfa0 --- /dev/null +++ b/app/src/playstore/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt b/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt index c7314f0f..dd3bc3ed 100644 --- a/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt +++ b/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt @@ -11,40 +11,43 @@ import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension class ApplicationConventionPlugin : Plugin { - override fun apply(target: Project) = with(target) { - apply(plugin = "com.android.application") - apply(plugin = "org.jetbrains.kotlin.android") + override fun apply(target: Project) = + with(target) { + apply(plugin = "com.android.application") + if (extensions.findByName("kotlin") == null) { + apply(plugin = "org.jetbrains.kotlin.android") + } - extensions.configure { - compileSdk = COMPILE_SDK - buildToolsVersion = BUILD_TOOLS_VERSION + extensions.configure { + compileSdk = COMPILE_SDK + buildToolsVersion = BUILD_TOOLS_VERSION - defaultConfig { - minSdk = MIN_SDK - targetSdk = COMPILE_SDK - } + defaultConfig { + minSdk = MIN_SDK + targetSdk = COMPILE_SDK + } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } } - } - extensions.configure { - toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } } - } - extensions.configure { - jvmToolchain(21) + extensions.configure { + jvmToolchain(21) - sourceSets.all { - languageSettings { - optIn("kotlin.ExperimentalStdlibApi") - optIn("kotlinx.coroutines.FlowPreview") + compilerOptions { + optIn.addAll( + "kotlin.ExperimentalStdlibApi", + "kotlinx.coroutines.FlowPreview", + ) } } } - } } \ No newline at end of file diff --git a/build-logic/src/main/kotlin/ComposeConventionPlugin.kt b/build-logic/src/main/kotlin/ComposeConventionPlugin.kt index 938a6711..7c4b5e48 100644 --- a/build-logic/src/main/kotlin/ComposeConventionPlugin.kt +++ b/build-logic/src/main/kotlin/ComposeConventionPlugin.kt @@ -11,33 +11,33 @@ import org.gradle.kotlin.dsl.getByType import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension class ComposeConventionPlugin : Plugin { - override fun apply(target: Project) = with(target) { - apply(plugin = "com.android.application") - apply(plugin = "org.jetbrains.kotlin.android") - apply(plugin = "org.jetbrains.kotlin.plugin.compose") + override fun apply(target: Project) = + with(target) { + apply(plugin = "com.android.application") + apply(plugin = "org.jetbrains.kotlin.plugin.compose") - extensions.configure { - buildFeatures { - compose = true + extensions.configure { + buildFeatures { + compose = true + } } - } - extensions.configure { - sourceSets.all { - languageSettings { - optIn("androidx.compose.material3.ExperimentalMaterial3Api") - optIn("androidx.compose.foundation.ExperimentalFoundationApi") - optIn("androidx.compose.foundation.layout.ExperimentalLayoutApi") + extensions.configure { + compilerOptions { + optIn.addAll( + "androidx.compose.material3.ExperimentalMaterial3Api", + "androidx.compose.foundation.ExperimentalFoundationApi", + "androidx.compose.foundation.layout.ExperimentalLayoutApi", + ) } } - } - val libs = extensions.getByType().named("libs") - dependencies { - "implementation"(libs.findLibrary("androidx.compose.material3").get()) - "implementation"(libs.findLibrary("androidx.compose.ui").get()) - "implementation"(libs.findLibrary("androidx.compose.ui.tooling.preview").get()) - "debugImplementation"(libs.findLibrary("androidx.compose.ui.tooling").get()) + val libs = extensions.getByType().named("libs") + dependencies { + "implementation"(libs.findLibrary("androidx.compose.material3").get()) + "implementation"(libs.findLibrary("androidx.compose.ui").get()) + "implementation"(libs.findLibrary("androidx.compose.ui.tooling.preview").get()) + "debugImplementation"(libs.findLibrary("androidx.compose.ui.tooling").get()) + } } - } } \ No newline at end of file diff --git a/build-logic/src/main/kotlin/HiltConventionPlugin.kt b/build-logic/src/main/kotlin/HiltConventionPlugin.kt index 019487bb..753086c7 100644 --- a/build-logic/src/main/kotlin/HiltConventionPlugin.kt +++ b/build-logic/src/main/kotlin/HiltConventionPlugin.kt @@ -8,14 +8,15 @@ import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType class HiltConventionPlugin : Plugin { - override fun apply(target: Project) = with(target) { - apply(plugin = "dagger.hilt.android.plugin") - apply(plugin = "com.google.devtools.ksp") + override fun apply(target: Project) = + with(target) { + apply(plugin = "dagger.hilt.android.plugin") + apply(plugin = "com.google.devtools.ksp") - val libs = extensions.getByType().named("libs") - dependencies { - "implementation"(libs.findLibrary("hilt.android").get()) - "ksp"(libs.findLibrary("hilt.compiler").get()) + val libs = extensions.getByType().named("libs") + dependencies { + "implementation"(libs.findLibrary("hilt.android").get()) + "ksp"(libs.findLibrary("hilt.compiler").get()) + } } - } } \ No newline at end of file diff --git a/build-logic/src/main/kotlin/LibraryConventionPlugin.kt b/build-logic/src/main/kotlin/LibraryConventionPlugin.kt index 8df98de3..28fc5b3d 100644 --- a/build-logic/src/main/kotlin/LibraryConventionPlugin.kt +++ b/build-logic/src/main/kotlin/LibraryConventionPlugin.kt @@ -11,32 +11,32 @@ import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension class LibraryConventionPlugin : Plugin { - override fun apply(target: Project) = with(target) { - apply(plugin = "com.android.library") - apply(plugin = "org.jetbrains.kotlin.android") + override fun apply(target: Project) = + with(target) { + apply(plugin = "com.android.library") - extensions.configure { - compileSdk = COMPILE_SDK - buildToolsVersion = BUILD_TOOLS_VERSION + extensions.configure { + compileSdk = COMPILE_SDK + buildToolsVersion = BUILD_TOOLS_VERSION - defaultConfig { - minSdk = MIN_SDK - } + defaultConfig { + minSdk = MIN_SDK + } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } } - } - extensions.configure { - toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } } - } - extensions.configure { - jvmToolchain(21) + extensions.configure { + jvmToolchain(21) + } } - } } \ No newline at end of file diff --git a/datastore/build.gradle.kts b/datastore/build.gradle.kts index 0e403c80..79b7eb22 100644 --- a/datastore/build.gradle.kts +++ b/datastore/build.gradle.kts @@ -1,15 +1,13 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) - alias(libs.plugins.protobuf) alias(libs.plugins.compose.compiler) alias(libs.plugins.hilt) alias(libs.plugins.kotlin.serialization) } android { - namespace = "com.dergoogler.mmrl.datastore" - compileSdk = 35 + namespace = "com.dergoogler.mmrl.wx.datastore" + compileSdk = 36 defaultConfig { minSdk = 26 @@ -35,10 +33,6 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - - kotlinOptions { - jvmTarget = "21" - } } dependencies { diff --git a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/UserPreferencesDataSource.kt b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/UserPreferencesDataSource.kt index 137e9643..d5efe13e 100644 --- a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/UserPreferencesDataSource.kt +++ b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/UserPreferencesDataSource.kt @@ -3,9 +3,9 @@ package com.dergoogler.mmrl.wx.datastore import androidx.datastore.core.DataStore import com.dergoogler.mmrl.datastore.model.DarkMode import com.dergoogler.mmrl.datastore.model.ModulesMenu -import com.dergoogler.mmrl.datastore.model.WorkingMode import com.dergoogler.mmrl.wx.datastore.model.UserPreferences import com.dergoogler.mmrl.wx.datastore.model.WebUIEngine +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -55,6 +55,14 @@ class UserPreferencesDataSource @Inject constructor( } } + suspend fun setAdbPath(value: String) = withContext(Dispatchers.IO) { + userPreferences.updateData { + it.copy( + adbPath = value + ) + } + } + suspend fun setWebUiDevUrl(value: String) = withContext(Dispatchers.IO) { userPreferences.updateData { it.copy( @@ -119,6 +127,14 @@ class UserPreferencesDataSource @Inject constructor( } } + suspend fun setDisableConsoleInterceptor(value: Boolean) = withContext(Dispatchers.IO) { + userPreferences.updateData { + it.copy( + disableConsoleInterceptor = value + ) + } + } + suspend fun setWebUIEngine(value: WebUIEngine) = withContext(Dispatchers.IO) { userPreferences.updateData { it.copy( diff --git a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/UserPreferencesRepository.kt b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/UserPreferencesRepository.kt index 73d5f820..f1a92207 100644 --- a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/UserPreferencesRepository.kt +++ b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/UserPreferencesRepository.kt @@ -2,8 +2,8 @@ package com.dergoogler.mmrl.wx.datastore import com.dergoogler.mmrl.datastore.model.DarkMode import com.dergoogler.mmrl.datastore.model.ModulesMenu -import com.dergoogler.mmrl.datastore.model.WorkingMode import com.dergoogler.mmrl.wx.datastore.model.WebUIEngine +import com.dergoogler.mmrl.wx.datastore.model.WorkingMode import javax.inject.Inject import javax.inject.Singleton @@ -24,6 +24,8 @@ class UserPreferencesRepository @Inject constructor( suspend fun setDatePattern(value: String) = userPreferencesDataSource.setDatePattern(value) + suspend fun setAdbPath(value: String) = userPreferencesDataSource.setAdbPath(value) + suspend fun setWebUiDevUrl(value: String) = userPreferencesDataSource.setWebUiDevUrl(value) @@ -48,6 +50,9 @@ class UserPreferencesRepository @Inject constructor( suspend fun setDisableGlobalExitConfirm(value: Boolean) = userPreferencesDataSource.setDisableGlobalExitConfirm(value) + suspend fun setDisableConsoleInterceptor(value: Boolean) = + userPreferencesDataSource.setDisableConsoleInterceptor(value) + suspend fun setWebUIEngine(value: WebUIEngine) = userPreferencesDataSource.setWebUIEngine(value) } \ No newline at end of file diff --git a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/UserPreferences.kt b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/UserPreferences.kt index a371719a..047f4c03 100644 --- a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/UserPreferences.kt +++ b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/UserPreferences.kt @@ -8,7 +8,6 @@ import android.content.res.Resources import android.os.Build import com.dergoogler.mmrl.datastore.model.DarkMode import com.dergoogler.mmrl.datastore.model.ModulesMenu -import com.dergoogler.mmrl.datastore.model.WorkingMode import com.dergoogler.mmrl.ui.theme.Colors import com.dergoogler.mmrl.ui.theme.Colors.Companion.getColorScheme import kotlinx.serialization.ExperimentalSerializationApi @@ -34,12 +33,25 @@ data class UserPreferences( @ProtoNumber(22) val developerMode: Boolean = false, @ProtoNumber(23) val useWebUiDevUrl: Boolean = false, @ProtoNumber(35) val enableErudaConsole: Boolean = false, - @ProtoNumber(37) val webuiEngine: WebUIEngine = WebUIEngine.PREFER_MODULE, + @ProtoNumber(37) val webuiEngine: WebUIEngine = WebUIEngine.WX, @ProtoNumber(38) val enableAutoOpenEruda: Boolean = false, @ProtoNumber(39) val forceKillWebUIProcess: Boolean = true, @ProtoNumber(40) val disableGlobalExitConfirm: Boolean = false, @ProtoNumber(41) val enableDevTools: Boolean = false, + @ProtoNumber(42) val disableConsoleInterceptor: Boolean = false, + @ProtoNumber(43) val adbPath: String = "/data/adb", ) { + fun getAdbPath(context: Context): String { + if (workingMode == WorkingMode.MODE_NON_ROOT) { + return context.filesDir.path + } + + return adbPath + } + + val isNonRoot: Boolean + get() = workingMode == WorkingMode.MODE_NON_ROOT + fun isDarkMode() = when (darkMode) { DarkMode.AlwaysOff -> false DarkMode.AlwaysOn -> true diff --git a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/WebUIEngine.kt b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/WebUIEngine.kt index 45aad7c1..0f5f223f 100644 --- a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/WebUIEngine.kt +++ b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/WebUIEngine.kt @@ -3,5 +3,6 @@ package com.dergoogler.mmrl.wx.datastore.model enum class WebUIEngine { WX, KSU, - PREFER_MODULE + PREFER_MODULE, + MX } \ No newline at end of file diff --git a/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/WorkingMode.kt b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/WorkingMode.kt new file mode 100644 index 00000000..e460771a --- /dev/null +++ b/datastore/src/main/kotlin/com/dergoogler/mmrl/wx/datastore/model/WorkingMode.kt @@ -0,0 +1,56 @@ +package com.dergoogler.mmrl.wx.datastore.model + +import com.dergoogler.mmrl.platform.Platform + +enum class WorkingMode { + FIRST_SETUP, + MODE_MAGISK, + MODE_KERNEL_SU, + MODE_KERNEL_SU_NEXT, + MODE_APATCH, + MODE_SUKISU, + MODE_RKSU, + MODE_MKSU, + MODE_NON_ROOT, + ; + + fun toPlatform() = + when (this) { + MODE_MAGISK -> Platform.Magisk + MODE_KERNEL_SU -> Platform.KernelSU + MODE_KERNEL_SU_NEXT -> Platform.KsuNext + MODE_APATCH -> Platform.APatch + MODE_SUKISU -> Platform.SukiSU + MODE_RKSU -> Platform.RKSU + MODE_MKSU -> Platform.MKSU + MODE_NON_ROOT -> Platform.NonRoot + FIRST_SETUP -> Platform.NonRoot + } + + val toString: String + get() = when (this) { + FIRST_SETUP -> "First Setup" + MODE_MAGISK -> "Magisk" + MODE_KERNEL_SU -> "KernelSU" + MODE_KERNEL_SU_NEXT -> "KsuNext" + MODE_APATCH -> "APatch" + MODE_SUKISU -> "SukiSu" + MODE_RKSU -> "RKSU" + MODE_MKSU -> "MKSU" + MODE_NON_ROOT -> "Non-Root" + } + + companion object { + val WorkingMode.isRoot + get() = + this == MODE_MAGISK || + this == MODE_KERNEL_SU || + this == MODE_KERNEL_SU_NEXT || + this == MODE_APATCH || + this == MODE_SUKISU || + this == MODE_RKSU || + this == MODE_MKSU + val WorkingMode.isNonRoot get() = this == MODE_NON_ROOT + val WorkingMode.isSetup get() = this == FIRST_SETUP + } +} diff --git a/gradle.properties b/gradle.properties index 550cf299..fa25e3e5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,4 +6,5 @@ android.useAndroidX=true android.nonTransitiveRClass=true kapt.include.compile.classpath=false -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +android.disallowKotlinSourceSets=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c8aa00c..ffcd4e77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,13 @@ [versions] dexlib2 = "2.5.2" -mmrl = "v34242" +mmrlx = "1.0.145" +mmrl = "b6da2febe9" adaptiveAndroid = "1.1.0-rc01" -androidGradlePlugin = "8.9.1" +androidGradlePlugin = "9.0.1" androidxActivity = "1.9.3" androidxAnnotation = "1.9.1" androidxAppCompat = "1.7.0" -androidxCompose = "1.7.6" +androidxCompose = "1.10.6" androidxComposeMaterial3 = "1.3.1" androidxCore = "1.15.0" androidxCoreSplashscreen = "1.0.1" @@ -16,7 +17,7 @@ androidxHiltNavigationCompose = "1.2.0" androidxLifecycle = "2.8.7" androidxNavigation = "2.8.9" arbor = "2.0.103" -foundation = "1.7.3" +foundation = "1.10.6" cashLicensee = "1.13.0" kotlinxHtmlJvm = "0.9.1" multiplatformMarkdownRenderer = "0.27.0" @@ -29,13 +30,13 @@ browser = "1.8.0" coilCompose = "2.1.0" composeMarkdown = "0.5.4" hiddenApiRefine = "4.4.0" -hilt = "2.51.1" -kotlin = "2.0.0" +hilt = "2.59.2" +kotlin = "2.2.0" kotlinReflect = "1.9.24" kotlinxCoroutines = "1.8.1" kotlinxDatetime = "0.6.0" kotlinxSerialization = "1.7.1" -ksp = "2.0.0-1.0.21" +ksp = "2.2.0-2.0.2" libsu = "6.0.0" protobuf = "4.28.2" protobufPlugin = "0.9.4" @@ -50,7 +51,7 @@ lifecycleProcess = "2.8.7" apacheCommons = "1.27.1" parcelablelist = "2.0.1" zoomableVersion = "2.2.0" -kotlinVersion = "1.9.0" +kotlinVersion = "2.2.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" @@ -92,7 +93,7 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" } androidx-datastore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidxDocumentFile" } -androidx-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } @@ -124,6 +125,11 @@ lib-zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomableVe libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" } libsu-io = { module = "com.github.topjohnwu.libsu:io", version.ref = "libsu" } +mmrlx-ui = { module = "dev.mmrlx:ui", version.ref = "mmrlx" } +mmrlx-nio = { module = "dev.mmrlx:nio", version.ref = "mmrlx" } +mmrlx-webui-core = { module = "dev.mmrlx:webui.core", version.ref = "mmrlx" } +mmrlx-webui-lua = { module = "dev.mmrlx:webui.lua", version.ref = "mmrlx" } +mmrlx-utilities = { module = "dev.mmrlx:utilities", version.ref = "mmrlx" } multiplatform-markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } @@ -144,6 +150,8 @@ square-okhttp-dnsoverhttps = { group = "com.squareup.okhttp3", name = "okhttp-dn square-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "squareOkhttp" } square-moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "squareMoshi" } square-moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "squareMoshi" } +kotlin-parcelize-compiler = { module = "org.jetbrains.kotlin:kotlin-parcelize-compiler", version.ref = "kotlin" } +kotlin-parcelize-runtime = { module = "org.jetbrains.kotlin:kotlin-parcelize-runtime", version.ref = "kotlin" } dev-rikka-rikkax-parcelablelist = { module = "dev.rikka.rikkax.parcelablelist:parcelablelist", version.ref = "parcelablelist" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7701f663..39dd3b14 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Apr 20 19:20:15 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/helper/build.gradle.kts b/helper/build.gradle.kts index c7ceb5f0..47044fd6 100644 --- a/helper/build.gradle.kts +++ b/helper/build.gradle.kts @@ -1,11 +1,10 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) } android { namespace = "com.dergoogler.mmrl.webui.helper" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 26 @@ -27,9 +26,6 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } } dependencies { diff --git a/hwui/build.gradle.kts b/hwui/build.gradle.kts index fd4831ef..c34d02f2 100644 --- a/hwui/build.gradle.kts +++ b/hwui/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) } android { @@ -28,10 +27,6 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - - kotlinOptions { - jvmTarget = "21" - } } dependencies { diff --git a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/ConsoleEntry.kt b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/ConsoleEntry.kt index 12952722..4c93d8d6 100644 --- a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/ConsoleEntry.kt +++ b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/ConsoleEntry.kt @@ -25,5 +25,72 @@ data class ConsoleEntry( line = lineNumber(), ) } + + fun info(vararg args: Any?, source: String, line: Int): ConsoleEntry { + return logLevel( + *args, + source = source, + line = line, + level = ConsoleMessage.MessageLevel.LOG + ) + } + + fun warn(vararg args: Any?, source: String, line: Int): ConsoleEntry { + return logLevel( + *args, + source = source, + line = line, + level = ConsoleMessage.MessageLevel.WARNING + ) + } + + fun error(vararg args: Any?, source: String, line: Int): ConsoleEntry { + return logLevel( + *args, + source = source, + line = line, + level = ConsoleMessage.MessageLevel.ERROR + ) + } + + fun debug(vararg args: Any?, source: String, line: Int): ConsoleEntry { + return logLevel( + *args, + source = source, + line = line, + level = ConsoleMessage.MessageLevel.DEBUG + ) + } + + fun trace(vararg args: Any?, source: String, line: Int): ConsoleEntry { + return logLevel( + *args, + source = source, + line = line, + level = ConsoleMessage.MessageLevel.TIP + ) + } + + private fun logLevel( + vararg args: Any?, + source: String, + line: Int, + level: ConsoleMessage.MessageLevel, + ): ConsoleEntry { + return ConsoleEntry( + level = level, + args = args.map { + ResultNode.Primitive( + key = null, + value = it.toString(), + kind = PrimitiveKind.parse(it), + depth = 0 + ) + + }, + source = source, + line = line, + ) + } } } \ No newline at end of file diff --git a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/HybridWebUIStore.kt b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/HybridWebUIStore.kt index c13f77d6..bc9d7602 100644 --- a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/HybridWebUIStore.kt +++ b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/HybridWebUIStore.kt @@ -9,7 +9,7 @@ import com.dergoogler.mmrl.hybridwebui.store.WebConsoleStore class HybridWebUIStore : ViewModel() { internal val jsInterfaceStore = JavaScriptInterfaceStore() val networkStore = NetworkRequestStore(maxHistory = 200) - val consoleStore = WebConsoleStore(maxHistory = 200) + val consoleStore = WebConsoleStore(maxHistory = 200, "HWUI") val pathMatchers = PathMatchersStore() fun clear() { diff --git a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/ResultNode.kt b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/ResultNode.kt index e089bf5f..9048ea5d 100644 --- a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/ResultNode.kt +++ b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/ResultNode.kt @@ -65,5 +65,22 @@ sealed class ResultNode { } enum class PrimitiveKind { - STRING, NUMBER, BOOLEAN, NULL_UNDEFINED, FUNCTION, OTHER + STRING, NUMBER, BOOLEAN, NULL_UNDEFINED, FUNCTION, OTHER; + + companion object { + /** + * Parses a Kotlin object into a [PrimitiveKind]. + * + * [PrimitiveKind.FUNCTION] is not supported. + * + * @param obj The Kotlin object to be parsed. + */ + inline fun parse(obj: T?): PrimitiveKind = when (obj) { + is String -> STRING + is Number -> NUMBER + is Boolean -> BOOLEAN + null -> NULL_UNDEFINED + else -> OTHER + } + } } \ No newline at end of file diff --git a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/interfaces/JavaScriptInterface.kt b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/interfaces/JavaScriptInterface.kt index f3178927..b2535ed9 100644 --- a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/interfaces/JavaScriptInterface.kt +++ b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/interfaces/JavaScriptInterface.kt @@ -28,7 +28,7 @@ open class JavaScriptInterface( protected val activity: ComponentActivity, protected val view: HybridWebUI ) : ContextWrapper(activity) { - protected val context: Context get() = activity.baseContext + protected val context: Context get() = activity.applicationContext /** * The name of the entity. @@ -148,4 +148,4 @@ open class JavaScriptInterface( return JavaScriptInterfaceImplementation(clazz, initArgs, parameterTypes) } } -} \ No newline at end of file +} diff --git a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/store/WebConsoleStore.kt b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/store/WebConsoleStore.kt index 15d08db7..8c78cb90 100644 --- a/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/store/WebConsoleStore.kt +++ b/hwui/src/main/kotlin/com/dergoogler/mmrl/hybridwebui/store/WebConsoleStore.kt @@ -1,5 +1,6 @@ package com.dergoogler.mmrl.hybridwebui.store +import android.util.Log import com.dergoogler.mmrl.hybridwebui.ConsoleEntry import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -7,12 +8,16 @@ import kotlinx.coroutines.flow.asStateFlow import java.util.Collections import java.util.LinkedList -class WebConsoleStore(private val maxHistory: Int = 100) { +class WebConsoleStore( + private val maxHistory: Int = 100, + internal val source: String, +) { private val _console = Collections.synchronizedList(LinkedList()) private val _flow = MutableStateFlow>(emptyList()) val flow: StateFlow> = _flow.asStateFlow() + fun add(request: ConsoleEntry) { synchronized(_console) { _console.add(request) @@ -33,3 +38,44 @@ class WebConsoleStore(private val maxHistory: Int = 100) { val all: List get() = synchronized(_console) { _console.toList() } } + +fun WebConsoleStore?.info(vararg args: Any?, line: Int = -1) { + if (this == null) return + add(ConsoleEntry.info(args, source = source, line = line)) +} + +fun WebConsoleStore?.warn(vararg args: Any?, line: Int = -1) { + if (this == null) return + add(ConsoleEntry.warn(args, source = source, line = line)) +} + +fun WebConsoleStore?.error(vararg args: Any?, line: Int = -1) { + if (this == null) return + add(ConsoleEntry.error(args, source = source, line = line)) +} + +fun WebConsoleStore?.debug(vararg args: Any?, line: Int = -1) { + if (this == null) return + add(ConsoleEntry.debug(args, source = source, line = line)) +} + +fun WebConsoleStore?.trace(vararg args: Any?, line: Int = -1) { + if (this == null) return + add(ConsoleEntry.trace(args, source = source, line = line)) +} + +fun WebConsoleStore?.error(throwable: Throwable) { + if (this == null) return + + val stack = throwable.stackTrace + val caller = stack.getOrNull(1) ?: stack.firstOrNull() + val trace = Log.getStackTraceString(throwable) + + add( + ConsoleEntry.error( + arrayOf(trace), + source = caller?.fileName ?: source, + line = caller?.lineNumber ?: -1 + ) + ) +} \ No newline at end of file diff --git a/jna/build.gradle.kts b/jna/build.gradle.kts index 2b9c2264..f7fec732 100644 --- a/jna/build.gradle.kts +++ b/jna/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) } android { @@ -30,10 +29,6 @@ android { targetCompatibility = JavaVersion.VERSION_21 } - kotlinOptions { - jvmTarget = "21" - } - sourceSets { getByName("main") { jniLibs.srcDirs("jniLibs") diff --git a/lua/build.gradle.kts b/lua/build.gradle.kts index 9144256b..2c40f6b8 100644 --- a/lua/build.gradle.kts +++ b/lua/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) } android { @@ -30,10 +29,6 @@ android { targetCompatibility = JavaVersion.VERSION_21 } - kotlinOptions { - jvmTarget = "21" - } - sourceSets { getByName("main") { jniLibs.srcDirs("jniLibs") diff --git a/modconf/build.gradle.kts b/modconf/build.gradle.kts index ce4c8ac5..03f8484b 100644 --- a/modconf/build.gradle.kts +++ b/modconf/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.compose.compiler) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.serialization) @@ -35,10 +34,6 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - - kotlinOptions { - jvmTarget = "21" - } } dependencies { diff --git a/settings.gradle.kts b/settings.gradle.kts index 78ff67d2..b67cebd8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,12 +1,29 @@ enableFeaturePreview("STABLE_CONFIGURATION_CACHE") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") +val user: String? = providers.gradleProperty("gpr.user").orNull + ?: System.getenv("GITHUB_ACTOR") +val pass: String? = providers.gradleProperty("gpr.key").orNull + ?: System.getenv("GITHUB_TOKEN") + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() + mavenLocal() maven("https://jitpack.io") + + if (user != null && pass != null) { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/MMRLApp/X") + credentials { + username = user + password = pass + } + } + } } } @@ -15,6 +32,7 @@ pluginManagement { repositories { google() mavenCentral() + mavenLocal() gradlePluginPortal() } } diff --git a/webui/build.gradle.kts b/webui/build.gradle.kts index cc16f9be..dce31ab2 100644 --- a/webui/build.gradle.kts +++ b/webui/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.compose.compiler) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.serialization) @@ -35,10 +34,6 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - - kotlinOptions { - jvmTarget = "21" - } } dependencies { @@ -48,7 +43,6 @@ dependencies { compileOnly(libs.mmrl.compat) compileOnly(libs.mmrl.hiddenApi) implementation(projects.jna) - implementation(projects.hwui) implementation(kotlin("reflect")) implementation(libs.libsu.core) implementation(libs.androidx.core.ktx) @@ -83,4 +77,5 @@ dependencies { implementation(libs.androidx.swiperefreshlayout) implementation(libs.dexlib2) ksp(libs.square.moshi.kotlin) + api(projects.hwui) } \ No newline at end of file diff --git a/webui/src/main/assets/ext/statusbar.js b/webui/src/main/assets/ext/statusbar.js new file mode 100644 index 00000000..6a865c04 --- /dev/null +++ b/webui/src/main/assets/ext/statusbar.js @@ -0,0 +1,26 @@ +function checkTopContrast() { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + canvas.width = 1; + canvas.height = 27; + + ctx.drawWindow ? ctx.drawWindow(window, 0, 0, 1, 27, "white") : null; + + const topElement = document.elementFromPoint(5, 5); + const bgColor = window.getComputedStyle(topElement).backgroundColor; + + const rgb = bgColor.match(/\d+/g).map(Number); + + const a = rgb.map(v => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + const luminance = 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; + + if (window.webui) { + window.webui.updateStatusBarIconTint(luminance > 0.5); + } +} + +window.addEventListener('load', checkTopContrast); +window.addEventListener('scroll', checkTopContrast); \ No newline at end of file diff --git a/webui/src/main/kotlin/com/dergoogler/mmrl/ext/JSONCollectionExt.kt b/webui/src/main/kotlin/com/dergoogler/mmrl/ext/JSONCollectionExt.kt index 10bc74cd..7c671a8f 100644 --- a/webui/src/main/kotlin/com/dergoogler/mmrl/ext/JSONCollectionExt.kt +++ b/webui/src/main/kotlin/com/dergoogler/mmrl/ext/JSONCollectionExt.kt @@ -176,12 +176,6 @@ fun JSONObject.getBoolean(key: String, defaultValue: Boolean = false): Boolean = fun JSONObject.getArray(key: String, defaultValue: JSONArray = JSONArray.EMPTY): JSONArray = getArray(key) ?: defaultValue fun JSONObject.getObject(key: String, defaultValue: JSONObject = JSONObject.EMPTY): JSONObject = getObject(key) ?: defaultValue -// Safe casting with helpers -fun JSONObject.optString(key: String): String? = getString(key) -fun JSONObject.optNumber(key: String): Number? = getNumber(key) -fun JSONObject.optBoolean(key: String): Boolean? = getBoolean(key) -fun JSONObject.optArray(key: String): JSONArray? = getArray(key) -fun JSONObject.optObject(key: String): JSONObject? = getObject(key) // ==================== JSONNull Helpers ==================== diff --git a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/activity/WXActivity.kt b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/activity/WXActivity.kt index 56a8ddc2..9adda8fc 100644 --- a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/activity/WXActivity.kt +++ b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/activity/WXActivity.kt @@ -4,17 +4,14 @@ import android.app.Activity import android.app.ActivityManager import android.content.Context import android.content.Intent -import android.graphics.Rect import android.os.Bundle import android.os.Process import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.LinearLayout import android.widget.ProgressBar import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.CallSuper @@ -30,12 +27,7 @@ import com.dergoogler.mmrl.platform.model.ModId import com.dergoogler.mmrl.platform.model.ModId.Companion.getModId import com.dergoogler.mmrl.platform.model.ModId.Companion.putBaseDir import com.dergoogler.mmrl.platform.model.ModId.Companion.putModId -import com.dergoogler.mmrl.ui.component.dialog.ConfirmData -import com.dergoogler.mmrl.ui.component.dialog.confirm -import com.dergoogler.mmrl.webui.R import com.dergoogler.mmrl.webui.model.WXEvent -import com.dergoogler.mmrl.webui.model.WXEventHandler -import com.dergoogler.mmrl.webui.model.WXKeyboardEventData import com.dergoogler.mmrl.webui.model.WebUIConfig import com.dergoogler.mmrl.webui.model.toWebUIConfig import com.dergoogler.mmrl.webui.util.WebUIOptions @@ -146,144 +138,8 @@ open class WXActivity : ComponentActivity() { lifecycleScope.launch { onRender(this) - registerBackEvents() - - - config { - rootView.viewTreeObserver.addOnGlobalLayoutListener { - val r = Rect() - rootView.getWindowVisibleDisplayFrame(r) - - val screenHeight = rootView.rootView.height - val keypadHeight = screenHeight - r.bottom - val keyboardVisibleNow = keypadHeight > screenHeight * 0.15 - - if (keyboardVisibleNow != isKeyboardShowing) { - isKeyboardShowing = keyboardVisibleNow - - view?.wx?.postWXEvent( - WXEventHandler( - WXEvent.WX_ON_KEYBOARD, - WXKeyboardEventData( - height = keypadHeight.asPx, - visible = keyboardVisibleNow - ) - ) - ) - - updateWebViewKeyboardMapping( - keyboardVisibleNow, - windowResize, - keypadHeight - ) - } - } - } - } - } - - private fun updateWebViewKeyboardMapping( - isVisible: Boolean, - isNativeResize: Boolean, - height: Int, - ) { - if (!isVisible) { - resetWebViewHeight() - removeCssKeyboardHeight() - return +// registerBackEvents() } - - if (isNativeResize) { - adjustWebViewHeight(height) - } else { - setCssKeyboardHeight(height) - } - } - - private fun adjustWebViewHeight(keypadHeight: Int) { - val params = view?.layoutParams - params?.height = rootView.height - keypadHeight - view?.layoutParams = params - } - - private fun resetWebViewHeight() { - val params = view?.layoutParams - params?.height = LinearLayout.LayoutParams.MATCH_PARENT - view?.layoutParams = params - } - - - fun setCssKeyboardHeight(keypadHeight: Int) { - val density = this.resources.displayMetrics.density - val cssPixel = (keypadHeight / density).toInt() - view?.wx?.runJs( - "document.documentElement.style.setProperty('--window-keyboard-height', '${cssPixel}px');", - ) - } - - private fun removeCssKeyboardHeight() { - view?.wx?.runJs( - "document.documentElement.style.removeProperty('--window-keyboard-height');", - ) - } - - private fun registerBackEvents() { - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - val options = view?.options ?: return - val wx = view?.wx ?: return - - val backHandler = options.config.backHandler - val interceptor = options.config.backInterceptor - - if (backHandler != true) { - handleNativeBack() - return - } - - when (interceptor) { - "native" -> handleNativeBack() - "javascript" -> wx.postWXEvent( - WXEventHandler(WXEvent.WX_ON_BACK, null) - ) - - true -> handleNativeBack() - null -> handleNativeBack() - false -> exit(options) - else -> exit(options) - } - } - }) - } - - private fun handleNativeBack() { - val options = view?.options ?: return - val wx = view?.wx ?: return - - if (wx.canGoBack()) { - wx.goBack() - return - } - - if (options.disableGlobalExitConfirm) { - exit(options) - return - } - - if (options.config.exitConfirm) { - confirm( - confirmData = ConfirmData( - title = getString(R.string.exit), - description = getString(R.string.exit_desc), - onConfirm = { exit(options) }, - onClose = {} - ), - colorScheme = options.colorScheme - ) - return - } - - exit(options) } @CallSuper diff --git a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/compose/WebUIExtensions.kt b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/compose/WebUIExtensions.kt new file mode 100644 index 00000000..e69de29b diff --git a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/ApplicationInterface.kt b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/ApplicationInterface.kt index 16746963..8ce27458 100644 --- a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/ApplicationInterface.kt +++ b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/ApplicationInterface.kt @@ -5,13 +5,16 @@ import android.content.Intent import android.webkit.JavascriptInterface import androidx.annotation.Keep import androidx.core.content.pm.PackageInfoCompat +import androidx.core.view.WindowInsetsControllerCompat import com.dergoogler.mmrl.compat.MediaStoreCompat.getPathForUri +import com.dergoogler.mmrl.ext.findActivity import com.dergoogler.mmrl.platform.PlatformManager import com.dergoogler.mmrl.webui.activity.WXActivity.Companion.exit import com.dergoogler.mmrl.webui.model.App import com.dergoogler.mmrl.webui.model.WebUIConfigAdditionalConfig.Companion.toValueMap import com.dergoogler.mmrl.webui.moshi import com.squareup.moshi.JsonClass +import com.dergoogler.mmrl.hybridwebui.store.error import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -24,8 +27,10 @@ class ApplicationInterface( @JavascriptInterface fun exit() { - withActivity { - exit(options) + try { + activity.exit(options) + } catch (e: Exception) { + consoleLogs.error("Error while exiting", e) } } @@ -153,6 +158,17 @@ class ApplicationInterface( } } + @JavascriptInterface + fun updateStatusBarIconTint(isLightBackground: Boolean) { + val act = context.findActivity() ?: return + mainThread { + val window = act.window + // Use WindowInsetsControllerCompat to toggle icon tinting + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightStatusBars = isLightBackground + } + } + @JsonClass(generateAdapter = true) data class OnResultData( val path: String?, diff --git a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/FileInterface.kt b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/FileInterface.kt index fc62c19f..02e2b0ce 100644 --- a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/FileInterface.kt +++ b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/FileInterface.kt @@ -4,6 +4,8 @@ import android.webkit.JavascriptInterface import androidx.annotation.Keep import com.dergoogler.mmrl.platform.PlatformManager import com.dergoogler.mmrl.platform.file.SuFile +import com.dergoogler.mmrl.platform.file.readText +import com.dergoogler.mmrl.platform.file.writeText @Keep class FileInterface( diff --git a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/WXinterface.kt b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/WXinterface.kt index cd954165..6fc8897f 100644 --- a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/WXinterface.kt +++ b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/interfaces/WXinterface.kt @@ -1,10 +1,9 @@ package com.dergoogler.mmrl.webui.interfaces import android.app.Activity -import android.content.Context -import android.content.ContextWrapper import android.content.Intent import android.os.Handler +import android.os.Looper import android.webkit.WebView import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResult @@ -13,6 +12,9 @@ import androidx.annotation.UiThread import com.dergoogler.mmrl.ext.findActivity import com.dergoogler.mmrl.hybridwebui.HybridWebUI import com.dergoogler.mmrl.hybridwebui.interfaces.JavaScriptInterface +import com.dergoogler.mmrl.hybridwebui.store.error +import com.dergoogler.mmrl.hybridwebui.store.trace +import com.dergoogler.mmrl.hybridwebui.store.warn import com.dergoogler.mmrl.platform.model.ModId import com.dergoogler.mmrl.webui.model.WebUIConfig import com.dergoogler.mmrl.webui.util.WebUIOptions @@ -133,9 +135,10 @@ open class WXInterface( } fun withActivity(block: Activity.() -> R): R? { - if (activity == null) { - console.trace("withActivity -> activity == null") - console.error("[$tag->withActivity] Activity not found") + val act = context.findActivity() + if (act == null) { + consoleLogs.trace("withActivity -> activity == null") + consoleLogs.error("[$tag->withActivity] Activity not found") return null } @@ -181,22 +184,32 @@ open class WXInterface( ) { } + fun mainThread(callback: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + callback() + } else { + Handler(Looper.getMainLooper()).post { callback() } + } + } + @UiThread fun runMainLooperPost(action: Activity.() -> Unit) { - if (activity == null) { - console.error("[$tag->runMainLooperPost] Activity not found") + val act = context.findActivity() + if (act == null) { + consoleLogs.error("[$tag->runMainLooperPost] Activity not found") return } Handler(mainLooper).post { - action(activity) + action(act) } } @UiThread fun runMainLooperPost(r: Runnable) { - if (activity == null) { - console.error("[$tag->runMainLooperPost/Runnable] Activity not found") + val act = context.findActivity() + if (act == null) { + consoleLogs.error("[$tag->runMainLooperPost/Runnable] Activity not found") return } @@ -220,7 +233,7 @@ open class WXInterface( * deprecated("oldFunction", "newFunction") */ fun deprecated(method: String, replaceWith: String? = null) { - console.log( + consoleLogs.warn( "%c[DEPRECATED]%c The `$method` method will be removed in future versions.${if (replaceWith != null) " Use `$replaceWith` instead." else ""}", "color: white; background: red; font-weight: bold; padding: 2px 6px; border-radius: 4px;", "color: orange; font-weight: bold;" @@ -236,7 +249,7 @@ open class WXInterface( ): R = try { block() } catch (e: Throwable) { - console.error(e) + consoleLogs.error(e) default } @@ -260,7 +273,7 @@ open class WXInterface( return try { with(with, block) } catch (e: Throwable) { - console.error(e) + consoleLogs.error(e) return default } } diff --git a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/model/Config.kt b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/model/Config.kt index 2c43b38f..6e7c70f0 100644 --- a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/model/Config.kt +++ b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/model/Config.kt @@ -23,6 +23,7 @@ import com.dergoogler.mmrl.platform.file.config.JSONArray import com.dergoogler.mmrl.platform.file.config.JSONCollection import com.dergoogler.mmrl.platform.file.config.JSONString import com.dergoogler.mmrl.platform.file.config.toTypedList +import com.dergoogler.mmrl.platform.file.readBytes import com.dergoogler.mmrl.platform.hiddenApi.HiddenPackageManager import com.dergoogler.mmrl.platform.hiddenApi.HiddenUserManager import com.dergoogler.mmrl.platform.model.ModId @@ -416,6 +417,7 @@ data class WebUIConfig( val pullToRefreshHelper: Boolean = true, val historyFallbackFile: String = "index.html", val autoStatusBarsStyle: Boolean = true, + val autoAddInsets: Boolean = false, val dexFiles: MutableList = mutableListOf(), val killShellWhenBackground: Boolean = true, val contentSecurityPolicy: String = "default-src 'self' data: blob: {domain}; " + diff --git a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/pathHandler/WebrootPathHandler.kt b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/pathHandler/WebrootPathHandler.kt index 0e94abb7..0d781318 100644 --- a/webui/src/main/kotlin/com/dergoogler/mmrl/webui/pathHandler/WebrootPathHandler.kt +++ b/webui/src/main/kotlin/com/dergoogler/mmrl/webui/pathHandler/WebrootPathHandler.kt @@ -3,9 +3,6 @@ package com.dergoogler.mmrl.webui.pathHandler import android.util.Log import android.webkit.WebResourceResponse import com.dergoogler.mmrl.ext.isNotNullOrBlank -import com.dergoogler.mmrl.hybridwebui.HybridWebUI -import com.dergoogler.mmrl.hybridwebui.HybridWebUIInsets -import com.dergoogler.mmrl.hybridwebui.HybridWebUIResourceRequest import com.dergoogler.mmrl.platform.file.SuFile import com.dergoogler.mmrl.platform.file.SuFile.Companion.toSuFile import com.dergoogler.mmrl.platform.model.ModId.Companion.moduleConfigDir @@ -14,6 +11,9 @@ import com.dergoogler.mmrl.webui.InjectionType import com.dergoogler.mmrl.webui.addInjection import com.dergoogler.mmrl.webui.asResponse import com.dergoogler.mmrl.webui.util.WebUIOptions +import com.dergoogler.mmrl.hybridwebui.HybridWebUI +import com.dergoogler.mmrl.hybridwebui.HybridWebUIInsets +import com.dergoogler.mmrl.hybridwebui.HybridWebUIResourceRequest import java.io.IOException @@ -121,9 +121,7 @@ class WebrootPathHandler( if (options.config.autoStatusBarsStyle) { addInjection { - appendLine("") + appendLine("") } } diff --git a/webui/src/main/res/drawable/git_branch.xml b/webui/src/main/res/drawable/git_branch.xml new file mode 100644 index 00000000..42d19fbd --- /dev/null +++ b/webui/src/main/res/drawable/git_branch.xml @@ -0,0 +1,48 @@ + + + + + + + +