diff --git a/MobileGlues b/MobileGlues index 6a5638dd..f56af31d 160000 --- a/MobileGlues +++ b/MobileGlues @@ -1 +1 @@ -Subproject commit 6a5638ddf87efd1f0990c98189a18b96cd0fe592 +Subproject commit f56af31d3e490d95808b3adc4ace20234249a37a diff --git a/README.md b/README.md index 2f3e38f5..1c6b71d2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,19 @@ Please see [LICENSE](https://github.com/MobileGL-Dev/MobileGlues-plugin/blob/mai Third party components ==== -Please see [MobileGlues-release](https://github.com/MobileGL-Dev/MobileGlues-release). +For the components used by the renderer itself, please see [MobileGlues-release](https://github.com/MobileGL-Dev/MobileGlues-release). + +The plugin application additionally uses: + +**Miuix** by **compose-miuix-ui** - [Apache License 2.0](https://github.com/compose-miuix-ui/miuix/blob/main/LICENSE): [github](https://github.com/compose-miuix-ui/miuix) + +**Jetpack Compose** by **Android Open Source Project (AOSP)** - [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt): [Android Developers](https://developer.android.com/jetpack/compose) + +**AndroidX** by **Android Open Source Project (AOSP)** - [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt): [Android Developers](https://developer.android.com/jetpack/androidx) + +**kotlinx.coroutines** by **JetBrains** - [Apache License 2.0](https://github.com/Kotlin/kotlinx.coroutines/blob/master/LICENSE.txt): [github](https://github.com/Kotlin/kotlinx.coroutines) + +**Gson** by **Google** - [Apache License 2.0](https://github.com/google/gson/blob/main/LICENSE): [github](https://github.com/google/gson) Check signature of your release ==== diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fef4895a..288f1359 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { @@ -13,8 +14,8 @@ android { applicationId = "com.fcl.plugin.mobileglues" minSdk = 26 targetSdk = 36 - versionCode = 1350 - versionName = "1.3.5" + versionCode = 2000 + versionName = "2.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -47,8 +48,11 @@ android { manifestPlaceholders["minMCVer"] = "1.17" manifestPlaceholders["maxMCVer"] = "" //为空则不限制 No restriction if empty + // MG_COUNT_LAUNCH:只有经启动器起来的这一次才算「一次启动」。 + // 基准测试、各种工具、以及本插件自己 dlopen 一次去读 GL 信息,都不该计数。 manifestPlaceholders["boatEnv"] = mutableMapOf().apply { put("LIBGL_ES", "3") + put("MG_COUNT_LAUNCH", "1") }.run { var env = "" forEach { (key, value) -> @@ -61,6 +65,7 @@ android { put("POJAV_RENDERER", "opengles3") put("POJAVEXEC_EGL", "libmobileglues.so") put("LIBGL_EGL", "libmobileglues.so") + put("MG_COUNT_LAUNCH", "1") }.run { var env = "" forEach { (key, value) -> @@ -81,12 +86,15 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } } buildFeatures { buildConfig = true - viewBinding = true + aidl = true + compose = true } packaging { jniLibs { @@ -97,8 +105,20 @@ android { dependencies { implementation(libs.gson) - implementation(libs.appcompat) - implementation(libs.constraintlayout) - implementation(libs.google.material) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.documentfile) + implementation(libs.miuix.ui) + // 协程和 lifecycleScope 以前是从 appcompat 传递依赖里蹭来的,这里显式声明。 + implementation(libs.coroutines.android) + implementation(libs.lifecycle.runtime.ktx) implementation(project(":MobileGlues")) + + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ac54a40..694ef3af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,24 @@ android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> + + + + + + + + + + + + + + + diff --git a/app/src/main/aidl/com/fcl/plugin/mobileglues/IMgQuery.aidl b/app/src/main/aidl/com/fcl/plugin/mobileglues/IMgQuery.aidl new file mode 100644 index 00000000..ec1e8b07 --- /dev/null +++ b/app/src/main/aidl/com/fcl/plugin/mobileglues/IMgQuery.aidl @@ -0,0 +1,19 @@ +package com.fcl.plugin.mobileglues; + +/** + * 渲染器查询:在一次性的 :mgquery 进程里执行。 + * + * 每个方法都是阻塞的同步调用(runBench 可达一分多钟),binder 会占住调用方的一条线程, + * 所以只能从后台线程发起。angleDirectory 传空串表示「不借 ANGLE」——AIDL 的 String + * 不好表达 null,而渲染器那边空串与 null 本就同义(MG_ANGLE_DIR 的空值状态)。 + */ +interface IMgQuery { + /** + * startSections = 起手场景规模,0 用渲染器默认值;maxSections = 本次不得越过的上限, + * 0 表示没有。上一次跑分丢了 GL 上下文之后,调用方带着上限重来一次——崩过的那个规模 + * 只能由调用方记住,查询进程每次都是新的,它自己不可能知道。 + */ + String runBench(String mgDirectory, String angleDirectory, int startSections, int maxSections); + int benchProgress(); + String glInfo(String mgDirectory, String angleDirectory); +} diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 67c7d745..6d632f81 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -10,6 +10,7 @@ add_library(mobileglues_info_getter SHARED ) find_library(android-lib android) +find_library(log-lib log) target_compile_options(mobileglues_info_getter PRIVATE -Oz @@ -34,5 +35,6 @@ target_include_directories(mobileglues_info_getter PRIVATE target_link_libraries(mobileglues_info_getter ${android-lib} + ${log-lib} dl ) diff --git a/app/src/main/cpp/mobileglues_info_getter.cpp b/app/src/main/cpp/mobileglues_info_getter.cpp index 8c790504..e3ec0ff3 100644 --- a/app/src/main/cpp/mobileglues_info_getter.cpp +++ b/app/src/main/cpp/mobileglues_info_getter.cpp @@ -136,6 +136,17 @@ static std::string create_context_and_query() { out << "Is MobileGlues (>=1.3.3): " << (g_MGQueryCapability.HasMobileGluesExt ? "Yes\n" : "No\n"); + // Which driver actually answered. The loader is the only honest source: the + // renderer string cannot be trusted (a system driver may itself be ANGLE), + // and the caller's own "did I pass a directory" is an intention, not a fact. + // Old renderers lack the symbol; the line is simply absent then, and the + // Kotlin side treats that as "unknown". + typedef int (*PFN_mg_angle_in_use)(); + auto p_angle_in_use = (PFN_mg_angle_in_use) dlsym(mg_handle, "mg_angle_in_use"); + if (p_angle_in_use) { + out << "ANGLE in use: " << (p_angle_in_use() ? "yes" : "no") << "\n"; + } + const GLubyte* renderer = p_glGetString(GL_RENDERER); const GLubyte* version = p_glGetString(GL_VERSION); const GLubyte* vendor = p_glGetString(GL_VENDOR); @@ -203,6 +214,118 @@ Java_com_fcl_plugin_mobileglues_MGInfoGetter_getMobileGluesGLInfo(JNIEnv *env, return env->NewStringUTF(res.c_str()); } +// Runs the MultiDraw micro benchmark that lives inside libmobileglues +// (gl/multidraw_bench.cpp) and returns its JSON verbatim. Same shape as the GL +// info query: dlopen the renderer, make a context current through its own EGL +// layer, call one symbol, tear everything down. +// Published while the benchmark runs so the UI thread can poll how far along it +// is. The pointer belongs to a dlopen'd library, so it is only valid between +// the dlsym below and the dlclose at the end of the run -- hence the lock. +typedef int (*PFN_mg_multidraw_bench_progress)(); +static std::mutex g_bench_progress_mutex; +static PFN_mg_multidraw_bench_progress p_bench_progress = nullptr; + +static void set_bench_progress_fn(PFN_mg_multidraw_bench_progress fn) { + std::lock_guard lock(g_bench_progress_mutex); + p_bench_progress = fn; +} + +static std::string create_context_and_bench(int start_sections, int max_sections) { + if (!load_mobile_symbols()) return R"({"error":"failed to load libmobileglues symbols"})"; + + typedef const char* (*PFN_mg_multidraw_bench_run)(int, int); + auto p_bench = (PFN_mg_multidraw_bench_run) dlsym(mg_handle, "mg_multidraw_bench_run"); + if (!p_bench) { + return R"({"error":"mg_multidraw_bench_run is missing; the renderer is too old"})"; + } + // Optional: an older renderer has the benchmark but not the progress + // counter, and the caller falls back to an indeterminate spinner. + auto p_progress = (PFN_mg_multidraw_bench_progress) dlsym(mg_handle, "mg_multidraw_bench_progress"); + + EGLDisplay display = p_eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (display == EGL_NO_DISPLAY) return R"({"error":"eglGetDisplay returned EGL_NO_DISPLAY"})"; + + EGLint major = 0, minor = 0; + if (!p_eglInitialize(display, &major, &minor)) { + return R"({"error":"eglInitialize failed"})"; + } + + EGLint configAttribs[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, + EGL_NONE + }; + EGLConfig config; + EGLint numConfigs = 0; + if (!p_eglChooseConfig(display, configAttribs, &config, 1, &numConfigs) || numConfigs <= 0) { + p_eglTerminate(display); + return R"({"error":"eglChooseConfig failed"})"; + } + + EGLint pbufAttribs[] = { EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE }; + EGLSurface surface = p_eglCreatePbufferSurface(display, config, pbufAttribs); + if (surface == EGL_NO_SURFACE) { + p_eglTerminate(display); + return R"({"error":"eglCreatePbufferSurface failed"})"; + } + + EGLint ctxAttribs3[] = { EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE }; + EGLContext context = p_eglCreateContext(display, config, EGL_NO_CONTEXT, ctxAttribs3); + if (context == EGL_NO_CONTEXT) { + p_eglDestroySurface(display, surface); + p_eglTerminate(display); + return R"({"error":"eglCreateContext failed"})"; + } + + if (!p_eglMakeCurrent(display, surface, surface, context)) { + p_eglDestroyContext(display, context); + p_eglDestroySurface(display, surface); + p_eglTerminate(display); + return R"({"error":"eglMakeCurrent failed"})"; + } + + set_bench_progress_fn(p_progress); + const char* raw = p_bench(start_sections, max_sections); + // Copy before teardown: the string lives inside libmobileglues. + std::string res = raw ? raw : R"({"error":"benchmark returned nothing"})"; + set_bench_progress_fn(nullptr); + + p_eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + p_eglDestroySurface(display, surface); + p_eglDestroyContext(display, context); + p_eglTerminate(display); + + dlclose(mg_handle); + mg_handle = nullptr; + + return res; +} + +extern "C" +JNIEXPORT jstring JNICALL +Java_com_fcl_plugin_mobileglues_MGBench_runMultidrawBench(JNIEnv *env, jobject thiz, + jint startSections, jint maxSections) { + std::string res = create_context_and_bench(startSections, maxSections); + printf("MobileGlues MultiDraw bench: \n%s", res.c_str()); + __android_log_print(ANDROID_LOG_INFO, "MGBench", "%s", res.c_str()); + return env->NewStringUTF(res.c_str()); +} + +// 0..1000 while a benchmark is running, -1 when none is (or when the renderer +// predates the progress counter). Called from a different thread than the run. +extern "C" +JNIEXPORT jint JNICALL +Java_com_fcl_plugin_mobileglues_MGBench_benchProgress(JNIEnv *env, jobject thiz) { + (void)env; + (void)thiz; + std::lock_guard lock(g_bench_progress_mutex); + return p_bench_progress ? p_bench_progress() : -1; +} + JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { (void)vm; (void)reserved; diff --git a/app/src/main/cpp/mobileglues_info_getter.h b/app/src/main/cpp/mobileglues_info_getter.h index 92c2f24c..b564a044 100644 --- a/app/src/main/cpp/mobileglues_info_getter.h +++ b/app/src/main/cpp/mobileglues_info_getter.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/DeviceInfo.kt b/app/src/main/java/com/fcl/plugin/mobileglues/DeviceInfo.kt new file mode 100644 index 00000000..c991b6ad --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/DeviceInfo.kt @@ -0,0 +1,93 @@ +package com.fcl.plugin.mobileglues + +import android.app.ActivityManager +import android.content.Context +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLSurface +import android.opengl.GLES20 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** 首页要展示的设备信息。 */ +data class DeviceInfo( + val gpuRenderer: String?, + val glesVersion: String?, + val totalRamBytes: Long, +) + +/** + * 查 GPU 名字和 GLES 版本要创建一次 EGL 上下文(重活,放后台线程), + * 结果在进程内不会变,查一次缓存住——首页展示和 Adreno 740 判定共用这份缓存。 + */ +object DeviceInfoProvider { + + private val lock = Mutex() + private var cached: DeviceInfo? = null + + suspend fun get(context: Context): DeviceInfo = lock.withLock { + cached?.let { return it } + val gl = withContext(Dispatchers.Default) { probeGl() } + val memoryInfo = ActivityManager.MemoryInfo() + context.getSystemService(ActivityManager::class.java).getMemoryInfo(memoryInfo) + DeviceInfo( + gpuRenderer = gl?.first, + glesVersion = gl?.second, + totalRamBytes = memoryInfo.totalMem, + ).also { cached = it } + } + + suspend fun isAdreno740(context: Context): Boolean { + val renderer = get(context).gpuRenderer ?: return false + return renderer.contains("adreno", ignoreCase = true) && + renderer.contains("740") + } + + /** GL_RENDERER + GL_VERSION;拿不到(模拟器异常等)返回 null。 */ + private fun probeGl(): Pair? { + val eglDisplay: EGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + if (eglDisplay == EGL14.EGL_NO_DISPLAY || + !EGL14.eglInitialize(eglDisplay, IntArray(2), 0, IntArray(2), 1) + ) return null + + var result: Pair? = null + val configAttributes = + intArrayOf(EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, EGL14.EGL_NONE) + val eglConfigs = arrayOfNulls(1) + val numConfigs = IntArray(1) + + if (EGL14.eglChooseConfig( + eglDisplay, configAttributes, 0, eglConfigs, 0, 1, numConfigs, 0 + ) + ) { + val contextAttributes = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE) + val eglContext: EGLContext = EGL14.eglCreateContext( + eglDisplay, eglConfigs[0], EGL14.EGL_NO_CONTEXT, contextAttributes, 0 + ) + + if (eglContext != EGL14.EGL_NO_CONTEXT) { + val pbufferAttributes = + intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE) + val eglSurface: EGLSurface = + EGL14.eglCreatePbufferSurface(eglDisplay, eglConfigs[0], pbufferAttributes, 0) + if (eglSurface != EGL14.EGL_NO_SURFACE) { + if (EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) { + val renderer = GLES20.glGetString(GLES20.GL_RENDERER) + val version = GLES20.glGetString(GLES20.GL_VERSION) + if (renderer != null || version != null) { + result = (renderer ?: "") to (version ?: "") + } + } + EGL14.eglDestroySurface(eglDisplay, eglSurface) + } + EGL14.eglDestroyContext(eglDisplay, eglContext) + } + } + EGL14.eglTerminate(eglDisplay) + return result + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/MGApplication.kt b/app/src/main/java/com/fcl/plugin/mobileglues/MGApplication.kt new file mode 100644 index 00000000..67107240 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/MGApplication.kt @@ -0,0 +1,43 @@ +package com.fcl.plugin.mobileglues + +import android.app.Application +import com.fcl.plugin.mobileglues.settings.AuthController +import com.fcl.plugin.mobileglues.settings.MGCacheExporter +import com.fcl.plugin.mobileglues.settings.MGConfigStore +import com.fcl.plugin.mobileglues.settings.PluginConfigStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +/** + * 持有各仓库与授权控制器。 + * + * 放在 Application 而不是 Activity 上,是因为去抖写盘的协程需要一个比界面更长的生命周期: + * 用户改完设置立刻按 Home 键时,落盘不应该被 Activity 的销毁打断。 + * 启动次数也记在这里:旋转屏幕会重建 Activity,但不算一次启动。 + */ +class MGApplication : Application() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + val pluginConfigStore: PluginConfigStore by lazy { PluginConfigStore(this) } + + val authController: AuthController by lazy { AuthController(this, pluginConfigStore) } + + val configStore: MGConfigStore by lazy { + MGConfigStore( + storage = null, + scope = scope, + ) + } + + val cacheExporter: MGCacheExporter by lazy { MGCacheExporter(this, configStore) } + + /** 赞助弹窗每个进程最多弹一次——旋转屏幕重建 Activity 不该再弹。 */ + var sponsorPromptedThisProcess: Boolean = false + + override fun onCreate() { + super.onCreate() + authController.refresh() + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/MGBench.kt b/app/src/main/java/com/fcl/plugin/mobileglues/MGBench.kt new file mode 100644 index 00000000..a8de9ea6 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/MGBench.kt @@ -0,0 +1,69 @@ +package com.fcl.plugin.mobileglues + +import java.io.File + +object MGBench { + + init { + System.loadLibrary("mobileglues_info_getter") + } + + private external fun runMultidrawBench(startSections: Int, maxSections: Int): String + + private external fun benchProgress(): Int + + /** + * 在本进程内跑 MultiDraw 微基准。 + * + * 与 [MGInfoGetter.info] 同一条通道:dlopen libmobileglues,经渲染器自己的 EGL 层 + * 建上下文,调用 `mg_multidraw_bench_run`,拿回一段 JSON。阻塞且耗时(默认预算 8 秒, + * 见 native 侧的 MG_BENCH_BUDGET_MS),必须放在后台线程。 + * + * 只应从 [MgQueryService] 的进程里调用:环境变量和配置都在库的静态构造里读, + * 而库离不开进程,所以「每次都读到新值」只有一次性进程给得起。 + */ + /** + * @param angleDirectory 借来的 ANGLE 所在目录(某个启动器的 native 库目录); + * null = 不借,配置若要求 ANGLE 则渲染器会退回系统驱动,结果里会如实写明。 + * @param startSections 起手的场景规模;0 = 用渲染器的默认值。 + * @param maxSections 本次不得越过的规模上限;0 = 没有上限。上一次跑分丢了上下文 + * 之后由 [BenchRunner] 填进来——崩掉的那个规模不可能在同一个进程里再探一次, + * 要探就得靠新进程,而新进程不知道上一个进程撞到过什么。 + */ + fun run( + mgDirectory: File, + angleDirectory: String? = null, + startSections: Int = 0, + maxSections: Int = 0, + ): String = try { + // 跑分不是一次「启动」,不设 MG_COUNT_LAUNCH。 + MGInfoGetter.setenv("MG_PLUGIN_STATUS", "1", 1) + MGInfoGetter.setenv("MG_DIR_PATH", mgDirectory.path, 1) + // 空串等于没设:渲染器那边只认非空值,游戏进程里本来也不会有这个变量。 + MGInfoGetter.setenv("MG_ANGLE_DIR", angleDirectory.orEmpty(), 1) + runMultidrawBench(startSections, maxSections) + } catch (e: Throwable) { + """{"error":"${e.message ?: e.javaClass.simpleName}"}""" + } + + /** 第 [attempt] 次测量(1 起)跑到 [fraction](0f..1f)。 */ + data class Progress(val attempt: Int, val fraction: Float) + + /** + * 当前进度的原始编码;-1 = 没有在跑(或渲染器版本太老没有这个计数器)。 + * + * 在查询进程里由 binder 线程调用([run] 正阻塞着另一条),原样送回主进程, + * 那边用 [decodeProgress] 解开。native 把「第几次」和「这次跑到哪」编在同一个 + * 原子量里,就是为了让一次读到的两个数一定是同一时刻的。 + */ + fun rawProgress(): Int = try { + benchProgress() + } catch (_: Throwable) { + -1 + } + + /** [rawProgress] 的解码侧;负数即「没有进度」。 */ + fun decodeProgress(raw: Int): Progress? = raw.takeIf { it >= 0 }?.let { + Progress(attempt = it / 1000 + 1, fraction = (it % 1000) / 1000f) + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/MGInfoGetter.kt b/app/src/main/java/com/fcl/plugin/mobileglues/MGInfoGetter.kt new file mode 100644 index 00000000..704a3e10 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/MGInfoGetter.kt @@ -0,0 +1,36 @@ +package com.fcl.plugin.mobileglues + +import java.io.File + +object MGInfoGetter { + + init { + System.loadLibrary("mobileglues_info_getter") + } + + external fun setenv(key: String, value: String, overwrite: Int): Int + + external fun getMobileGluesGLInfo(): String + + /** + * 查询 MobileGlues 的 GL 信息。 + * + * 这是一个阻塞调用:native 侧会 dlopen libmobileglues 并创建 EGL 上下文,必须放在后台线程。 + * [mgDirectory] 显式传入,而不是像以前那样从一个可变静态字段里读。 + */ + /** + * @param angleDirectory 借来的 ANGLE 所在目录(某个启动器的 native 库目录); + * null = 不借。配置若要求 ANGLE 而这里不借,渲染器会退回系统驱动,那这份信息 + * 讲的就不是游戏里的那个驱动了。 + */ + fun info(mgDirectory: File, angleDirectory: String? = null): String = try { + // 这里不设 MG_COUNT_LAUNCH:我们自己把渲染器加载起来问一句话,不是一次「启动」。 + setenv("MG_PLUGIN_STATUS", "1", 1) + setenv("MG_DIR_PATH", mgDirectory.path, 1) + // 空串等于没设:渲染器那边只认非空值,游戏进程里本来也不会有这个变量。 + setenv("MG_ANGLE_DIR", angleDirectory.orEmpty(), 1) + getMobileGluesGLInfo() + } catch (e: Throwable) { + "Error: ${e.message}" + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/MainActivity.kt b/app/src/main/java/com/fcl/plugin/mobileglues/MainActivity.kt index 7bbe6aec..712d0b5c 100644 --- a/app/src/main/java/com/fcl/plugin/mobileglues/MainActivity.kt +++ b/app/src/main/java/com/fcl/plugin/mobileglues/MainActivity.kt @@ -1,104 +1,60 @@ package com.fcl.plugin.mobileglues import android.Manifest -import android.content.DialogInterface import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.Color import android.net.Uri -import android.opengl.EGL14 -import android.opengl.EGLConfig -import android.opengl.GLES20 import android.os.Build import android.os.Bundle -import android.os.CountDownTimer -import android.os.Environment import android.provider.Settings -import android.text.Editable -import android.text.Html -import android.text.Spanned -import android.text.TextWatcher -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.CompoundButton -import android.widget.Spinner +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat import androidx.core.net.toUri -import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.lifecycleScope -import com.fcl.plugin.mobileglues.databinding.ActivityMainBinding -import com.fcl.plugin.mobileglues.settings.MGConfig -import com.fcl.plugin.mobileglues.utils.Constants -import com.fcl.plugin.mobileglues.utils.toast -import com.google.android.material.color.MaterialColors -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.AuthFlowLauncher +import com.fcl.plugin.mobileglues.ui.MobileGluesApp import kotlin.system.exitProcess -import androidx.appcompat.R as AppcompatR -class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, - CompoundButton.OnCheckedChangeListener { +/** + * 壳:导航、页面容器、授权流程。全部界面逻辑在 [AppController],全部绘制在两套皮肤里。 + * + * Activity 只做两件别处做不了的事:持有 ActivityResult 启动器(系统授权页 / + * SAF 选择器 / 运行时权限),以及在移除完成后退出进程。 + */ +class MainActivity : ComponentActivity(), AuthFlowLauncher { - // ---- GL 版本映射 ---- - private val glVersionMap: Map by lazy { - linkedMapOf( - getString(R.string.option_angle_disable) to 0, - "OpenGL 4.6" to 46, "OpenGL 4.5" to 45, "OpenGL 4.4" to 44, - "OpenGL 4.3" to 43, "OpenGL 4.2" to 42, "OpenGL 4.1" to 41, - "OpenGL 4.0" to 40, "OpenGL 3.3" to 33, "OpenGL 3.2" to 32 - ) - } - - private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } - private var config: MGConfig? = null - private var isSpinnerInitialized = false + private lateinit var controller: AppController - // ---- Activity Result Launchers ---- private val manageAllFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - handlePermissionResult(hasMgDirectoryAccess()) + controller.onAllFilesResult() + } + + private val safPickerLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + uri?.let { + contentResolver.takePersistableUriPermission( + it, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + controller.onSafResult(uri) } private val appSettingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - handlePermissionResult(hasLegacyPermissions()) + // 从应用详情页回来:用户可能手动开了权限,重新核验一遍。 + controller.refreshAuthorization() } private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - when { - isGranted -> handlePermissionResult(true) - !shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE) -> showGoToSettingsDialog() - else -> handlePermissionResult(false) - } + val permanentlyDenied = !isGranted && + !shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE) + controller.onLegacyPermissionResult(isGranted, permanentlyDenied) } - private fun handlePermissionResult(isGranted: Boolean) { - if (isGranted) { - MGConfig(this).save() - showOptions() - } else { - snackbar(getString(R.string.permission_failed)) - hideOptions() - } - } - - // ---- 生命周期 ---- override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -106,564 +62,84 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, window.isNavigationBarContrastEnforced = false } - setContentView(binding.root) - setSupportActionBar(binding.appBar) - setupSpinners() - - binding.openOptions.setOnClickListener { - if (hasMgDirectoryAccess()) { - // 如果已经授权,但因没有 MG 文件夹而停留在首屏,直接触发创建并进入设置 - handlePermissionResult(true) - } else { - // 如果未授权,走正常的授权流程 - checkPermission() - } - } - setupWindowInsets() + controller = AppController(application as MGApplication, this, this) + setContent { MobileGluesApp(controller) } + consumeHeadlessRequest(intent) } - override fun onResume() { - super.onResume() - checkPermissionSilently() + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + consumeHeadlessRequest(intent) } - // ---- 菜单 ---- - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - menu.findItem(R.id.action_remove)?.isEnabled = - hasMgDirectoryAccess() && File(Constants.CONFIG_FILE_PATH).exists() - return true + /** + * adb 直接发起的跑分。没有界面入口——见 AppController.runMultidrawBenchHeadless。 + * + * extra 消费后就从 Intent 上抹掉:旋转屏幕会带着同一个 Intent 重建 Activity,不抹掉 + * 就会再跑一次。 + */ + private fun consumeHeadlessRequest(intent: Intent?) { + val entry = intent?.getStringExtra(EXTRA_BENCH) ?: return + intent.removeExtra(EXTRA_BENCH) + val angle = intent.getStringExtra(EXTRA_ANGLE) + intent.removeExtra(EXTRA_ANGLE) + controller.runMultidrawBenchHeadless(entry, angle) } - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - R.id.action_about -> { - showAppInfoDialog(this, config); true - } - - R.id.action_remove -> { - showRemoveConfirmationDialog(); true - } - - else -> super.onOptionsItemSelected(item) + override fun onResume() { + super.onResume() + controller.refreshAuthorization() } - // ---- UI 初始化 ---- - private fun setupWindowInsets() { - val optionLayoutParams = binding.optionLayout.layoutParams as ViewGroup.MarginLayoutParams - window.decorView.setOnApplyWindowInsetsListener { _, insets -> - @Suppress("DEPRECATION") - val bottomInset = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom - } else { - insets.systemWindowInsetBottom - } - optionLayoutParams.setMargins(0, 0, 0, bottomInset) - insets - } + override fun onStop() { + super.onStop() + // 用 store 自己的作用域,不能用界面层的作用域:旋转屏幕时后者会在 onDestroy 被取消, + // 这次保存可能还没轮到 IO 线程就被砍掉。 + (application as MGApplication).configStore.flushAsync() } - private fun setupSpinners() { - fun bindSpinner(spinner: Spinner, arrayRes: Int) { - ArrayAdapter.createFromResource(this, arrayRes, R.layout.spinner).also { adapter -> - adapter.setDropDownViewResource(R.layout.spinner) - spinner.adapter = adapter - } - } - - bindSpinner(binding.spinnerAngle, R.array.angle_options) - bindSpinner(binding.spinnerNoError, R.array.no_error_options) - bindSpinner(binding.spinnerMultidrawMode, R.array.multidraw_mode_options) - bindSpinner(binding.angleClearWorkaround, R.array.angle_clear_workaround_options) - - binding.spinnerCustomGlVersion.adapter = - ArrayAdapter(this, R.layout.spinner, ArrayList(glVersionMap.keys)) + override fun onDestroy() { + super.onDestroy() + controller.destroy() } - // ---- 权限检查 ---- - private fun hasMgDirectoryAccess(): Boolean = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Environment.isExternalStorageManager() - } else { - hasLegacyPermissions() - } - - private fun hasLegacyPermissions(): Boolean = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.READ_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission( - this, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED - - private fun checkPermissionSilently() { - // 关键逻辑:只有在已经授权且配置文件/MG文件夹存在时,才自动进入设置 - val isConfigured = File(Constants.CONFIG_FILE_PATH).exists() + // ---- AuthFlowLauncher ---- - if (hasMgDirectoryAccess() && isConfigured) { - (MGConfig.loadConfig(this) ?: MGConfig(this)).save() - showOptions() - } else { - // 否则(未授权,或已授权但未点击启用),停留在启动页并显示 openOptions 按钮 - hideOptions() - } - } - - private fun checkPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - showManageAllFilesDialog() - } else { - if (hasLegacyPermissions()) showOptions() - else requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - } - - // ---- 权限相关对话框 ---- - @RequiresApi(Build.VERSION_CODES.R) - private fun showManageAllFilesDialog() { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_permission_title) - .setMessage(getString(R.string.dialog_permission_msg_android_Q, Constants.MG_DIRECTORY)) - .setPositiveButton(R.string.dialog_positive) { _, _ -> launchManageAllFilesSettings() } - .setNegativeButton(R.string.dialog_negative, null) - .show() - } - - @RequiresApi(Build.VERSION_CODES.R) - private fun launchManageAllFilesSettings() { + override fun openAllFilesSettings() { try { manageAllFilesLauncher.launch( Intent( Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - "package:$packageName".toUri() - ) + "package:$packageName".toUri(), + ), ) } catch (_: Exception) { manageAllFilesLauncher.launch(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) } } - private fun showGoToSettingsDialog() { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_permission_title) - .setMessage(R.string.dialog_permission_msg) - .setPositiveButton(R.string.dialog_positive) { _, _ -> - appSettingsLauncher.launch( - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", packageName, null) - ) - ) - } - .setNegativeButton(R.string.dialog_negative, null) - .show() - } - - // ---- 选项面板显示/隐藏 ---- - private fun showOptions() { - isSpinnerInitialized = false - setAllListeners(null) - - config = (MGConfig.loadConfig(this) ?: MGConfig(this)).apply { - if (enableANGLE !in 0..3) enableANGLE = 0 - if (enableNoError !in 0..3) enableNoError = 0 - if (maxGlslCacheSize <= 0 && maxGlslCacheSize != -1) maxGlslCacheSize = 32 - - binding.inputMaxGlslCacheSize.setText(maxGlslCacheSize.toString()) - binding.spinnerAngle.setSelection(enableANGLE) - binding.spinnerNoError.setSelection(enableNoError) - binding.spinnerMultidrawMode.setSelection(multidrawMode) - binding.angleClearWorkaround.setSelection(angleDepthClearFixMode) - - binding.switchExtTimerQuery.isChecked = enableExtTimerQuery == 0 - binding.switchExtDirectStateAccess.isChecked = enableExtDirectStateAccess == 1 - binding.switchExtCs.isChecked = enableExtComputeShader == 1 - binding.switchEnableFsr1.isChecked = fsr1Setting == 1 - - binding.spinnerCustomGlVersion.setSelection(getSpinnerIndexByGLVersion(customGLVersion)) - } - - setAllListeners(this) - setupGlslCacheSizeWatcher() - - binding.openOptions.visibility = View.GONE - binding.scrollLayout.visibility = View.VISIBLE - - binding.root.post { isSpinnerInitialized = true } - } - - private fun hideOptions() { - binding.openOptions.visibility = View.VISIBLE - binding.scrollLayout.visibility = View.GONE - invalidateOptionsMenu() - } - - private fun setAllListeners(listener: Any?) { - val itemListener = listener as? AdapterView.OnItemSelectedListener - val checkedListener = listener as? CompoundButton.OnCheckedChangeListener - - invalidateOptionsMenu() - - binding.apply { - spinnerAngle.onItemSelectedListener = itemListener - spinnerNoError.onItemSelectedListener = itemListener - spinnerMultidrawMode.onItemSelectedListener = itemListener - spinnerCustomGlVersion.onItemSelectedListener = itemListener - angleClearWorkaround.onItemSelectedListener = itemListener - - switchExtCs.setOnCheckedChangeListener(checkedListener) - switchExtTimerQuery.setOnCheckedChangeListener(checkedListener) - switchExtDirectStateAccess.setOnCheckedChangeListener(checkedListener) - switchEnableFsr1.setOnCheckedChangeListener(checkedListener) - } - } - - private fun setupGlslCacheSizeWatcher() { - binding.inputMaxGlslCacheSize.addTextChangedListener(object : TextWatcher { - override fun afterTextChanged(s: Editable?) { - val text = s.toString().trim() - if (text.isEmpty()) { - binding.inputMaxGlslCacheSizeLayout.error = null - config?.maxGlslCacheSize = 32 - return - } - text.toIntOrNull()?.let { number -> - if (number < -1 || number == 0) { - binding.inputMaxGlslCacheSizeLayout.error = - getString(R.string.option_glsl_cache_error_range) - } else { - binding.inputMaxGlslCacheSizeLayout.error = null - config?.maxGlslCacheSize = number - } - } ?: run { - binding.inputMaxGlslCacheSizeLayout.error = - getString(R.string.option_glsl_cache_error_invalid) - } - } - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - }) - } - - // ---- Spinner 回调 ---- - override fun onItemSelected(adapterView: AdapterView<*>, view: View?, position: Int, id: Long) { - if (!isSpinnerInitialized || config == null) return - - when (adapterView.id) { - R.id.spinner_angle -> handleAngleSelection(position) - R.id.spinner_no_error -> config?.enableNoError = position - R.id.spinner_multidraw_mode -> config?.multidrawMode = position - R.id.spinner_custom_gl_version -> handleCustomGLVersionSelection(position) - R.id.angle_clear_workaround -> handleAngleClearWorkaroundSelection(position) - } - } - - override fun onNothingSelected(parent: AdapterView<*>) {} - - private fun handleAngleSelection(position: Int) { - val previous = config?.enableANGLE ?: return - if (position == previous) return - - if (position == 3 && isAdreno740()) { - showWarningDialog( - messageRes = R.string.warning_adreno_740_angle, - onConfirm = { config?.enableANGLE = position }, - onCancel = { revertSpinner(binding.spinnerAngle, previous) } - ) - } else { - config?.enableANGLE = position - } - } - - private fun handleAngleClearWorkaroundSelection(position: Int) { - val previous = config?.angleDepthClearFixMode ?: return - if (position == previous) return - - if (position >= 1) { - showWarningDialog( - messageRes = R.string.warning_enabling_angle_clear_workaround, - onConfirm = { config?.angleDepthClearFixMode = position }, - onCancel = { revertSpinner(binding.angleClearWorkaround, previous) } - ) - } else { - config?.angleDepthClearFixMode = position - } - } - - private fun handleCustomGLVersionSelection(position: Int) { - val previous = config?.customGLVersion ?: return - val newValue = getGLVersionBySpinnerIndex(position) - if (newValue == previous) return - - if (previous == 0) { - showCountdownWarningDialog( - messageRes = R.string.warning_enabling_custom_gl_version, - cooldownSeconds = 41, - onConfirm = { config?.customGLVersion = newValue }, - onCancel = { - revertSpinner( - binding.spinnerCustomGlVersion, - getSpinnerIndexByGLVersion(previous) - ) - } - ) - } else { - config?.customGLVersion = newValue - } - } - - // ---- Switch 回调 ---- - override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { - if (config == null) return - - when (buttonView.id) { - R.id.switch_ext_cs -> handleSwitchWithWarning( - isChecked = isChecked, - warningMsgRes = R.string.warning_ext_cs_enable, - onConfirm = { config?.enableExtComputeShader = 1 }, - onCancel = { config?.enableExtComputeShader = 0 }, - button = buttonView - ) - - R.id.switch_enable_fsr1 -> handleSwitchWithWarning( - isChecked = isChecked, - warningMsgRes = R.string.warning_fsr1_enable, - onConfirm = { config?.fsr1Setting = 1 }, - onCancel = { config?.fsr1Setting = 0 }, - button = buttonView - ) - - R.id.switch_ext_timer_query -> config?.enableExtTimerQuery = if (isChecked) 0 else 1 - R.id.switch_ext_direct_state_access -> config?.enableExtDirectStateAccess = - if (isChecked) 1 else 0 - } - } - - private fun handleSwitchWithWarning( - isChecked: Boolean, - @StringRes warningMsgRes: Int, - onConfirm: () -> Unit, - onCancel: () -> Unit, - button: CompoundButton - ) { - if (isChecked) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_title_warning) - .setMessage(warningMsgRes) - .setCancelable(false) - .setOnKeyListener { _, keyCode, _ -> keyCode == KeyEvent.KEYCODE_BACK } - .setPositiveButton(R.string.dialog_positive) { _, _ -> onConfirm() } - .setNegativeButton(R.string.dialog_negative) { _, _ -> - button.isChecked = false - onCancel() - } - .show() - } else { - onCancel() - } + override fun openSafPicker() { + safPickerLauncher.launch(null) } - // ---- 对话框辅助 ---- - private fun showWarningDialog( - @StringRes messageRes: Int, - onConfirm: () -> Unit, - onCancel: () -> Unit - ) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_title_warning) - .setMessage(getString(messageRes)) - .setCancelable(false) - .setPositiveButton(R.string.dialog_positive) { _, _ -> onConfirm() } - .setNegativeButton(R.string.dialog_negative) { _, _ -> onCancel() } - .show() + override fun requestLegacyPermission() { + requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } - private fun showCountdownWarningDialog( - @StringRes messageRes: Int, - cooldownSeconds: Int, - onConfirm: () -> Unit, - onCancel: () -> Unit - ) { - val dialog = MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_title_warning) - .setMessage(getStyledMessage(messageRes)) - .setCancelable(false) - .setPositiveButton(R.string.ok, null) - .setNegativeButton(R.string.dialog_negative) { _, _ -> onCancel() } - .show() - - val positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE) - positiveButton.isEnabled = false - positiveButton.setOnClickListener { onConfirm(); dialog.dismiss() } - - object : CountDownTimer(cooldownSeconds * 1000L, 1000) { - override fun onTick(millisUntilFinished: Long) { - positiveButton.text = - getString(R.string.ok_with_countdown, (millisUntilFinished / 1000).toInt()) - } - - override fun onFinish() { - positiveButton.apply { - text = getString(R.string.ok) - setTextColor( - MaterialColors.getColor( - context, - AppcompatR.attr.colorError, - Color.RED - ) - ) - isEnabled = true - } - } - }.start() - } - - // ---- 删除 MobileGlues ---- - private fun showRemoveConfirmationDialog() { - showCountdownWarningDialog( - R.string.remove_mg_files_message, - 10, - { removeMobileGluesCompletely() }, - {}) - } - - private fun removeMobileGluesCompletely() { - val view = LayoutInflater.from(this).inflate(R.layout.progress_dialog_md3, null) - - val progressDialog = MaterialAlertDialogBuilder(this) - .setTitle(R.string.removing_mobileglues) - .setView(view) - .setCancelable(false) - .show() - - lifecycleScope.launch(Dispatchers.IO) { - try { - File(Constants.MG_DIRECTORY).deleteRecursively() - - withContext(Dispatchers.Main) { - config = null - hideOptions() - progressDialog.dismiss() - showFinalDialog() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - progressDialog.dismiss() - toast(getString(R.string.remove_failed, e.message)) - } - } - } - } - - private fun showFinalDialog() { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.remove_complete_title) - .setMessage(R.string.remove_complete_message) - .setCancelable(false) - .setPositiveButton(R.string.exit) { _, _ -> finishAffinity(); exitProcess(0) } - .show() - } - - // ---- GPU 检测 ---- - private fun getGPUName(): String? { - val eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) - if (eglDisplay == EGL14.EGL_NO_DISPLAY || !EGL14.eglInitialize( - eglDisplay, - IntArray(2), - 0, - IntArray(2), - 1 - ) - ) return null - - var renderer: String? = null - val configAttributes = - intArrayOf(EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, EGL14.EGL_NONE) - val eglConfigs = arrayOfNulls(1) - val numConfigs = IntArray(1) - - if (EGL14.eglChooseConfig( - eglDisplay, - configAttributes, - 0, - eglConfigs, - 0, - 1, - numConfigs, - 0 - ) - ) { - val contextAttributes = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE) - val eglContext = EGL14.eglCreateContext( - eglDisplay, - eglConfigs[0], - EGL14.EGL_NO_CONTEXT, - contextAttributes, - 0 - ) - - if (eglContext != EGL14.EGL_NO_CONTEXT) { - val pbufferAttributes = - intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE) - val eglSurface = - EGL14.eglCreatePbufferSurface(eglDisplay, eglConfigs[0], pbufferAttributes, 0) - if (eglSurface != EGL14.EGL_NO_SURFACE) { - if (EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) { - renderer = GLES20.glGetString(GLES20.GL_RENDERER) - } - EGL14.eglDestroySurface(eglDisplay, eglSurface) - } - EGL14.eglDestroyContext(eglDisplay, eglContext) - } - } - EGL14.eglTerminate(eglDisplay) - return renderer - } - - private fun isAdreno740(): Boolean { - return getGPUName()?.let { - it.contains( - "adreno", - ignoreCase = true - ) && it.contains("740") - } == true - } - - // ---- GL Version Spinner 辅助 ---- - private fun getGLVersionBySpinnerIndex(index: Int): Int { - val selected = - binding.spinnerCustomGlVersion.getItemAtPosition(index) as? String ?: return 0 - return glVersionMap[selected] ?: 0 - } - - private fun getSpinnerIndexByGLVersion(glVersion: Int): Int { - val targetDisplay = glVersionMap.entries.firstOrNull { it.value == glVersion }?.key - ?: getString(R.string.option_angle_disable) - return glVersionMap.keys.indexOf(targetDisplay).coerceAtLeast(0) - } - - private fun revertSpinner(spinner: Spinner, position: Int) { - isSpinnerInitialized = false - spinner.setSelection(position) - spinner.post { isSpinnerInitialized = true } + override fun openAppDetailsSettings() { + appSettingsLauncher.launch( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)), + ) } - // ---- 样式化消息 ---- - private fun getStyledMessage(@StringRes id: Int): Spanned { - val errorColorHex = String.format( - "#%06X", - 0xFFFFFF and MaterialColors.getColor(this, AppcompatR.attr.colorError, Color.RED) - ) - return Html.fromHtml( - getString(id).replace("@colorError", errorColorHex), - Html.FROM_HTML_MODE_LEGACY - ) + override fun exitApp() { + finishAffinity() + exitProcess(0) } - // ---- Snackbar ---- - fun snackbar(text: CharSequence, duration: Int = Snackbar.LENGTH_SHORT) { - Snackbar.make(binding.root, text, duration).show() + private companion object { + const val EXTRA_BENCH = "mg_bench" + const val EXTRA_ANGLE = "mg_angle" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/MgQuery.kt b/app/src/main/java/com/fcl/plugin/mobileglues/MgQuery.kt new file mode 100644 index 00000000..aa0bd180 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/MgQuery.kt @@ -0,0 +1,65 @@ +package com.fcl.plugin.mobileglues + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import java.io.IOException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * 把一次渲染器查询放进一次性的 [MgQueryService] 进程里执行。 + * + * 为什么不能就地跑:见 [MgQueryService] 顶部——库进了进程就出不去,环境和配置只被 + * 第一次查询读到。这里每次查询绑一次服务、查完解绑,服务进程随解绑自杀,于是 + * 「每次查询都从头初始化」从祈愿变成结构保证。 + * + * 同一时刻只允许一个查询在跑:两个查询若共用一个服务进程,先结束的那个解绑会把进程 + * 连同另一个查询一起杀掉。互斥而不是并发,是这个设计的一部分。 + */ +class MgQuery(private val context: Context) { + + /** 查询进程没绑上,或半路死了(多半是驱动在里面崩了)。 */ + class RemoteFailure(message: String, cause: Throwable? = null) : IOException(message, cause) + + private val mutex = Mutex() + + /** + * 绑定 → 执行 [block] → 解绑(进而杀掉查询进程)。 + * + * [block] 里的 binder 调用是阻塞的(跑分要以分钟计),必须已经在后台调度器上。 + * 查询进程死亡时,进行中的调用抛 DeadObjectException,由调用方兜成错误报告。 + */ + suspend fun use(block: suspend (IMgQuery) -> T): T = mutex.withLock { + val ready = CompletableDeferred() + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + ready.complete(IMgQuery.Stub.asInterface(service)) + } + + override fun onServiceDisconnected(name: ComponentName?) { + // 只影响还没连上的等待者;已经在飞的调用自己会收到 DeadObjectException。 + ready.completeExceptionally(RemoteFailure("query process died while connecting")) + } + + override fun onNullBinding(name: ComponentName?) { + ready.completeExceptionally(RemoteFailure("query service refused the binding")) + } + } + // bindService 返回 false 也要求 unbind,所以两条路径都走同一个 finally。 + val requested = context.bindService( + Intent(context, MgQueryService::class.java), + connection, + Context.BIND_AUTO_CREATE, + ) + try { + if (!requested) throw RemoteFailure("bindService failed") + block(ready.await()) + } finally { + runCatching { context.unbindService(connection) } + } + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/MgQueryService.kt b/app/src/main/java/com/fcl/plugin/mobileglues/MgQueryService.kt new file mode 100644 index 00000000..74a69897 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/MgQueryService.kt @@ -0,0 +1,51 @@ +package com.fcl.plugin.mobileglues + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.Process +import java.io.File + +/** + * 一次性查询进程(`android:process=":mgquery"`)。 + * + * 渲染器只在 libmobileglues 第一次进入进程时读环境和配置:MG_ANGLE_DIR、config.json + * 都在它的静态构造函数里消费,而这个库一旦载入就退不出去——GL 线程的 thread_local + * 析构攥着 dlopen 引用,dlclose 的计数永远减不到零,构造函数不会再跑。于是同一个进程里 + * 第一次查询选定的驱动会钉死后面的每一次:后来借的 ANGLE 不生效,后来改选的系统驱动 + * 仍然是 ANGLE。 + * + * 解法不在进程里,而在进程外:查询挪进这个进程,查完即由 [onUnbind] 自杀。下一次查询 + * 绑定时系统重新拉起进程,一切从头初始化,环境和配置必然是新的。附带的好处是隔离: + * 驱动在跑分中崩溃,死的是这个进程,主界面只会收到一个 binder 异常。 + */ +class MgQueryService : Service() { + + private val binder = object : IMgQuery.Stub() { + override fun runBench( + mgDirectory: String, + angleDirectory: String, + startSections: Int, + maxSections: Int, + ): String = MGBench.run( + File(mgDirectory), + angleDirectory.takeUnless { it.isEmpty() }, + startSections, + maxSections, + ) + + override fun benchProgress(): Int = MGBench.rawProgress() + + override fun glInfo(mgDirectory: String, angleDirectory: String): String = + MGInfoGetter.info(File(mgDirectory), angleDirectory.takeUnless { it.isEmpty() }) + } + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onUnbind(intent: Intent?): Boolean { + // 客户端在拿到返回值之后才解绑,所以走到这里时结果一定已经送出去了。 + // 自杀而不是等系统回收:回收「最终会发生」,而下一次查询要求的是「必然全新」。 + Process.killProcess(Process.myPid()) + return false + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/AngleProvider.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/AngleProvider.kt new file mode 100644 index 00000000..4b15c88c --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/AngleProvider.kt @@ -0,0 +1,86 @@ +package com.fcl.plugin.mobileglues.settings + +import android.content.Context +import android.content.pm.PackageManager +import java.io.File + +/** + * 一个能提供 ANGLE 的启动器。 + * + * [libraryDir] 是它解压出来的 native 库目录(`nativeLibraryDir`),里面躺着 + * ANGLE 的那两个 .so。 + */ +data class AngleSource( + val packageName: String, + val label: String, + val versionName: String?, + val libraryDir: String, +) + +/** + * 找出设备上谁能借 ANGLE 给我们。 + * + * ANGLE 不在本 App 里——它随启动器分发,MobileGlues 在游戏进程里能直接 dlopen 到它, + * 是因为那时候进程属于启动器。本 App 自己 dlopen 渲染器跑分时没有这个便利: + * [com.fcl.plugin.mobileglues.settings.AngleProvider] 就是用来补上这一段的。 + * + * 能这么做的前提有三条,都验过:启动器开了 `extractNativeLibs`(.so 是磁盘上的真文件 + * 而不是压在 apk 里)、app 的 linker namespace 允许 `/data` 下的路径、以及那两个 .so + * 只依赖系统库,不需要把启动器的其它库一起搬过来。 + * + * 这终究是把别的应用的原生代码加载进本进程,所以选哪一个必须由用户明说,不能替他决定。 + */ +object AngleProvider { + + const val GLES_LIBRARY = "libGLESv2_angle.so" + const val EGL_LIBRARY = "libEGL_angle.so" + + /** + * 已知会带 ANGLE 的启动器。 + * + * 必须和 AndroidManifest 里的 `` 对上——Android 11 起,没在那儿声明过的 + * 包,PackageManager 一律当作不存在。 + */ + private val KNOWN_LAUNCHERS = listOf( + "com.tungsten.fcl", + "com.tungsten.fcm", + "com.tungsten.fcl.ngg", + "com.tungsten.fcl.qualcommdr", + "com.tungsten.fcl.mgdebug.debug", + "com.movtery.zalithlauncher.v2", + "com.movtery.zalithlauncher.v2.debug", + "org.fcl.enchantnet", + ) + + /** + * 装了、并且两个库都齐全可读的启动器。 + * + * 少一个都不算数:EGL 那个负责建上下文,GLESv2 那个负责画,只有一个的话 + * MobileGlues 会一半走 ANGLE 一半走系统驱动,比干脆用不了还糟。 + */ + fun sources(context: Context): List { + val pm = context.packageManager + return KNOWN_LAUNCHERS.mapNotNull { packageName -> + val info = runCatching { pm.getPackageInfo(packageName, 0) }.getOrNull() ?: return@mapNotNull null + val dir = info.applicationInfo?.nativeLibraryDir ?: return@mapNotNull null + if (!hasAngle(dir)) return@mapNotNull null + AngleSource( + packageName = packageName, + label = runCatching { + pm.getApplicationLabel(info.applicationInfo!!).toString() + }.getOrNull() ?: packageName, + versionName = info.versionName, + libraryDir = dir, + ) + } + } + + /** 这个目录里两个库都在,而且读得到。 */ + fun hasAngle(directory: String?): Boolean { + if (directory.isNullOrEmpty()) return false + return listOf(GLES_LIBRARY, EGL_LIBRARY).all { name -> + val file = File(directory, name) + runCatching { file.isFile && file.canRead() }.getOrDefault(false) + } + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/AuthController.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/AuthController.kt new file mode 100644 index 00000000..2d801734 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/AuthController.kt @@ -0,0 +1,134 @@ +package com.fcl.plugin.mobileglues.settings + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.fcl.plugin.mobileglues.utils.Constants +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +/** 一次授权判定的结果。[storage] 非空即「现在有访问权」。 */ +data class AuthState( + val method: AuthMethod?, + val storage: MgStorage?, +) { + val granted: Boolean get() = storage != null +} + +/** + * 决定「现在能不能碰 MG 目录」,并在授权建立后装配对应的 [MgStorage]。 + * + * 授权只认本 App 记下来的那次明确选择([PluginConfigStore.authMethod]),不从系统状态反推: + * 「所有文件访问」这种系统级权限撤不掉,若是看到它开着就认作已授权,用户做完重置一开 App + * 又会变成已授权,撤销等于没撤。 + * + * 反过来,选择也不等于当前有效——所有文件访问可能被系统在设置里收回,SAF 的 URI 可能被吊销、 + * 目录可能被外部删除。所以每次界面回到前台都要 [refresh] 一次,用真实状态驱动权限门。 + */ +class AuthController( + private val context: Context, + private val pluginConfigStore: PluginConfigStore, +) { + + private val appContext = context.applicationContext + + private val mutableState = MutableStateFlow(AuthState(pluginConfigStore.authMethod.value, null)) + val state: StateFlow = mutableState.asStateFlow() + + /** 重新核验当前记录的授权方式,并把结果发出去。 */ + fun refresh() { + val method = pluginConfigStore.authMethod.value + val storage = when (method) { + AuthMethod.AllFiles -> + if (hasAllFilesAccess()) directStorage() else null + + AuthMethod.Saf -> pluginConfigStore.safTreeUri + ?.let { SafMgStorage(appContext, it.toUri()) } + ?.takeIf { it.isAccessible() } + + AuthMethod.Legacy -> + if (hasLegacyPermissions(appContext)) directStorage() else null + + null -> null + } + mutableState.value = AuthState(method, storage) + } + + /** 「所有文件访问」已在系统设置里开好。 */ + fun grantAllFiles() { + pluginConfigStore.setAuthMethod(AuthMethod.AllFiles) + refresh() + } + + /** [grantSaf] 的结果。 */ + enum class SafGrantResult { Success, WrongFolder, Inaccessible } + + /** + * SAF 选择器返回的 tree URI。URI 的持久化(takePersistableUriPermission)由调用方做。 + * [SafGrantResult.WrongFolder] 时什么都没记录。 + */ + fun grantSaf(treeUri: Uri): SafGrantResult { + if (!isMgDirectoryName(DocumentFile.fromTreeUri(appContext, treeUri)?.name)) { + return SafGrantResult.WrongFolder + } + pluginConfigStore.safTreeUri = treeUri.toString() + pluginConfigStore.setAuthMethod(AuthMethod.Saf) + refresh() + return if (state.value.granted) SafGrantResult.Success else SafGrantResult.Inaccessible + } + + /** 旧版运行时权限已授予(Android 10 及以下)。 */ + fun grantLegacy() { + pluginConfigStore.setAuthMethod(AuthMethod.Legacy) + refresh() + } + + /** + * 忘掉授权选择。SAF 的持久化权限一并释放。 + * + * 「所有文件访问」是系统级权限,本 App 撤不掉;但授权与否只看本 App 有没有记录, + * 记录一清就等同于未授权,系统那边的权限开着也不会被用到。 + * + * 两处会用到:「撤销授权」和「撤销并删除全部文件」。 + */ + fun revoke() { + pluginConfigStore.safTreeUri?.let { raw -> + runCatching { + appContext.contentResolver.releasePersistableUriPermission( + raw.toUri(), + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or + android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + } + pluginConfigStore.safTreeUri = null + pluginConfigStore.setAuthMethod(null) + refresh() + } + + private fun directStorage() = DirectMgStorage(File(Constants.MG_DIRECTORY)) + + companion object { + /** SAF 目录授权只接受名字恰好是 MG 的目录——native 端读的是写死的 /sdcard/MG。 */ + fun isMgDirectoryName(name: String?): Boolean = name == SafMgStorage.MG_DIRECTORY_NAME + + fun hasAllFilesAccess(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager() + + fun hasLegacyPermissions(context: Context): Boolean = + ContextCompat.checkSelfPermission( + context, Manifest.permission.READ_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + context, Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/GlslCacheScale.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/GlslCacheScale.kt new file mode 100644 index 00000000..618daf34 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/GlslCacheScale.kt @@ -0,0 +1,61 @@ +package com.fcl.plugin.mobileglues.settings + +import kotlin.math.pow +import kotlin.math.roundToInt + +/** + * GLSL 缓存滑块的刻度换算。 + * + * 滑块的取值是「档位」(0..[STEPS]),MiB 由档位换算,所以滑块自身的量程是常量—— + * 不会出现「上限低于当前值」这种 Slider 会抛异常的中间状态。 + * + * 档位 0 是「关闭」;1..N 在 1 MiB 和上限之间按 [CURVE] 次幂分布: + * 靠近 0 的一段一格只差一点点(16/32/64 这些真正要调的档位有足够行程),靠近上限一格跨几十 MiB。 + */ +object GlslCacheScale { + + /** 滑块上限取总内存的几分之一。 */ + const val RAM_DIVISOR = 16L + + /** 内存特别小的设备上也要留出可用的调节范围。 */ + const val MIN_UPPER_BOUND_MIB = 64L + const val MAX_UPPER_BOUND_MIB = 8192L + + /** 滑块的档位数(不含 0 号「关闭」档)。 */ + const val STEPS = 200 + + /** 刻度的弯度:1 = 线性,越大越向低端倾斜(对数刻度相当于无穷大,低端过于夸张)。 */ + const val CURVE = 2.0 + + /** 由设备总内存(字节)算出的基础上限(MiB)。 */ + fun baseCeiling(totalRamBytes: Long): Int = + (totalRamBytes / (RAM_DIVISOR * 1024 * 1024)) + .coerceIn(MIN_UPPER_BOUND_MIB, MAX_UPPER_BOUND_MIB) + .toInt() + + /** + * 实际量程上限。配置里可能存着比设备内存 1/16 更大的值(旧配置或手工编辑): + * 把量程抬到它,宁可让滑块变长,也不能悄悄把用户的设置调低。 + */ + fun ceiling(totalRamBytes: Long, currentMebibytes: Int): Int = + maxOf(baseCeiling(totalRamBytes), currentMebibytes) + + /** 档位 → MiB。 */ + fun mebibytesAt(position: Int, ceiling: Int): Int { + if (position <= 0) return 0 + if (ceiling <= 1) return ceiling + val ratio = (position - 1).toDouble() / (STEPS - 1) + return (1 + (ceiling - 1) * ratio.pow(CURVE)) + .roundToInt() + .coerceIn(1, ceiling) + } + + /** [mebibytesAt] 的逆运算。 */ + fun positionFor(mebibytes: Int, ceiling: Int): Int { + if (mebibytes <= 0) return 0 + if (ceiling <= 1) return STEPS + val ratio = ((mebibytes - 1).toDouble() / (ceiling - 1)).coerceIn(0.0, 1.0) + val position = ratio.pow(1.0 / CURVE) * (STEPS - 1) + return (1 + position.roundToInt()).coerceIn(1, STEPS) + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGCacheExporter.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGCacheExporter.kt new file mode 100644 index 00000000..8db3e425 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGCacheExporter.kt @@ -0,0 +1,39 @@ +package com.fcl.plugin.mobileglues.settings + +import android.content.Context +import com.fcl.plugin.mobileglues.utils.Constants +import java.io.File + +/** + * 把当前配置导出到应用私有缓存目录,供 MGInfoGetter 通过 `MG_DIR_PATH` 读取。 + * + * 取代了原先挂在 MGConfig 伴生对象上的两个可变静态字段(`cacheConfigPath` / `cacheMGDir`): + * 那两个字段默认是 `File("")`,能工作只是因为调用方恰好先调了一次导出。 + */ +class MGCacheExporter(context: Context, private val store: MGConfigStore) { + + private val appContext = context.applicationContext + + val directory: File + get() = File(appContext.externalCacheDir ?: appContext.cacheDir, DIRECTORY_NAME) + + suspend fun export(): Result = + store.exportTo(File(directory, Constants.CONFIG_FILE_NAME)).map { directory } + + /** + * 删掉导出的那份副本。 + * + * 这里躺着的是用户配置的完整拷贝(还有 native 库查询时顺手写的日志)。用户收回授权 + * 或者要求删干净的时候,只删 `/sdcard/MG` 而把这份副本留在应用缓存里,是说话不算数。 + */ + fun clear(): Result = runCatching { + val target = directory + if (target.exists() && !target.deleteRecursively()) { + throw java.io.IOException("Could not delete ${target.path}") + } + } + + private companion object { + const val DIRECTORY_NAME = "MG" + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt index e253219e..17a57518 100644 --- a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt @@ -1,179 +1,380 @@ package com.fcl.plugin.mobileglues.settings import android.content.Context -import com.fcl.plugin.mobileglues.utils.Constants -import com.google.gson.Gson -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.io.File +import androidx.annotation.StringRes +import com.fcl.plugin.mobileglues.R /** - * MobileGlues 配置类。 + * 一个会被原样写进 `MG/config.json` 的取值。 + * + * [wire] 是磁盘上的整数,必须与 MobileGlues native 端 `config/settings.h` 中对应的枚举一致, + * 并且永远不能修改——它已经存在于用户既有的配置文件里。 */ -class MGConfig private constructor(val context: Context, private var isInitializing: Boolean) { +interface WireValue { + val wire: Int +} - // 默认构造函数,供正常实例化使用 - constructor(context: Context) : this(context, false) +/** + * 会出现在 Spinner 里的取值。 + * + * 选项列表直接由枚举生成(见 MainActivity.setupSpinners),因此不存在 + * 「选项数量和取值范围对不上」的可能——新增一档只需要在枚举里加一行。 + * 也正因为如此,枚举的 `ordinal` 就是它在 Spinner 中的位置。 + */ +interface SpinnerOption : WireValue { + fun label(context: Context): CharSequence +} - // ---- 配置字段(UI 触发变更后自动保存) ---- +/** 磁盘上的整数 → 枚举。缺失或越界时回落到 [fallback],绝不抛异常。 */ +internal fun List.fromWire(wire: Int?, fallback: T): T = + firstOrNull { it.wire == wire } ?: fallback - var enableANGLE: Int = 1 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** `enableANGLE` */ +enum class AngleConfig(override val wire: Int, @param:StringRes private val labelRes: Int) : + SpinnerOption { + DisableIfPossible(0, R.string.option_angle_disable_if_possible), + EnableIfPossible(1, R.string.option_angle_enable_if_possible), + ForceDisable(2, R.string.option_angle_disable), + ForceEnable(3, R.string.option_angle_enable); - var enableNoError: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } + override fun label(context: Context): CharSequence = context.getString(labelRes) +} - var enableExtTimerQuery: Int = 1 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** `enableNoError` */ +enum class NoErrorConfig(override val wire: Int, @param:StringRes private val labelRes: Int) : + SpinnerOption { + Auto(0, R.string.option_no_error_auto), + DoNotIgnore(1, R.string.option_no_error_enable), + IgnoreShaderProgram(2, R.string.option_no_error_disable_pri), + IgnoreShaderProgramFramebuffer(3, R.string.option_no_error_disable_sec); - var enableExtComputeShader: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } + override fun label(context: Context): CharSequence = context.getString(labelRes) +} - var enableExtDirectStateAccess: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** + * MultiDraw 的一种实现方式。 + * + * 写进 `config.json` 的是 [key] 这个**名字**而不是序号——native 特意这么改的: + * 序号一旦新增或调整,就会把用户已经写好的取值指到别的实现上。 + */ +enum class MultidrawBackend(val key: String, @param:StringRes private val labelRes: Int) { + // 前三种:每个子绘制各发一次驱动调用。 + Unroll("unroll", R.string.md_backend_unroll), + BaseVertex("basevertex", R.string.md_backend_basevertex), + Indirect("indirect", R.string.md_backend_indirect), - var maxGlslCacheSize: Int = 32 - set(value) { - if (field != value) { - field = value - if (value == -1) clearCacheFile() - saveIfReady() - } - } + // 后三种:整批只发一次。 + MultiArrays("multiarrays", R.string.md_backend_multiarrays), + MultiBaseVertex("multibasevertex", R.string.md_backend_multibasevertex), + MultiIndirect("multiindirect", R.string.md_backend_multiindirect), - var multidrawMode: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } + Compute("compute", R.string.md_backend_compute); - var angleDepthClearFixMode: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } + fun label(context: Context): CharSequence = context.getString(labelRes) - var customGLVersion: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } + companion object { + /** 与 native 的 `md_parse_backend` 一致:忽略大小写,以及空格 / 下划线 / 连字符。 */ + fun parse(raw: String?): MultidrawBackend? { + val normalized = raw + ?.filterNot { it == ' ' || it == '\t' || it == '_' || it == '-' } + ?.lowercase() + ?.takeIf { it.isNotEmpty() } + ?: return null + return entries.firstOrNull { it.key == normalized } } + } +} - var fsr1Setting: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** + * 全局排序中的一项:七种真实实现,外加「原生 / 原生EXT」。 + * + * 「原生」不是新的实现方式,只是对每个函数自动匹配 GLES 核心或扩展里同形的函数 + * (见 [MultidrawEntry.nativeBackend])。声明顺序就是默认排序,也与 native 端 + * `k_md_default_global_order` 一致:原生优先、整批单次调用其次、compute 殿后。 + */ +enum class MultidrawOrderItem( + val key: String, + val backend: MultidrawBackend?, + @param:StringRes private val labelRes: Int, +) { + Native("native", null, R.string.md_item_native), + MultiIndirect("multiindirect", MultidrawBackend.MultiIndirect, R.string.md_backend_multiindirect), + MultiBaseVertex("multibasevertex", MultidrawBackend.MultiBaseVertex, R.string.md_backend_multibasevertex), + MultiArrays("multiarrays", MultidrawBackend.MultiArrays, R.string.md_backend_multiarrays), + Indirect("indirect", MultidrawBackend.Indirect, R.string.md_backend_indirect), + BaseVertex("basevertex", MultidrawBackend.BaseVertex, R.string.md_backend_basevertex), + Unroll("unroll", MultidrawBackend.Unroll, R.string.md_backend_unroll), + Compute("compute", MultidrawBackend.Compute, R.string.md_backend_compute); - // ---- 对外操作 ---- + fun label(context: Context): CharSequence = context.getString(labelRes) - private fun saveIfReady() { - if (!isInitializing) save() - } + companion object { + val DefaultOrder: List = entries.toList() - fun save() { - runCatching { - val configFile = File(Constants.CONFIG_FILE_PATH) - configFile.parentFile?.mkdirs() - configFile.writeText(Gson().toJson(buildConfigMap())) + fun parse(raw: String?): MultidrawOrderItem? { + val normalized = raw + ?.filterNot { it == ' ' || it == '\t' || it == '_' || it == '-' } + ?.lowercase() + ?.takeIf { it.isNotEmpty() } + ?: return null + return entries.firstOrNull { it.key == normalized } } - } - fun saveToCachePath() { - if (cacheConfigPath == null) { - val cacheDir = context.externalCacheDir ?: context.cacheDir - cacheMGDir = File(cacheDir, "MG").apply { mkdirs() } - cacheConfigPath = File(cacheMGDir, "config.json").absolutePath - } - runCatching { - File(cacheConfigPath!!).writeText(Gson().toJson(buildConfigMap())) - } + /** + * 把任意来源(配置文件、bench 结果)的列表规整成合法的全局排序: + * 去重、丢弃不认识的项、把漏掉的项按默认顺序补到末尾。排序永远是全 8 项的置换。 + */ + fun normalize(items: List): List = + (items.distinct() + DefaultOrder).distinct() } +} - // ---- 私有辅助 ---- +/** + * 有多种实现可选的 MultiDraw 入口点。 + * + * [implemented] 抄自 native 的 `k_md_entries`:只列对这个函数来说是「不同实现」的后端 + * (例如 glMultiDrawElements 没有 base vertex,就不含 basevertex/compute), + * 顺序是该函数的默认排序(默认全局排序在此函数上的展开)。 + * [nativeBackend] 是全局排序里「原生」一项在此函数上的落点。 + */ +enum class MultidrawEntry( + val orderKey: String, + val legacyModeKey: String, + val glFunction: String, + val implemented: List, + val nativeBackend: MultidrawBackend, +) { + Arrays( + "multidrawOrderArrays", "multidrawModeArrays", "glMultiDrawArrays", + listOf( + MultidrawBackend.MultiArrays, MultidrawBackend.MultiIndirect, MultidrawBackend.Unroll, + ), + // glMultiDrawArraysEXT(GL_EXT_multi_draw_arrays) + MultidrawBackend.MultiArrays, + ), + Elements( + "multidrawOrderElements", "multidrawModeElements", "glMultiDrawElements", + listOf( + MultidrawBackend.MultiArrays, MultidrawBackend.MultiIndirect, + MultidrawBackend.MultiBaseVertex, MultidrawBackend.Indirect, MultidrawBackend.Unroll, + ), + // glMultiDrawElementsEXT(GL_EXT_multi_draw_arrays) + MultidrawBackend.MultiArrays, + ), + ElementsBaseVertex( + "multidrawOrderElementsBaseVertex", "multidrawModeElementsBaseVertex", "glMultiDrawElementsBaseVertex", + listOf( + MultidrawBackend.MultiBaseVertex, MultidrawBackend.MultiIndirect, + MultidrawBackend.Indirect, MultidrawBackend.BaseVertex, + MultidrawBackend.Unroll, MultidrawBackend.Compute, + ), + // glMultiDrawElementsBaseVertexEXT(GL_EXT/OES_draw_elements_base_vertex) + MultidrawBackend.MultiBaseVertex, + ), + ArraysIndirect( + "multidrawOrderArraysIndirect", "multidrawModeArraysIndirect", "glMultiDrawArraysIndirect", + listOf(MultidrawBackend.MultiIndirect, MultidrawBackend.Indirect), + // glMultiDrawArraysIndirectEXT(GL_EXT_multi_draw_indirect) + MultidrawBackend.MultiIndirect, + ), + ElementsIndirect( + "multidrawOrderElementsIndirect", "multidrawModeElementsIndirect", "glMultiDrawElementsIndirect", + listOf(MultidrawBackend.MultiIndirect, MultidrawBackend.Indirect), + // glMultiDrawElementsIndirectEXT(GL_EXT_multi_draw_indirect) + MultidrawBackend.MultiIndirect, + ), + ; - private fun clearCacheFile() { - CoroutineScope(Dispatchers.IO).launch { - runCatching { File(Constants.GLSL_CACHE_FILE_PATH).delete() } - } - } + /** 把(自家或别家的)后端列表规整成本函数的合法例外排序:全 [implemented] 项的置换。 */ + fun normalize(backends: List): List = + (backends.filter { it in implemented }.distinct() + implemented).distinct() +} - private fun buildConfigMap(): Map = mapOf( - "enableANGLE" to enableANGLE, - "enableNoError" to enableNoError, - "enableExtTimerQuery" to enableExtTimerQuery, - "enableExtComputeShader" to enableExtComputeShader, - "enableExtDirectStateAccess" to enableExtDirectStateAccess, - "maxGlslCacheSize" to maxGlslCacheSize, - "multidrawMode" to multidrawMode, - "angleDepthClearFixMode" to angleDepthClearFixMode, - "customGLVersion" to customGLVersion, - "fsr1Setting" to fsr1Setting +/** + * MultiDraw 的全部设置:一份全局排序,加上若干函数的例外排序。 + * + * [globalOrder] 永远是全 8 项的置换;[exceptions] 里某个函数存在,就表示该函数启用了 + * 独立排序,其值永远是该函数 [MultidrawEntry.implemented] 全项的置换。两个不变量都由 + * [MultidrawOrderItem.normalize] / [MultidrawEntry.normalize] 维护,equals 因此可靠, + * 去抖保存不会被两种等价表示无谓触发。 + */ +data class MultidrawSettings( + val globalOrder: List = MultidrawOrderItem.DefaultOrder, + val exceptions: Map> = emptyMap(), +) { + /** 全局排序在某个函数上的展开:native 落到对应 EXT 实现,去重后补齐漏项。 */ + fun globalOrderFor(entry: MultidrawEntry): List = entry.normalize( + globalOrder.mapNotNull { item -> item.backend ?: entry.nativeBackend }, ) + /** 此函数实际生效的排序:例外优先,否则全局展开。 */ + fun effectiveOrderFor(entry: MultidrawEntry): List = + exceptions[entry] ?: globalOrderFor(entry) + + fun hasException(entry: MultidrawEntry): Boolean = entry in exceptions + + /** 例外排序被调过了(不等于它的默认值——全局排序在这个函数上的展开)。 */ + fun exceptionCustomized(entry: MultidrawEntry): Boolean = + exceptions[entry]?.let { it != globalOrderFor(entry) } == true + + fun withGlobalOrder(order: List): MultidrawSettings = + copy(globalOrder = MultidrawOrderItem.normalize(order)) + + /** 开启例外时,以当前全局排序的展开作为起点,用户从熟悉的顺序开始调。 */ + fun withException(entry: MultidrawEntry, enabled: Boolean): MultidrawSettings = copy( + exceptions = if (enabled) { + exceptions + (entry to effectiveOrderFor(entry)) + } else { + exceptions - entry + }, + ) + + fun withExceptionOrder(entry: MultidrawEntry, order: List): MultidrawSettings = + copy(exceptions = exceptions + (entry to entry.normalize(order))) + + val globalCustomized: Boolean get() = globalOrder != MultidrawOrderItem.DefaultOrder + companion object { - var cacheConfigPath: String? = null - var cacheMGDir: File = File("") + val Default = MultidrawSettings() + } +} + +/** + * `angleDepthClearFixMode`。 + * + * native 的 `AngleDepthClearFixMode` 还有一个 `Mode2 = 2`,但那一档不对外开放, + * 本 App 不提供、也不接受它。 + */ +enum class DepthClearFixMode(override val wire: Int, @param:StringRes private val labelRes: Int) : + SpinnerOption { + Disabled(0, R.string.option_angle_clear_workaround_disable), + Mode1(1, R.string.option_angle_clear_workaround_enable_1); + + override fun label(context: Context): CharSequence = context.getString(labelRes) +} + +/** + * `customGLVersion`。 + * + * 只列出 native 认可的档位:`config/settings.cpp` 会把 34..39、31 及以下等中间值夹到这些档位上, + * 所以让用户选一个 native 会二次修改的值没有意义。 + */ +enum class GlVersion(override val wire: Int, private val literal: String?) : SpinnerOption { + Default(0, null), + Gl46(46, "OpenGL 4.6"), + Gl45(45, "OpenGL 4.5"), + Gl44(44, "OpenGL 4.4"), + Gl43(43, "OpenGL 4.3"), + Gl42(42, "OpenGL 4.2"), + Gl41(41, "OpenGL 4.1"), + Gl40(40, "OpenGL 4.0"), + Gl33(33, "OpenGL 3.3"), + Gl32(32, "OpenGL 3.2"); + override fun label(context: Context): CharSequence = + literal ?: context.getString(R.string.option_custom_gl_version_default) + + companion object { /** - * 从磁盘加载配置,文件不存在或解析失败时返回 null。 + * 这一项不能像别的枚举那样「不认识就回落到默认值」。 + * + * native 对 customGLVersion 是**夹取**而不是重置(settings.cpp):47 当 4.6 用,38 当 3.3 用, + * 31 当 3.2 用。若把这些值统统读成 0,本 App 下一次保存就会把它们写成 0, + * 而 0 在 native 那边等于 DEFAULT_GL_VERSION(40)——用户没动过任何开关,目标版本却被改了。 + * 所以这里照抄 native 的夹取规则,保证两边对同一个文件的理解永远一致。 */ - fun loadConfig(context: Context): MGConfig? { - val configFile = File(Constants.CONFIG_FILE_PATH) - if (!configFile.exists()) return null - - val configStr = runCatching { configFile.readText() }.getOrNull() ?: return null - - return runCatching { - val obj: JsonObject = JsonParser.parseString(configStr).asJsonObject - // 开启 isInitializing 拦截,防止在读取 JSON 赋值时触发大量冗余的 save() 磁盘 I/O - val config = MGConfig(context, isInitializing = true) - config.applyFromJson(obj) - config.isInitializing = false - config - }.getOrNull() + fun fromWire(wire: Int?): GlVersion { + if (wire == null) return Default + entries.firstOrNull { it.wire == wire }?.let { return it } + return when { + wire > 46 -> Gl46 + wire in 34..39 -> Gl33 + wire in 1..31 -> Gl32 + else -> Default + } + } + } +} + +/** + * `fsr1Setting`。 + * + * native 端是 5 档画质预设,界面上目前只提供一个开关;把完整取值建模出来是为了让 + * 「配置里已经是 Balanced」这种情况能被正确识别并原样保留,而不是被开关改写成 1。 + */ +enum class Fsr1Preset(override val wire: Int) : WireValue { + Disabled(0), + UltraQuality(1), + Quality(2), + Balanced(3), + Performance(4), +} + +/** `maxGlslCacheSize`。把「关闭」这个用负数表达的状态显式建模出来。 */ +sealed interface GlslCacheSize { + + val wire: Int + + /** + * 不使用 GLSL 缓存。 + * + * native 的判据是 `maxGlslCacheSize > 0`,任何小于等于 0 的值都等于关闭; + * 写回磁盘时统一用 -1,与历史配置保持一致。 + */ + data object Disabled : GlslCacheSize { + override val wire: Int get() = -1 + } + + /** 以 MiB 为单位的上限,必须为正数。 */ + data class Limited(val mebibytes: Int) : GlslCacheSize { + init { + require(mebibytes > 0) { "GLSL cache size must be positive, was $mebibytes" } } - private fun MGConfig.applyFromJson(obj: JsonObject) { - fun JsonObject.int(key: String, default: Int) = get(key)?.asInt ?: default - - enableANGLE = obj.int("enableANGLE", 1) - enableNoError = obj.int("enableNoError", 0) - enableExtTimerQuery = obj.int("enableExtTimerQuery", 1) - enableExtComputeShader = obj.int("enableExtComputeShader", 0) - enableExtDirectStateAccess = obj.int("enableExtDirectStateAccess", 0) - maxGlslCacheSize = obj.int("maxGlslCacheSize", 32) - multidrawMode = obj.int("multidrawMode", 0) - angleDepthClearFixMode = obj.int("angleDepthClearFixMode", 0) - customGLVersion = obj.int("customGLVersion", 0) - fsr1Setting = obj.int("fsr1Setting", 0) + override val wire: Int get() = mebibytes + } + + /** 滑块上的位置:关闭对应 0。 */ + val mebibytesOrZero: Int get() = (this as? Limited)?.mebibytes ?: 0 + + companion object { + val Default: GlslCacheSize = Limited(32) + + /** 滑块回来的 MiB 数 → 配置值,0 表示关闭。 */ + fun ofMebibytes(mebibytes: Int): GlslCacheSize = + if (mebibytes > 0) Limited(mebibytes) else Disabled + + fun fromWire(wire: Int?): GlslCacheSize = when { + wire == null -> Default + wire > 0 -> Limited(wire) + // native 把 0 也当成关闭;旧版界面却把它显示成 32MiB,两边对同一个文件的理解是不一致的。 + else -> Disabled } } -} \ No newline at end of file +} + +/** + * MobileGlues 的配置,一个不可变的值。 + * + * 它不知道文件、不知道 UI、也没有任何副作用:改配置就是 [copy],落盘由 [MGConfigStore] 负责。 + * 这里的默认值是全 App 唯一的一份——[MGConfigCodec] 解析时的回落值也取自这里。 + */ +data class MGConfig( + val angle: AngleConfig = AngleConfig.EnableIfPossible, + val noError: NoErrorConfig = NoErrorConfig.Auto, + val multidraw: MultidrawSettings = MultidrawSettings.Default, + val depthClearFix: DepthClearFixMode = DepthClearFixMode.Disabled, + val glVersion: GlVersion = GlVersion.Default, + val glslCache: GlslCacheSize = GlslCacheSize.Default, + val extComputeShader: Boolean = false, + /** 磁盘上 1 表示「启用 timer_query 扩展」;界面上的开关文案是「禁用」,取反只发生在渲染那一处。 */ + val extTimerQuery: Boolean = true, + val extDirectStateAccess: Boolean = false, + val fsr1: Fsr1Preset = Fsr1Preset.Disabled, +) { + val fsr1Enabled: Boolean get() = fsr1 != Fsr1Preset.Disabled + + companion object { + val Default = MGConfig() + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigCodec.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigCodec.kt new file mode 100644 index 00000000..34db84c4 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigCodec.kt @@ -0,0 +1,152 @@ +package com.fcl.plugin.mobileglues.settings + +import com.google.gson.JsonObject + +/** + * `MG/config.json` 的读写格式。 + * + * 这里是键名、以及「磁盘上的值不合法时用什么」的唯一定义处;除此之外没有第二个地方 + * 知道 config.json 长什么样。 + * + * 两条约定: + * - [decode] 永不抛异常。单个字段坏掉(缺失、越界、类型不对)只会让该字段回落到默认值, + * 不会连累整份配置——只有 JSON 本身语法错误才算文件损坏,那由 [MGConfigStore] 处理。 + * - [encode] 会带上 [foreignKeysOf] 取出的未知键。native 端读的键比 App 认识的多 + * (例如 `hideMGEnvLevel`),不把它们写回去就等于每次保存都在删别人的设置。 + */ +internal object MGConfigCodec { + + private const val KEY_ANGLE = "enableANGLE" + private const val KEY_NO_ERROR = "enableNoError" + private const val KEY_EXT_TIMER_QUERY = "enableExtTimerQuery" + private const val KEY_EXT_COMPUTE_SHADER = "enableExtComputeShader" + private const val KEY_EXT_DIRECT_STATE_ACCESS = "enableExtDirectStateAccess" + private const val KEY_GLSL_CACHE = "maxGlslCacheSize" + + /** + * 已废弃的三代旧键:native 不再读取,只会在它们还存在时打弃用警告。 + * 保留在 [KNOWN_KEYS] 里是为了让下一次保存把它们清掉,而不是当成未知键永久留着。 + */ + private const val KEY_MULTIDRAW_LEGACY = "multidrawMode" + private const val KEY_MULTIDRAW_DISABLE_LEGACY = "multidrawDisableBackends" + + private const val KEY_MULTIDRAW_ORDER = "multidrawOrder" + private const val KEY_DEPTH_CLEAR_FIX = "angleDepthClearFixMode" + private const val KEY_GL_VERSION = "customGLVersion" + private const val KEY_FSR1 = "fsr1Setting" + + private val KNOWN_KEYS = listOf( + KEY_ANGLE, + KEY_NO_ERROR, + KEY_EXT_TIMER_QUERY, + KEY_EXT_COMPUTE_SHADER, + KEY_EXT_DIRECT_STATE_ACCESS, + KEY_GLSL_CACHE, + KEY_MULTIDRAW_LEGACY, + KEY_MULTIDRAW_DISABLE_LEGACY, + KEY_MULTIDRAW_ORDER, + KEY_DEPTH_CLEAR_FIX, + KEY_GL_VERSION, + KEY_FSR1, + ) + MultidrawEntry.entries.flatMap { listOf(it.orderKey, it.legacyModeKey) } + + fun decode(root: JsonObject): MGConfig { + val defaults = MGConfig.Default + return MGConfig( + // 缺键的回落必须与渲染器一致:native 的 config_get_int 对缺键回 -1,越界 + // 一律按 DisableIfPossible 跑。这里若回落到 App 自己的默认(尽可能启用), + // 界面就会宣称一个游戏里并不成立的档位——老版本遗留的 config.json 正是这样。 + angle = AngleConfig.entries.fromWire(root.intOrNull(KEY_ANGLE), AngleConfig.DisableIfPossible), + noError = NoErrorConfig.entries.fromWire(root.intOrNull(KEY_NO_ERROR), defaults.noError), + multidraw = decodeMultidraw(root), + depthClearFix = DepthClearFixMode.entries + .fromWire(root.intOrNull(KEY_DEPTH_CLEAR_FIX), defaults.depthClearFix), + glVersion = GlVersion.fromWire(root.intOrNull(KEY_GL_VERSION)), + glslCache = GlslCacheSize.fromWire(root.intOrNull(KEY_GLSL_CACHE)), + extComputeShader = root.boolOrNull(KEY_EXT_COMPUTE_SHADER) + ?: defaults.extComputeShader, + extTimerQuery = root.boolOrNull(KEY_EXT_TIMER_QUERY) ?: defaults.extTimerQuery, + extDirectStateAccess = root.boolOrNull(KEY_EXT_DIRECT_STATE_ACCESS) + ?: defaults.extDirectStateAccess, + fsr1 = Fsr1Preset.entries.fromWire(root.intOrNull(KEY_FSR1), defaults.fsr1), + ) + } + + fun encode(config: MGConfig, foreignKeys: JsonObject?): JsonObject = + (foreignKeys?.deepCopy() ?: JsonObject()).apply { + addProperty(KEY_ANGLE, config.angle.wire) + addProperty(KEY_NO_ERROR, config.noError.wire) + addProperty(KEY_EXT_TIMER_QUERY, config.extTimerQuery.wire) + addProperty(KEY_EXT_COMPUTE_SHADER, config.extComputeShader.wire) + addProperty(KEY_EXT_DIRECT_STATE_ACCESS, config.extDirectStateAccess.wire) + addProperty(KEY_GLSL_CACHE, config.glslCache.wire) + addProperty(KEY_DEPTH_CLEAR_FIX, config.depthClearFix.wire) + addProperty(KEY_GL_VERSION, config.glVersion.wire) + addProperty(KEY_FSR1, config.fsr1.wire) + encodeMultidraw(config.multidraw) + } + + private fun decodeMultidraw(root: JsonObject): MultidrawSettings = MultidrawSettings( + // 与 native 一致:不认识的名字丢弃,重复项保留首次出现,漏掉的项按默认顺序补齐。 + globalOrder = MultidrawOrderItem.normalize( + root.stringOrNull(KEY_MULTIDRAW_ORDER) + .orEmpty() + .split(',', ';') + .mapNotNull { MultidrawOrderItem.parse(it) }, + ), + exceptions = MultidrawEntry.entries.mapNotNull { entry -> + // 键存在即例外开启;内容再怎么残缺,normalize 都会补成全项置换。 + val raw = root.stringOrNull(entry.orderKey) ?: return@mapNotNull null + entry to entry.normalize( + raw.split(',', ';').mapNotNull { MultidrawBackend.parse(it) }, + ) + }.toMap(), + ) + + private fun JsonObject.encodeMultidraw(settings: MultidrawSettings) { + // 默认顺序就是「不写这个键」,两种等价表示只能留一种。 + if (settings.globalOrder == MultidrawOrderItem.DefaultOrder) { + remove(KEY_MULTIDRAW_ORDER) + } else { + addProperty(KEY_MULTIDRAW_ORDER, settings.globalOrder.joinToString(",") { it.key }) + } + + MultidrawEntry.entries.forEach { entry -> + val exception = settings.exceptions[entry] + if (exception == null) { + remove(entry.orderKey) + } else { + addProperty(entry.orderKey, exception.joinToString(",") { it.key }) + } + // 三代旧键 native 已不再读取,留着只会让它每次启动都打弃用警告。 + remove(entry.legacyModeKey) + } + remove(KEY_MULTIDRAW_LEGACY) + remove(KEY_MULTIDRAW_DISABLE_LEGACY) + } + + /** 取出配置文件里本 App 不认识的键,保存时原样写回。 */ + fun foreignKeysOf(root: JsonObject): JsonObject = + root.deepCopy().apply { KNOWN_KEYS.forEach { remove(it) } } + + /** native 端一律用 `> 0` 判断布尔开关,这里保持一致。 */ + private val Boolean.wire: Int get() = if (this) 1 else 0 + + private fun JsonObject.intOrNull(key: String): Int? { + val element = get(key) ?: return null + if (!element.isJsonPrimitive) return null + val primitive = element.asJsonPrimitive + return runCatching { + // 容忍被手工改成字符串的数字("32"):读进来之后下一次保存会写回真正的整数。 + if (primitive.isNumber) primitive.asInt else primitive.asString.trim().toInt() + }.getOrNull() + } + + private fun JsonObject.boolOrNull(key: String): Boolean? = intOrNull(key)?.let { it > 0 } + + private fun JsonObject.stringOrNull(key: String): String? { + val element = get(key) ?: return null + if (!element.isJsonPrimitive) return null + return runCatching { element.asString }.getOrNull() + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigStore.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigStore.kt new file mode 100644 index 00000000..0ed6634a --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigStore.kt @@ -0,0 +1,278 @@ +package com.fcl.plugin.mobileglues.settings + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException + +/** [MGConfigStore.load] 的结果。加载失败不等于「用默认值覆盖」,由调用方决定怎么办。 */ +sealed interface ConfigLoadResult { + + /** 配置文件不存在。此时 [MGConfigStore.config] 会变成默认值,但磁盘上什么都没写。 */ + data object Missing : ConfigLoadResult + + data class Loaded(val config: MGConfig) : ConfigLoadResult + + /** JSON 本身无法解析。原文件已尽量备份到 [backup],配置保持「未加载」直到用户决定。 */ + data class Corrupt(val backupName: String?, val cause: Throwable) : ConfigLoadResult +} + +sealed interface ConfigStoreEvent { + data class SaveFailed(val cause: Throwable) : ConfigStoreEvent +} + +/** + * 全 App 唯一会碰 `MG/config.json` 的地方。 + * + * 设计要点: + * - [config] 为 `null` 表示「尚未加载」。UI 的回调在这个阶段一律无视——这取代了原先的 + * `isSpinnerInitialized` / `config == null` 两个标志位,而且含义是明确的状态而不是时序。 + * - [update] 只改内存并立刻发出新状态,落盘去抖后异步进行。连续敲键盘不再等于连续写外部存储。 + * - 直接文件访问下写入是原子的(临时文件 + fsync + rename,见 [MgStorage.writeConfig])。 + * native 端会在游戏启动时读这个文件,截断式写入可能让它读到半截 JSON。 + * - 写入失败通过 [events] 上报,不再被 `runCatching {}` 静默吞掉。 + */ +class MGConfigStore( + storage: MgStorage?, + private val scope: CoroutineScope, + private val io: CoroutineDispatcher = Dispatchers.IO, + private val saveDebounceMillis: Long = SAVE_DEBOUNCE_MILLIS, +) { + + private val gson = Gson() + + /** + * 当前生效的存储访问。授权方式(所有文件访问 / SAF)由外面的 AuthController 决定, + * store 只关心「往哪里读写」;授权失效时外面会调 [detachStorage]。 + */ + @Volatile + private var storage: MgStorage? = storage + + private val mutableConfig = MutableStateFlow(null) + + /** `null` = 尚未加载。 */ + val config: StateFlow = mutableConfig.asStateFlow() + + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = mutableEvents.asSharedFlow() + + /** + * GLSL 缓存文件的大小(字节),`null` 表示文件不存在。 + * + * 界面据此决定要不要给出「删除缓存」按钮、以及在按钮上显示多大,所以它是状态而不是 + * 一次性查询:删除之后必须立刻反映出来,把按钮收回去。 + */ + private val mutableGlslCacheBytes = MutableStateFlow(null) + val glslCacheBytes: StateFlow = mutableGlslCacheBytes.asStateFlow() + + /** 配置文件里本 App 不认识的键(例如 native 的 `hideMGEnvLevel`),保存时原样写回。 */ + private var foreignKeys: JsonObject? = null + + /** + * 内存里的配置和磁盘上的不一致。 + * + * 只有它为 true 才会真的写文件。没有这个判据的话,「退到后台就 flush 一次」会在好几种场景下 + * 写出不该写的东西:配置文件损坏后用户选了取消、用户在外部删掉了 MG 目录、或者用户根本什么都没改。 + */ + @Volatile + private var dirty = false + + private val pendingSave = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** 串行化所有对 [configFile] 的读写。 */ + private val fileLock = Mutex() + + init { + scope.launch { collectPendingSaves() } + } + + /** 授权建立后接入存储。更换授权方式时先 [detachStorage] 再 attach。 */ + fun attachStorage(storage: MgStorage) { + this.storage = storage + } + + /** + * 授权失效后摘掉存储,store 回到「未加载」,也不再可能写盘。 + * 否则用户在外部删掉 MG 目录后,退到后台的那次保存会把目录连同配置一起重建出来。 + */ + fun detachStorage() { + storage = null + forget() + } + + @Throws(IOException::class) + private fun requireStorage(): MgStorage = + storage ?: throw IOException("MG directory is not authorized") + + @OptIn(FlowPreview::class) + private suspend fun collectPendingSaves() { + pendingSave.debounce(saveDebounceMillis).collect { persist() } + } + + suspend fun load(): ConfigLoadResult = fileLock.withLock { + // 先把还没落盘的改动写出去。否则重新读盘会把用户刚刚改的值覆盖回旧值—— + // Activity 重建(旋转屏幕)时 onResume 的这次 load 正好会撞进去抖窗口里。 + persistLocked() + withContext(io) { readConfigFile() } + } + + /** + * 修改配置。[transform] 返回的结果与当前值相等时什么都不会发生——这让所有 + * 「界面回灌触发的回调」天然是幂等的。 + */ + fun update(transform: (MGConfig) -> MGConfig) { + val current = mutableConfig.value ?: return + val next = transform(current) + if (next == current) return + mutableConfig.value = next + dirty = true + pendingSave.tryEmit(Unit) + } + + /** 把去抖队列里还没写的改动立刻落盘,并等待完成。 */ + suspend fun flush() = persist() + + /** + * 同 [flush],但跑在 store 自己的作用域上。 + * + * Activity 的 onStop 必须用这个:`lifecycleScope` 会在 onDestroy 时被取消, + * 旋转屏幕产生的那次 flush 可能还没轮到 IO 线程执行就被砍掉了。 + */ + fun flushAsync() { + scope.launch { persist() } + } + + /** 配置文件损坏后由用户确认的重置。 */ + suspend fun resetToDefaults() { + foreignKeys = null + mutableConfig.value = MGConfig.Default + dirty = true + persist() + } + + /** + * 回到「未加载」状态,UI 的回调随即失效,并且不会再写盘。 + * + * 在两种情况下必须调用:MG 目录被删除(不管是本 App 删的还是用户在外部删的), + * 以及配置文件损坏而用户选择了不重置。 + */ + fun forget() { + foreignKeys = null + mutableConfig.value = null + dirty = false + } + + /** + * 删除 GLSL 缓存文件。 + * + * 这是一条只由用户显式触发的命令:既不是「把缓存上限设成关闭」的副作用,也不会在读取配置时 + * 顺手执行——删掉的是用户已经积累好的着色器缓存,不能由赋值语句代劳。 + */ + suspend fun clearGlslCache(): Result = withContext(io) { + runCatching { + requireStorage().deleteGlslCache() + }.also { refreshGlslCacheFile() } + } + + private fun refreshGlslCacheFile() { + mutableGlslCacheBytes.value = runCatching { storage?.glslCacheBytes() }.getOrNull() + } + + /** 把当前配置导出到应用私有目录(供 MGInfoGetter 读取),不影响用户的配置文件。 */ + suspend fun exportTo(file: File): Result = withContext(io) { + runCatching { + file.parentFile?.mkdirs() + file.writeText(serialize(mutableConfig.value ?: MGConfig.Default)) + file + } + } + + private fun readConfigFile(): ConfigLoadResult { + // 顺便刷新缓存文件的状态:游戏在后台跑过一轮之后它可能才出现、或者变大了。 + refreshGlslCacheFile() + + val storage = try { + requireStorage() + } catch (e: IOException) { + forget() + return ConfigLoadResult.Corrupt(backupName = null, cause = e) + } + + if (!storage.configExists()) { + foreignKeys = null + mutableConfig.value = MGConfig.Default + // 内存里有默认值而磁盘上什么都没有,两边确实不一致:让调用方 flush 一次即可建立文件。 + dirty = true + return ConfigLoadResult.Missing + } + + val text = try { + storage.readConfig() + } catch (e: Exception) { + forget() + return ConfigLoadResult.Corrupt(backupName = null, cause = e) + } + + val root = try { + JsonParser.parseString(text).asJsonObject + } catch (e: Exception) { + // forget() 之后 store 不再持有任何配置,也就不可能有哪次 flush 把损坏的文件覆盖掉。 + val backup = storage.writeCorruptBackup(text) + forget() + return ConfigLoadResult.Corrupt(backupName = backup, cause = e) + } + + val config = MGConfigCodec.decode(root) + foreignKeys = MGConfigCodec.foreignKeysOf(root) + mutableConfig.value = config + dirty = false + return ConfigLoadResult.Loaded(config) + } + + private suspend fun persist() = fileLock.withLock { persistLocked() } + + /** 调用方必须已经持有 [fileLock]。 */ + private suspend fun persistLocked() { + val config = mutableConfig.value + if (!dirty || config == null) return + + val payload = serialize(config) + // NonCancellable:写到一半被取消会留下一个只有临时文件、没有落地的保存。 + val outcome = withContext(NonCancellable + io) { + runCatching { requireStorage().writeConfig(payload) } + } + + outcome.onFailure { mutableEvents.tryEmit(ConfigStoreEvent.SaveFailed(it)) } + // 写的过程中配置又被改了的话保持 dirty,那次改动自己排了一次保存。 + if (outcome.isSuccess && mutableConfig.value === config) dirty = false + } + + private fun serialize(config: MGConfig): String = + gson.toJson(MGConfigCodec.encode(config, foreignKeys)) + + companion object { + private const val SAVE_DEBOUNCE_MILLIS = 300L + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MgStats.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MgStats.kt new file mode 100644 index 00000000..b0485b6a --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MgStats.kt @@ -0,0 +1,38 @@ +package com.fcl.plugin.mobileglues.settings + +import com.google.gson.JsonParser + +/** + * `MG/stats.json`:native 库自己记的运行数据。 + * + * 只读。写的一方是渲染器——它被游戏加载时把启动次数加一,本 App 的进程根本不在场, + * 所以这里既不该、也没办法去改它。 + */ +data class MgStats(val launchCount: Int) { + + companion object { + val Empty = MgStats(launchCount = 0) + + /** + * 解析 stats.json。 + * + * 任何读不懂的情况都回落到 [Empty]:这个文件坏了顶多让赞助弹窗晚问一阵子, + * 不值得为它打断用户——和 config.json 损坏时的处理完全不同,那个必须叫住用户。 + */ + fun parse(text: String?): MgStats { + if (text.isNullOrBlank()) return Empty + return runCatching { + val root = JsonParser.parseString(text).asJsonObject + val count = root.get(KEY_LAUNCH_COUNT) + ?.takeIf { it.isJsonPrimitive } + ?.asInt + ?.coerceAtLeast(0) + ?: 0 + MgStats(launchCount = count) + }.getOrDefault(Empty) + } + + /** 与 native 的 config/stats.cpp 一致。 */ + private const val KEY_LAUNCH_COUNT = "launchCount" + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MgStorage.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MgStorage.kt new file mode 100644 index 00000000..b1c3eda8 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MgStorage.kt @@ -0,0 +1,256 @@ +package com.fcl.plugin.mobileglues.settings + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * 对 `MG/` 目录的访问抽象。 + * + * 两种实现:[DirectMgStorage](「所有文件访问」或旧版存储权限,直接走文件系统)和 + * [SafMgStorage](SAF 目录授权,走 DocumentsContract)。native 端永远读 `/sdcard/MG` 的真实路径, + * 这个抽象只对 App 自己的读写生效——两种授权方式写出来的是同一个目录、同一个 config.json。 + * + * 所有函数都可能做 IO,调用方负责放到后台线程。 + */ +interface MgStorage { + + /** 给用户看的路径,两种模式下都指 `/sdcard/MG`。 */ + val displayPath: String + + /** 授权此刻是否仍然有效:SAF 的 URI 可能被吊销,目录也可能被用户在外部删掉。 */ + fun isAccessible(): Boolean + + fun configExists(): Boolean + + /** 读配置全文。文件不存在或不可读时抛 [IOException]。 */ + @Throws(IOException::class) + fun readConfig(): String + + /** + * 写入配置全文。 + * + * [DirectMgStorage] 是原子写(临时文件 + rename);SAF 没有 rename 可用, + * [SafMgStorage] 只能截断式覆写,是尽力而为。 + */ + @Throws(IOException::class) + fun writeConfig(text: String) + + /** 配置损坏时把原文备份到配置旁边,返回备份文件名;备份失败返回 null。 */ + fun writeCorruptBackup(text: String): String? + + /** + * 读 native 库写的 `stats.json`;文件不存在或读不动时返回 null。 + * + * 这个文件只有 native 库写、本 App 只读:启动次数由渲染器在被游戏加载时自增, + * 本 App 无从知道那件事发生过,只能来这里看。 + */ + fun readStats(): String? + + /** GLSL 缓存文件的字节数;文件不存在为 null。 */ + fun glslCacheBytes(): Long? + + @Throws(IOException::class) + fun deleteGlslCache() + + /** + * 删除 MobileGlues 和本插件在 MG 目录下自己创建的全部文件(危险区域的「移除 MobileGlues」)。 + * + * 只动 [KNOWN_MG_FILE_NAMES] 里的文件,用户手动放进 MG 目录的其他东西不会被碰; + * 删完之后目录若已经空了,会连目录一起删掉。 + */ + @Throws(IOException::class) + fun deleteAll() +} + +/** 直接文件访问:拥有「所有文件访问」或旧版存储权限时使用。 */ +class DirectMgStorage(private val root: File) : MgStorage { + + private val configFile = File(root, CONFIG_FILE_NAME) + private val glslCacheFile = File(root, GLSL_CACHE_FILE_NAME) + + override val displayPath: String get() = root.absolutePath + + override fun isAccessible(): Boolean = root.isDirectory || root.exists().not() + + override fun configExists(): Boolean = configFile.isFile + + override fun readConfig(): String = configFile.readText() + + override fun writeConfig(text: String) = configFile.writeAtomically(text) + + override fun writeCorruptBackup(text: String): String? = runCatching { + val backup = File(root, CONFIG_FILE_NAME + CORRUPT_BACKUP_SUFFIX) + backup.writeText(text) + backup.name + }.getOrNull() + + override fun readStats(): String? = + runCatching { File(root, STATS_FILE_NAME).takeIf { it.isFile }?.readText() }.getOrNull() + + override fun glslCacheBytes(): Long? = glslCacheFile.takeIf { it.isFile }?.length() + + override fun deleteGlslCache() { + if (glslCacheFile.exists() && !glslCacheFile.delete()) { + throw IOException("Could not delete ${glslCacheFile.path}") + } + } + + override fun deleteAll() { + if (!root.isDirectory) return + for (name in KNOWN_MG_FILE_NAMES) { + val file = File(root, name) + if (file.exists() && !file.delete()) { + throw IOException("Could not delete ${file.path}") + } + } + // 目录本身只有在清空之后才顺手删掉:用户自己塞进来的文件会让它继续留着。 + if (root.list()?.isEmpty() == true) { + root.delete() + } + } +} + +/** + * SAF 目录授权访问:用户通过系统文件选择器把 MG 目录授给本 App。 + * + * URI 已经过 `takePersistableUriPermission` 持久化,但仍有失效路径——用户在系统设置里 + * 吊销授权、或者直接把目录删了,[isAccessible] 都会返回 false。 + */ +class SafMgStorage( + context: Context, + private val treeUri: Uri, +) : MgStorage { + + private val appContext = context.applicationContext + + override val displayPath: String get() = "/sdcard/$MG_DIRECTORY_NAME" + + private fun tree(): DocumentFile? = + DocumentFile.fromTreeUri(appContext, treeUri)?.takeIf { it.isDirectory } + + override fun isAccessible(): Boolean { + val persisted = appContext.contentResolver.persistedUriPermissions.any { + it.uri == treeUri && it.isReadPermission && it.isWritePermission + } + return persisted && tree() != null + } + + private fun child(name: String): DocumentFile? = tree()?.findFile(name) + + @Throws(IOException::class) + private fun childForWrite(name: String, mimeType: String): DocumentFile { + child(name)?.takeIf { it.isFile }?.let { return it } + val dir = tree() ?: throw IOException("MG directory is not accessible") + return dir.createFile(mimeType, name) + ?: throw IOException("Could not create $name") + } + + override fun configExists(): Boolean = child(CONFIG_FILE_NAME)?.isFile == true + + override fun readConfig(): String { + val doc = child(CONFIG_FILE_NAME)?.takeIf { it.isFile } + ?: throw IOException("$MG_DIRECTORY_NAME/$CONFIG_FILE_NAME not found") + return appContext.contentResolver.openInputStream(doc.uri)?.use { input -> + input.readBytes().toString(Charsets.UTF_8) + } ?: throw IOException("Could not open ${doc.uri}") + } + + override fun writeConfig(text: String) { + val doc = childForWrite(CONFIG_FILE_NAME, JSON_MIME_TYPE) + appContext.contentResolver.openOutputStream(doc.uri, "wt")?.use { output -> + output.write(text.toByteArray(Charsets.UTF_8)) + } ?: throw IOException("Could not open ${doc.uri} for writing") + } + + override fun writeCorruptBackup(text: String): String? = runCatching { + val name = CONFIG_FILE_NAME + CORRUPT_BACKUP_SUFFIX + // 不能用 JSON_MIME_TYPE:扩展名对不上时系统提供器可能擅自再补一个 .json 后缀。 + val doc = childForWrite(name, FALLBACK_MIME_TYPE) + appContext.contentResolver.openOutputStream(doc.uri, "wt")?.use { output -> + output.write(text.toByteArray(Charsets.UTF_8)) + } + name + }.getOrNull() + + override fun readStats(): String? = runCatching { + val doc = child(STATS_FILE_NAME)?.takeIf { it.isFile } ?: return null + appContext.contentResolver.openInputStream(doc.uri)?.use { input -> + input.readBytes().toString(Charsets.UTF_8) + } + }.getOrNull() + + override fun glslCacheBytes(): Long? = + child(GLSL_CACHE_FILE_NAME)?.takeIf { it.isFile }?.length() + + override fun deleteGlslCache() { + val doc = child(GLSL_CACHE_FILE_NAME) ?: return + if (!doc.delete()) throw IOException("Could not delete $GLSL_CACHE_FILE_NAME") + } + + override fun deleteAll() { + val dir = tree() ?: return + for (name in KNOWN_MG_FILE_NAMES) { + val doc = dir.findFile(name) ?: continue + if (!doc.delete()) throw IOException("Could not delete $name") + } + // 目录本身只有在清空之后才顺手删掉:用户自己塞进来的文件会让它继续留着。 + if (dir.listFiles().isEmpty()) { + dir.delete() + } + } + + companion object { + const val MG_DIRECTORY_NAME = "MG" + private const val JSON_MIME_TYPE = "application/json" + private const val FALLBACK_MIME_TYPE = "application/octet-stream" + } +} + +internal const val CONFIG_FILE_NAME = "config.json" +internal const val GLSL_CACHE_FILE_NAME = "glsl_cache.tmp" +internal const val STATS_FILE_NAME = "stats.json" +internal const val LOG_FILE_NAME = "latest.log" +internal const val GL_CALLS_FILE_NAME = "glcalls.txt" +internal const val CORRUPT_BACKUP_SUFFIX = ".corrupt" +private const val CONFIG_TEMP_FILE_NAME = CONFIG_FILE_NAME + ".tmp" + +/** + * MobileGlues(native 库)和本插件会在 MG 目录下主动创建的全部文件名。 + * + * 「撤销并删除全部文件」只删这些——MG 目录是用户看得见的目录,谁都可能手动放点别的东西 + * 进去,那些文件不是我们创建的,也就没资格被我们删掉。 + */ +internal val KNOWN_MG_FILE_NAMES = listOf( + CONFIG_FILE_NAME, + CONFIG_TEMP_FILE_NAME, + CONFIG_FILE_NAME + CORRUPT_BACKUP_SUFFIX, + GLSL_CACHE_FILE_NAME, + STATS_FILE_NAME, + LOG_FILE_NAME, + GL_CALLS_FILE_NAME, +) + +/** + * 先写临时文件再 rename。 + * + * 同目录下的 rename 是原子的,所以读的一方(游戏里的 MobileGlues)要么看到旧内容, + * 要么看到完整的新内容,不会看到写到一半的文件。 + */ +private fun File.writeAtomically(text: String) { + parentFile?.mkdirs() + val temporary = File(parentFile, "$name.tmp") + FileOutputStream(temporary).use { output -> + output.write(text.toByteArray()) + output.flush() + output.fd.sync() + } + if (!temporary.renameTo(this)) { + // 同目录 rename 正常不会失败;万一失败就退回直接写,至少不会把配置丢掉。 + temporary.delete() + writeText(text) + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MultidrawBench.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MultidrawBench.kt new file mode 100644 index 00000000..fb16edaa --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MultidrawBench.kt @@ -0,0 +1,157 @@ +package com.fcl.plugin.mobileglues.settings + +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +/** 某一个函数这次测得怎么样。 */ +data class MultidrawBenchQuality( + /** 各轮之间的离散度(MAD/中位数):这个函数上的数字之间差多少才算真的差。 */ + val noise: Double, + val rounds: Int, + /** 这个函数测了几遍。抖得压不下去时 native 会放大场景单独重测它。 */ + val attempts: Int, + /** + * 这个函数的数字是在多大的场景上量的。 + * + * 抖动会让 native 把场景翻倍重测,而先压到目标的函数就此定格在较小的场景上—— + * 于是同一份结果里,不同函数的微秒数可能来自不同规模。名次不受影响(每个函数 + * 各自在同一趟里排),但把两个函数的绝对耗时摆在一起比就是错的。 + */ + val sections: Int, + /** 重测到头仍然没压到目标,这个函数的排名只能算参考。 */ + val noisy: Boolean, +) + +/** + * MultiDraw 微基准的结果:每个入口点 → 每个后端 → 每次调用多少微秒(多轮的中位数)。 + * + * JSON 由 native 端 `gl/multidraw_bench.cpp` 生成。没测出来的后端(设备不支持、 + * 或测量中发生了降级)直接缺席——宁缺毋滥,一个错的数字比没有数字糟得多。 + * + * 成色是分函数记的([quality]):每个函数各自排名,也就各自判抖不抖、各自重测。 + * 老版本渲染器不报这一段,缺席即空。 + */ +data class MultidrawBenchReport( + val timings: Map>, + /** 每个函数各自测得怎么样。抖与不抖是分函数判的,重测也是分函数做的。 */ + val quality: Map = emptyMap(), + val elapsedMs: Double = 0.0, + /** 配置要求用 ANGLE。 */ + val angleRequested: Boolean = false, + /** ANGLE 真的加载上了。渲染器加载不到会不声不响退回系统驱动。 */ + val angleInUse: Boolean = false, + /** 用户配置的原始档位([AngleConfig] 的 wire 值);老渲染器不报,缺席为 -1。 */ + val angleConfigured: Int = -1, + /** 设备过不过得了 ANGLE 探测(Vulkan 1.2 且非 Adreno 730/740);老渲染器缺席按 true。 */ + val angleSupported: Boolean = true, + val renderer: String? = null, + /** + * 这一趟结束时场景有多大(section 数);0 = 渲染器没报(旧版本)。 + * + * [error] 是 "context-lost" 时这就是把上下文撑爆的那个规模——调用方要靠它算出 + * 下一次的上限。所以它在出错的那条路径上也必须解出来,不能跟着结果一起丢掉。 + */ + val sections: Int = 0, + val error: String? = null, +) { + /** + * 这次是在错的驱动上测的:配置要 ANGLE,实际跑的却是系统驱动。 + * + * ANGLE 是 GLES-on-Vulkan,扩展支持、baseVertex 的实现方式、compute 的开销都与原生 + * 驱动是两套东西,此时的名次挪到游戏里不成立,采用它反而会把配置带偏。 + */ + val wrongDriver: Boolean get() = angleRequested && !angleInUse + + companion object { + + fun parse(text: String?): MultidrawBenchReport { + val root = runCatching { + JsonParser.parseString(text.orEmpty()).asJsonObject + }.getOrNull() ?: return MultidrawBenchReport(emptyMap(), error = "unparseable result") + + val sections = root.get("sections")?.let { runCatching { it.asInt }.getOrNull() } ?: 0 + + root.get("error")?.takeIf { it.isJsonPrimitive }?.let { + // sections 要跟着错误一起带出去:上下文丢失时,撑爆它的那个规模就是 + // 调用方唯一能拿来退一步的依据。 + return MultidrawBenchReport(emptyMap(), sections = sections, error = it.asString) + } + + val entriesObj = root.get("entries")?.takeIf { it.isJsonObject }?.asJsonObject + ?: return MultidrawBenchReport(emptyMap(), error = "no entries in result") + val qualityObj = root.get("quality")?.takeIf { it.isJsonObject }?.asJsonObject + + val timings = MultidrawEntry.entries.mapNotNull { entry -> + val obj = entriesObj.get(entry.glFunction) + ?.takeIf { it.isJsonObject }?.asJsonObject ?: return@mapNotNull null + val perBackend = obj.entrySet().mapNotNull { (name, value) -> + val backend = MultidrawBackend.parse(name) ?: return@mapNotNull null + val us = runCatching { value.asDouble }.getOrNull() ?: return@mapNotNull null + backend to us + }.toMap() + if (perBackend.isEmpty()) null else entry to perBackend + }.toMap() + + val quality = MultidrawEntry.entries.mapNotNull { entry -> + val obj = qualityObj?.get(entry.glFunction) + ?.takeIf { it.isJsonObject }?.asJsonObject ?: return@mapNotNull null + entry to MultidrawBenchQuality( + noise = obj.get("noise")?.let { runCatching { it.asDouble }.getOrNull() } ?: 0.0, + rounds = obj.get("rounds")?.let { runCatching { it.asInt }.getOrNull() } ?: 0, + attempts = obj.get("attempts")?.let { runCatching { it.asInt }.getOrNull() } ?: 1, + sections = obj.get("sections")?.let { runCatching { it.asInt }.getOrNull() } ?: sections, + noisy = obj.get("noisy")?.let { runCatching { it.asBoolean }.getOrNull() } == true, + ) + }.toMap() + + return if (timings.isEmpty()) { + MultidrawBenchReport(emptyMap(), sections = sections, error = "empty result") + } else { + MultidrawBenchReport( + timings = timings, + quality = quality, + elapsedMs = root.get("elapsedMs") + ?.let { runCatching { it.asDouble }.getOrNull() } ?: 0.0, + angleRequested = root.get("angleRequested") + ?.let { runCatching { it.asBoolean }.getOrNull() } == true, + angleInUse = root.get("angleInUse") + ?.let { runCatching { it.asBoolean }.getOrNull() } == true, + angleConfigured = root.get("angleConfigured") + ?.let { runCatching { it.asInt }.getOrNull() } ?: -1, + angleSupported = root.get("angleSupported") + ?.let { runCatching { it.asBoolean }.getOrNull() } != false, + renderer = root.get("renderer") + ?.let { runCatching { it.asString }.getOrNull() }, + sections = sections, + ) + } + } + } +} + +/** 排好序的一项推荐:条目 + 相对耗时(最快 = 1.0;没测出来为 null,排在末尾)。 */ +data class RankedItem(val item: T, val relativeCost: Double?) + +/** + * 从跑分结果算推荐排序。 + * + * 只按函数排,不合成全局排序:全局排序要一个次序同时管住五个函数,而这五个函数的绝对耗时 + * 差着数量级(unroll 一次 96 个驱动调用,multiindirect 一次一个),怎么加权都是拿一个 + * 在每个函数上都不是最优的顺序冒充最优。测出来的东西本来就是分函数的,就分函数交出去。 + */ +object MultidrawBenchAnalyzer { + + fun rankEntry( + report: MultidrawBenchReport, + entry: MultidrawEntry, + ): List> { + val perBackend = report.timings[entry] ?: emptyMap() + val fastest = perBackend.values.minOrNull() + val ranked = perBackend.entries.sortedBy { it.value } + .map { RankedItem(it.key, it.value / fastest!!) } + val unmeasured = entry.implemented + .filter { it !in perBackend } + .map { RankedItem(it, null) } + return ranked + unmeasured + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/PluginConfigStore.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/PluginConfigStore.kt new file mode 100644 index 00000000..75bda5c4 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/PluginConfigStore.kt @@ -0,0 +1,191 @@ +package com.fcl.plugin.mobileglues.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** 界面皮肤。 */ +enum class UiStyle(val key: String) { + Material("material"), + Miuix("miuix"); + + companion object { + fun ofKey(key: String?): UiStyle = entries.firstOrNull { it.key == key } ?: Material + + /** + * 没选过时给哪一套。小米自家系统上给 Miuix——那里它和系统本身是同一种长相。 + * + * 只是默认值:一旦用户在设置里选过,存下来的那个选择永远优先,包括 HyperOS 上 + * 主动选了 Material 的用户。 + */ + fun default(): UiStyle = if (SystemUi.isMiuiOrHyperOs) Miuix else Material + } +} + +/** 用户选择的存储授权方式。 */ +enum class AuthMethod(val key: String) { + /** 「所有文件访问」权限(Android 11+)。 */ + AllFiles("all_files"), + + /** SAF 目录授权,用户自己挑出 MG 目录。 */ + Saf("saf"), + + /** Android 10 及以下的旧版 READ/WRITE 运行时权限。 */ + Legacy("legacy"); + + companion object { + fun ofKey(key: String?): AuthMethod? = entries.firstOrNull { it.key == key } + } +} + +/** + * 赞助弹窗的判定逻辑。独立成纯函数是为了让「每 20 次问一次」这件事可以被单测盯住。 + * + * 计数来自 native 库写在 `MG/stats.json` 里的启动次数——一次启动是一局游戏加载渲染器, + * 不是用户打开一次设置页。本 App 只记住「上次是在第几次启动时问过」,两者相差够远才再问: + * 用差值而不是取模,是因为这个计数不由本 App 掌控,它可能一次跳很多(用户连开几局), + * 取模会正好跨过那个点而永远不弹。 + */ +object SponsorPrompt { + const val INTERVAL = 20 + + fun shouldPrompt(launchCount: Int, lastPromptedAt: Int, donated: Boolean): Boolean = + !donated && launchCount - lastPromptedAt >= INTERVAL +} + +/** + * 本 App 自己的本地设置(与 MobileGlues 的 config.json 无关)。 + * + * 用 SharedPreferences、不引新依赖。它记录的是「界面风格、授权方式、启动次数」这类 + * 本地偏好,所以必须放在存储权限门之上——未授权时设置页也不能是空白。 + */ +class PluginConfigStore(context: Context) { + + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // 区分「没选过」和「选了 material」:前者要跟随系统,后者是用户的明确意愿。 + // prefs.getString 的 null 正好把两者分开,ofKey 做不到(它把 null 也答成 Material)。 + private val mutableUiStyle = MutableStateFlow( + prefs.getString(KEY_UI_STYLE, null)?.let { UiStyle.ofKey(it) } ?: UiStyle.default(), + ) + val uiStyle: StateFlow = mutableUiStyle.asStateFlow() + + fun setUiStyle(style: UiStyle) { + prefs.edit { putString(KEY_UI_STYLE, style.key) } + mutableUiStyle.value = style + } + + private val mutableAuthMethod = + MutableStateFlow(AuthMethod.ofKey(prefs.getString(KEY_AUTH_METHOD, null))) + + /** 用户上次选择的授权方式;`null` = 还没选过。授权本身是否仍然有效由 AuthController 判断。 */ + val authMethod: StateFlow = mutableAuthMethod.asStateFlow() + + fun setAuthMethod(method: AuthMethod?) { + prefs.edit { + if (method == null) remove(KEY_AUTH_METHOD) else putString(KEY_AUTH_METHOD, method.key) + } + mutableAuthMethod.value = method + } + + /** SAF 模式下持久化过的 tree URI。 */ + var safTreeUri: String? + get() = prefs.getString(KEY_SAF_TREE_URI, null) + set(value) { + prefs.edit { + if (value == null) remove(KEY_SAF_TREE_URI) else putString(KEY_SAF_TREE_URI, value) + } + } + + /** + * 上次弹赞助窗时的启动次数。 + * + * 启动次数本身由 native 库记在 `MG/stats.json`,本 App 不再自己数——打开设置页 + * 不是一次启动。这里只记「问过的那个点」,够远了再问下一次。 + */ + val lastSponsorPromptAt: Int get() = prefs.getInt(KEY_LAST_SPONSOR_PROMPT_AT, 0) + + fun markSponsorPromptedAt(launchCount: Int) { + prefs.edit { putInt(KEY_LAST_SPONSOR_PROMPT_AT, launchCount) } + } + + private val mutablePrivacyAccepted = + MutableStateFlow(prefs.getBoolean(KEY_PRIVACY_ACCEPTED, false)) + + /** + * 用户同意过隐私政策。 + * + * 首次启动必须先问一次——这个 App 要的是「所有文件访问」这种分量的权限, + * 在开口要之前应当先讲清楚自己会碰什么。 + */ + val privacyAccepted: StateFlow = mutablePrivacyAccepted.asStateFlow() + + fun markPrivacyAccepted() { + prefs.edit { putBoolean(KEY_PRIVACY_ACCEPTED, true) } + mutablePrivacyAccepted.value = true + } + + fun revokePrivacyAcceptance() { + prefs.edit { putBoolean(KEY_PRIVACY_ACCEPTED, false) } + mutablePrivacyAccepted.value = false + } + + private val mutableAngleSource = + MutableStateFlow(prefs.getString(KEY_ANGLE_SOURCE, null)) + + /** + * 用户点头信任、用来借 ANGLE 的那个启动器的包名;`null` = 还没选过。 + * + * 存包名而不是路径:路径里带着安装时生成的一串随机码,启动器一更新就变了。 + */ + val angleSourcePackage: StateFlow = mutableAngleSource.asStateFlow() + + fun setAngleSourcePackage(packageName: String?) { + prefs.edit { + if (packageName == null) remove(KEY_ANGLE_SOURCE) else putString(KEY_ANGLE_SOURCE, packageName) + } + mutableAngleSource.value = packageName + } + + private val mutableDonated = MutableStateFlow(prefs.getBoolean(KEY_DONATED, false)) + + /** 用户说过「已经捐赠了」。一旦为 true,赞助弹窗永不再出现。 */ + val donated: StateFlow = mutableDonated.asStateFlow() + + fun markDonated() { + prefs.edit { putBoolean(KEY_DONATED, true) } + mutableDonated.value = true + } + + /** + * 清空全部本地偏好,回到刚安装的样子。 + * + * 只在「撤销并删除全部文件」那条路上用:既然承诺了删干净,就不能把授权方式、 + * 上次询问赞助的次数、是否已捐赠这些留在设备上。 + * + * 界面风格只清存储、不动内存里的当前值:这时候收尾对话框正开着,把皮肤当场切回 + * 默认值只会让人以为程序出错了。下次启动读到的就是默认值。 + */ + fun clearAll() { + prefs.edit { clear() } + mutableAuthMethod.value = null + mutablePrivacyAccepted.value = false + mutableDonated.value = false + mutableAngleSource.value = null + } + + private companion object { + const val PREFS_NAME = "plugin_config" + const val KEY_UI_STYLE = "ui_style" + const val KEY_AUTH_METHOD = "auth_method" + const val KEY_SAF_TREE_URI = "saf_tree_uri" + const val KEY_LAST_SPONSOR_PROMPT_AT = "last_sponsor_prompt_at" + const val KEY_DONATED = "donated" + const val KEY_PRIVACY_ACCEPTED = "privacy_accepted" + const val KEY_ANGLE_SOURCE = "angle_source_package" + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/SystemUi.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/SystemUi.kt new file mode 100644 index 00000000..41cbd0c1 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/SystemUi.kt @@ -0,0 +1,44 @@ +package com.fcl.plugin.mobileglues.settings + +/** + * 这台机器跑的是不是 MIUI / HyperOS。 + * + * 用来挑默认皮肤:小米自家系统上,Miuix 皮肤和系统本身长得一样,那是更合适的第一印象。 + * + * 判断的是**系统**,不是厂商。`Build.MANUFACTURER == "Xiaomi"` 看起来最省事,但它是错的: + * 小米的机器刷上 GSI 或第三方 ROM 之后,硬件还是小米,系统已经是原生 Android,那时候 + * 端出 Miuix 皮肤只会显得格格不入。开发中用的那台 TrebleDroid 就是这个情形——机器是 + * 小米的,`ro.miui.*` 与 `ro.mi.os.*` 全空。 + * + * 所以只认系统自己留下的痕迹,任一命中即可: + * + * - `miui.os.Build` 这个类。MIUI 和 HyperOS 都把它放在 boot classpath 里,原生 Android + * 没有。用 Class.forName 探测,不碰任何隐藏 API。 + * - `ro.miui.ui.version.name`:MIUI 一直在设,HyperOS 多数版本仍然保留。 + * - `ro.mi.os.version.name`:HyperOS 独有,用来兜住那些不再设上一条的版本。 + * + * 属性读取走反射调用 android.os.SystemProperties.get。它不是公开 API,所以整段包在 + * try 里:读不到就当作「不是」,最坏的结果是默认皮肤仍为 Material——一个能用的结果, + * 不是一次崩溃。 + */ +object SystemUi { + + val isMiuiOrHyperOs: Boolean by lazy { + hasMiuiClass() || !systemProperty("ro.miui.ui.version.name").isNullOrEmpty() || + !systemProperty("ro.mi.os.version.name").isNullOrEmpty() + } + + private fun hasMiuiClass(): Boolean = try { + Class.forName("miui.os.Build") + true + } catch (_: Throwable) { + false + } + + private fun systemProperty(name: String): String? = try { + val clazz = Class.forName("android.os.SystemProperties") + clazz.getMethod("get", String::class.java).invoke(null, name) as? String + } catch (_: Throwable) { + null + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/showAppInfoDialog.kt b/app/src/main/java/com/fcl/plugin/mobileglues/showAppInfoDialog.kt deleted file mode 100644 index 79f3603a..00000000 --- a/app/src/main/java/com/fcl/plugin/mobileglues/showAppInfoDialog.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.fcl.plugin.mobileglues - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.view.LayoutInflater -import android.widget.TextView -import androidx.core.net.toUri -import com.fcl.plugin.mobileglues.settings.MGConfig -import com.google.android.material.button.MaterialButton -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -@SuppressLint("InflateParams") -fun showAppInfoDialog(context: Context, config: MGConfig?) { - val view = LayoutInflater.from(context).inflate(R.layout.dialog_app_info, null, false) - view.findViewById(R.id.info_version).text = BuildConfig.VERSION_NAME - - MaterialAlertDialogBuilder(context) - .setTitle(R.string.dialog_info) - .setView(view) - .setNeutralButton(R.string.dialog_positive, null) - .setNegativeButton(R.string.dialog_sponsor) { _, _ -> - context.startActivity( - Intent(Intent.ACTION_VIEW, "https://www.buymeacoffee.com/Swung0x48".toUri()) - ) - } - .setPositiveButton(R.string.dialog_github) { _, _ -> - context.startActivity( - Intent(Intent.ACTION_VIEW, "https://github.com/MobileGL-Dev/MobileGlues-release".toUri()) - ) - } - .show() - .let { dialog -> - view.findViewById(R.id.button_gl_info)?.setOnClickListener { - showMGGLInfoDialog(context, config) - dialog.dismiss() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/showMGGLInfoDialog.kt b/app/src/main/java/com/fcl/plugin/mobileglues/showMGGLInfoDialog.kt deleted file mode 100644 index e85363dc..00000000 --- a/app/src/main/java/com/fcl/plugin/mobileglues/showMGGLInfoDialog.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.fcl.plugin.mobileglues - -import android.content.Context -import android.widget.TextView -import com.fcl.plugin.mobileglues.settings.MGConfig -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -object MGInfoGetter { - init { - System.loadLibrary("mobileglues_info_getter") - } - - external fun setenv(key: String, value: String, overwrite: Int): Int - - external fun getMobileGluesGLInfo(): String - - val mgGLInfo: String - get() = try { - setenv("MG_PLUGIN_STATUS", 1.toString(), 1) - setenv("MG_DIR_PATH", MGConfig.cacheMGDir.path, 1) - getMobileGluesGLInfo() - } catch (e: Throwable) { - "Error: ${e.message}" - } -} - -fun showMGGLInfoDialog(context: Context, config: MGConfig?) { - config?.saveToCachePath() - MaterialAlertDialogBuilder(context) - .setTitle(R.string.dialog_mg_gl_info_title) - .setMessage(MGInfoGetter.mgGLInfo) - .setNegativeButton(R.string.dismiss, null) - .show() - .let { dialog -> - dialog.findViewById(android.R.id.message)?.setTextIsSelectable(true) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/AppController.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/AppController.kt new file mode 100644 index 00000000..60c80d92 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/AppController.kt @@ -0,0 +1,1306 @@ +package com.fcl.plugin.mobileglues.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.annotation.StringRes +import androidx.core.net.toUri +import com.fcl.plugin.mobileglues.BuildConfig +import com.fcl.plugin.mobileglues.DeviceInfo +import com.fcl.plugin.mobileglues.DeviceInfoProvider +import com.fcl.plugin.mobileglues.MGApplication +import com.fcl.plugin.mobileglues.MGBench +import com.fcl.plugin.mobileglues.MGInfoGetter +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.settings.AngleConfig +import com.fcl.plugin.mobileglues.settings.AngleProvider +import com.fcl.plugin.mobileglues.MgQuery +import com.fcl.plugin.mobileglues.settings.AngleSource +import com.fcl.plugin.mobileglues.settings.AuthController +import com.fcl.plugin.mobileglues.settings.AuthMethod +import com.fcl.plugin.mobileglues.settings.ConfigLoadResult +import com.fcl.plugin.mobileglues.settings.ConfigStoreEvent +import com.fcl.plugin.mobileglues.settings.DepthClearFixMode +import com.fcl.plugin.mobileglues.settings.Fsr1Preset +import com.fcl.plugin.mobileglues.settings.GlVersion +import com.fcl.plugin.mobileglues.settings.GlslCacheScale +import com.fcl.plugin.mobileglues.settings.GlslCacheSize +import com.fcl.plugin.mobileglues.settings.MGConfig +import com.fcl.plugin.mobileglues.settings.MgStats +import com.fcl.plugin.mobileglues.settings.MultidrawBackend +import com.fcl.plugin.mobileglues.settings.MultidrawBenchAnalyzer +import com.fcl.plugin.mobileglues.settings.MultidrawBenchQuality +import com.fcl.plugin.mobileglues.settings.MultidrawBenchReport +import com.fcl.plugin.mobileglues.settings.MultidrawEntry +import com.fcl.plugin.mobileglues.settings.MultidrawOrderItem +import com.fcl.plugin.mobileglues.settings.MultidrawSettings +import com.fcl.plugin.mobileglues.settings.NoErrorConfig +import com.fcl.plugin.mobileglues.settings.RankedItem +import com.fcl.plugin.mobileglues.settings.SponsorPrompt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +/** 底部导航的三个页。 */ +enum class AppTab { Home, Settings, Info } + +/** 从信息页进入的子页面(带返回)。 */ +enum class AppSubPage { GlInfo, Privacy, ThirdParty } + +/** 配置加载状态:权限门之内的内容区按它决定显示什么。 */ +enum class SettingsLoadState { NotLoaded, Loading, Ready } + +/** 授权流程的对话框状态机。由皮肤渲染,按钮回调进 [AppController]。 */ +sealed interface AuthPrompt { + /** 选择授权方式(Android 11+:所有文件访问 / SAF)。 */ + data object ChooseMethod : AuthPrompt + + /** 「所有文件访问」的说明,确认后跳系统设置。 */ + data object AllFilesIntro : AuthPrompt + + /** SAF 引导:提示用户新建/选择 MG 目录。 */ + data object SafGuide : AuthPrompt + + /** 旧版权限被永久拒绝后引导去应用详情页。 */ + data object LegacyDenied : AuthPrompt +} + +/** 收尾道别:两条路走到最后都要谢一声,然后退出。 */ +enum class Farewell { + /** 只收回了授权,MG 目录原样留着。 */ + Revoked, + + /** 连文件一起删了。 */ + Removed, +} + +/** 赞助弹窗的两步。 */ +sealed interface SponsorPromptState { + /** 第一步:温和地询问(报出具体启动次数)。 */ + data class Ask(val launchCount: Int) : SponsorPromptState + + /** 第二步:跳转捐赠页之后,委婉地确认是否已捐赠。 */ + data object Confirm : SponsorPromptState +} + +/** + * 一次「先问再改」的确认。倒计时结束后 [errorAccent] 让确认键变成警示色—— + * 和旧界面自定义 GL 版本 / 移除 MobileGlues 的行为一致。 + */ +class ConfirmRequest( + @param:StringRes val titleRes: Int, + val message: String, + /** message 是 HTML(含 @colorError 占位符),皮肤用自己的 error 色替换后解析。 */ + val messageIsHtml: Boolean = false, + @param:StringRes val positiveRes: Int = R.string.dialog_positive, + val countdownSeconds: Int = 0, + val errorAccent: Boolean = false, + private val onResult: (Boolean) -> Unit, +) { + private val resolved = java.util.concurrent.atomic.AtomicBoolean(false) + + /** 按钮、返回键、对话框关闭可能各触发一次,只有第一次算数。 */ + fun resolve(result: Boolean) { + if (resolved.compareAndSet(false, true)) onResult(result) + } +} + +/** 由 MainActivity 实现:只有 Activity 能发起系统授权页 / 退出应用。 */ +interface AuthFlowLauncher { + fun openAllFilesSettings() + fun openSafPicker() + fun requestLegacyPermission() + fun openAppDetailsSettings() + fun exitApp() +} + +/** + * 两套皮肤(MD3 / Miuix)共用的全部 UI 逻辑:导航、授权流、配置加载、 + * 设置动作(含全部警告与倒计时)、赞助弹窗、移除流程、GL 信息查询。 + * 皮肤只负责把这些状态画出来,并把用户操作回调进来——操作逻辑因此只有一份。 + */ +class AppController( + private val app: MGApplication, + private val context: Context, + private val launcher: AuthFlowLauncher, +) { + + /** 渲染器查询的一次性进程通道;跑分与 GL 信息共用(互斥地)。 */ + private val mgQuery = MgQuery(context) + + val pluginConfig = app.pluginConfigStore + val configStore = app.configStore + val auth = app.authController + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + // ---- 导航 ---- + + private val mutableTab = MutableStateFlow(AppTab.Home) + val tab: StateFlow = mutableTab.asStateFlow() + + private val mutableSubPage = MutableStateFlow(null) + val subPage: StateFlow = mutableSubPage.asStateFlow() + + fun navigateTab(target: AppTab) { + mutableSubPage.value = null + mutableTab.value = target + } + + fun openSubPage(page: AppSubPage) { + mutableSubPage.value = page + } + + /** 返回键:有子页面先退子页面。返回 true 表示已消费。 */ + fun navigateBack(): Boolean { + return if (mutableSubPage.value != null) { + mutableSubPage.value = null + true + } else { + false + } + } + + // ---- Snackbar ---- + + private val mutableSnackbar = MutableSharedFlow(extraBufferCapacity = 8) + val snackbar: MutableSharedFlow = mutableSnackbar + + fun snackbar(text: CharSequence) { + mutableSnackbar.tryEmit(text) + } + + // ---- 确认对话框 ---- + + private val mutableConfirm = MutableStateFlow(null) + val confirmRequest: StateFlow = mutableConfirm.asStateFlow() + + /** + * 挂起直到用户做出选择。取消、返回键、点遮罩都算「否」。 + * 「先问再改」的流程因此是一条直线,不需要拆 onConfirm/onCancel 两条回调。 + */ + suspend fun confirm( + @StringRes messageRes: Int, + countdownSeconds: Int = 0, + messageIsHtml: Boolean = false, + ): Boolean = suspendCancellableCoroutine { continuation -> + mutableConfirm.value = ConfirmRequest( + titleRes = R.string.dialog_title_warning, + message = context.getString(messageRes), + messageIsHtml = messageIsHtml, + positiveRes = if (countdownSeconds > 0) R.string.ok else R.string.dialog_positive, + countdownSeconds = countdownSeconds, + errorAccent = countdownSeconds > 0, + ) { result -> + mutableConfirm.value = null + if (continuation.isActive) continuation.resume(result) + } + continuation.invokeOnCancellation { mutableConfirm.value = null } + } + + // ---- 隐私政策(首次启动)---- + + /** + * 还没同意过隐私政策。 + * + * 直接读偏好而不是拍一张快照:同意之后这个值翻成 false,弹窗自己就退场了。 + */ + val privacyConsentNeeded: StateFlow = pluginConfig.privacyAccepted + .map { !it } + .stateIn(scope, SharingStarted.Eagerly, !pluginConfig.privacyAccepted.value) + + fun acceptPrivacy() = pluginConfig.markPrivacyAccepted() + + /** 不同意就没有可谈的了:这个 App 的全部用途都需要读写 MG 目录。 */ + fun declinePrivacy() = launcher.exitApp() + + // ---- 授权流 ---- + + private val mutableAuthPrompt = MutableStateFlow(null) + val authPrompt: StateFlow = mutableAuthPrompt.asStateFlow() + + /** 「去授权」的总入口:Android 11+ 先选方式,以下直接走旧版运行时权限。 */ + fun requestAccess() { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + mutableAuthPrompt.value = AuthPrompt.ChooseMethod + } else { + launcher.requestLegacyPermission() + } + } + + fun dismissAuthPrompt() { + mutableAuthPrompt.value = null + } + + fun onAuthMethodSelected(method: AuthMethod) { + mutableAuthPrompt.value = when (method) { + AuthMethod.AllFiles -> AuthPrompt.AllFilesIntro + AuthMethod.Saf -> AuthPrompt.SafGuide + AuthMethod.Legacy -> { + launcher.requestLegacyPermission() + null + } + } + } + + /** AllFilesIntro 的确认键:跳系统的所有文件访问设置页。 */ + fun proceedAllFiles() { + mutableAuthPrompt.value = null + launcher.openAllFilesSettings() + } + + /** SafGuide 的确认键:打开系统目录选择器。 */ + fun proceedSaf() { + mutableAuthPrompt.value = null + launcher.openSafPicker() + } + + /** LegacyDenied 的确认键:跳应用详情页。 */ + fun proceedAppDetails() { + mutableAuthPrompt.value = null + launcher.openAppDetailsSettings() + } + + // ---- 由 MainActivity 回填的授权结果 ---- + + fun onAllFilesResult() { + if (AuthController.hasAllFilesAccess()) { + auth.grantAllFiles() + } else { + snackbar(context.getString(R.string.permission_failed)) + } + } + + fun onSafResult(uri: Uri?) { + if (uri == null) return + when (auth.grantSaf(uri)) { + AuthController.SafGrantResult.Success -> Unit + AuthController.SafGrantResult.WrongFolder -> + snackbar(context.getString(R.string.auth_wrong_folder)) + + AuthController.SafGrantResult.Inaccessible -> + snackbar(context.getString(R.string.permission_failed)) + } + } + + fun onLegacyPermissionResult(granted: Boolean, permanentlyDenied: Boolean) { + when { + granted -> auth.grantLegacy() + permanentlyDenied -> mutableAuthPrompt.value = AuthPrompt.LegacyDenied + else -> snackbar(context.getString(R.string.permission_failed)) + } + } + + // ---- 配置加载 ---- + + private val mutableLoadState = MutableStateFlow(SettingsLoadState.NotLoaded) + val loadState: StateFlow = mutableLoadState.asStateFlow() + + private val mutableCorruptPrompt = MutableStateFlow(null) + val corruptPrompt: StateFlow = mutableCorruptPrompt.asStateFlow() + + /** 正在进行的一次加载。授权状态翻转不能在它完成之前把界面打回未加载。 */ + private var loadJob: Job? = null + + init { + // 授权状态是唯一事实来源:授权建立 → 接入存储并加载;失效 → 摘掉存储退回未加载。 + scope.launch { + auth.state.collect { state -> + val storage = state.storage + if (state.granted && storage != null) { + configStore.attachStorage(storage) + ensureConfigLoaded() + } else { + configStore.detachStorage() + mutableLoadState.value = SettingsLoadState.NotLoaded + } + } + } + scope.launch { + configStore.events.collect { event -> + when (event) { + is ConfigStoreEvent.SaveFailed -> snackbar( + context.getString( + R.string.config_save_failed, + event.cause.message ?: event.cause.javaClass.simpleName, + ), + ) + } + } + } + } + + /** 前台恢复时调用:重新核验授权(系统设置里可能刚改完)。 */ + fun refreshAuthorization() = auth.refresh() + + private fun ensureConfigLoaded() { + if (mutableLoadState.value == SettingsLoadState.Ready) return + if (loadJob?.isActive == true) return + loadJob = scope.launch { + mutableLoadState.value = SettingsLoadState.Loading + when (val result = configStore.load()) { + ConfigLoadResult.Missing -> { + // 先建立 config.json 再亮出设置:危险区域的「移除」按授权+加载状态启用。 + configStore.flush() + mutableLoadState.value = SettingsLoadState.Ready + } + + is ConfigLoadResult.Loaded -> mutableLoadState.value = SettingsLoadState.Ready + + is ConfigLoadResult.Corrupt -> { + mutableLoadState.value = SettingsLoadState.NotLoaded + mutableCorruptPrompt.value = result + } + } + } + } + + /** 配置文件损坏 → 用户选择重置。 */ + fun resetCorruptConfig() { + mutableCorruptPrompt.value = null + scope.launch { + configStore.resetToDefaults() + mutableLoadState.value = SettingsLoadState.Ready + } + } + + /** 配置文件损坏 → 用户选择不重置:回首页,不碰那个文件。 */ + fun dismissCorruptConfig() { + mutableCorruptPrompt.value = null + navigateTab(AppTab.Home) + } + + // ---- 设备信息 ---- + + private val mutableDeviceInfo = MutableStateFlow(null) + val deviceInfo: StateFlow = mutableDeviceInfo.asStateFlow() + + fun ensureDeviceInfo() { + if (mutableDeviceInfo.value != null) return + scope.launch { mutableDeviceInfo.value = DeviceInfoProvider.get(context) } + } + + // ---- 设置动作(皮肤回调的唯一入口)---- + + private fun update(transform: (MGConfig) -> MGConfig) = configStore.update(transform) + + private fun confirmThenUpdate( + @StringRes messageRes: Int, + countdownSeconds: Int = 0, + messageIsHtml: Boolean = false, + transform: (MGConfig) -> MGConfig, + ) { + scope.launch { + if (confirm(messageRes, countdownSeconds, messageIsHtml)) update(transform) + // 取消时什么都不做:单向数据流下界面从未离开当前配置,不需要「撤销」。 + } + } + + fun selectAngle(target: AngleConfig) { + val current = configStore.config.value ?: return + if (target == current.angle) return + scope.launch { + val approved = target != AngleConfig.ForceEnable || + !DeviceInfoProvider.isAdreno740(context) || + confirm(R.string.warning_adreno_740_angle) + if (!approved) return@launch + update { it.copy(angle = target) } + // 换了驱动,之前那份排序是在另一个驱动上量出来的。只有用户自己调过或跑过分 + // 才值得说这句——默认顺序本来就不是量出来的,换驱动也谈不上过期。 + if (current.multidraw != MultidrawSettings.Default) { + mutableBenchOutdated.tryEmit(Unit) + } + } + } + + /** + * ANGLE 模式变了,而手上这份 MultiDraw 排序是在旧驱动上定的。 + * + * 用 SharedFlow 而不是状态位:这是一次「刚刚发生了什么」的通知,用户看过就过去了, + * 不该在重组或返回这一页时再冒出来一次。 + */ + private val mutableBenchOutdated = MutableSharedFlow(extraBufferCapacity = 1) + val benchOutdated: MutableSharedFlow = mutableBenchOutdated + + fun selectNoError(target: NoErrorConfig) = update { it.copy(noError = target) } + + fun selectGlVersion(target: GlVersion) { + val current = configStore.config.value ?: return + if (target == current.glVersion) return + if (current.glVersion == GlVersion.Default) { + // 只在「从不启用切到某个具体版本」时才需要冷静期。 + confirmThenUpdate( + R.string.warning_enabling_custom_gl_version, + CUSTOM_GL_VERSION_COOLDOWN_SECONDS, + messageIsHtml = true, + ) { it.copy(glVersion = target) } + } else { + update { it.copy(glVersion = target) } + } + } + + fun selectDepthClearFix(target: DepthClearFixMode) { + val current = configStore.config.value ?: return + if (target == current.depthClearFix) return + if (target == DepthClearFixMode.Disabled) { + update { it.copy(depthClearFix = target) } + } else { + confirmThenUpdate(R.string.warning_enabling_angle_clear_workaround) { + it.copy(depthClearFix = target) + } + } + } + + fun setExtComputeShader(enabled: Boolean) { + val current = configStore.config.value ?: return + if (enabled == current.extComputeShader) return + if (enabled) { + confirmThenUpdate(R.string.warning_ext_cs_enable) { it.copy(extComputeShader = true) } + } else { + update { it.copy(extComputeShader = false) } + } + } + + /** 开关文案是「禁用 timer_query」,勾上等于磁盘上写 0。 */ + fun setExtTimerQueryDisabled(disabled: Boolean) = update { it.copy(extTimerQuery = !disabled) } + + fun setExtDirectStateAccess(enabled: Boolean) = update { it.copy(extDirectStateAccess = enabled) } + + fun setFsr1(enabled: Boolean) { + val current = configStore.config.value ?: return + if (enabled == current.fsr1Enabled) return + if (enabled) { + confirmThenUpdate(R.string.warning_fsr1_enable) { it.copy(fsr1 = Fsr1Preset.UltraQuality) } + } else { + update { it.copy(fsr1 = Fsr1Preset.Disabled) } + } + } + + /** 滑块档位 → MiB → 配置。关掉缓存只是改配置,不动已有的缓存文件。 */ + fun setGlslCacheSliderPosition(position: Int, ceiling: Int) { + update { it.copy(glslCache = GlslCacheSize.ofMebibytes(GlslCacheScale.mebibytesAt(position, ceiling))) } + } + + fun deleteGlslCache() { + scope.launch { + configStore.clearGlslCache().onFailure { cause -> + snackbar( + context.getString( + R.string.option_glsl_cache_delete_failed, + cause.message ?: cause.javaClass.simpleName, + ), + ) + } + } + } + + // ---- MultiDraw 排序 ---- + + /** 全局排序里把第 [from] 项拖到第 [to] 位。 */ + fun moveMultidrawGlobalItem(from: Int, to: Int) { + update { + val order = it.multidraw.globalOrder.toMutableList() + if (!order.moveItem(from, to)) return@update it + it.copy(multidraw = it.multidraw.withGlobalOrder(order)) + } + } + + fun resetMultidrawGlobalOrder() { + update { it.copy(multidraw = it.multidraw.withGlobalOrder(MultidrawOrderItem.DefaultOrder)) } + } + + fun setMultidrawException(entry: MultidrawEntry, enabled: Boolean) { + update { it.copy(multidraw = it.multidraw.withException(entry, enabled)) } + } + + /** 把某个函数的例外排序退回它的默认值——全局排序在这个函数上的展开。 */ + fun resetMultidrawExceptionOrder(entry: MultidrawEntry) { + update { + it.copy( + multidraw = it.multidraw + .withExceptionOrder(entry, it.multidraw.globalOrderFor(entry)), + ) + } + } + + /** 某函数的例外排序里把第 [from] 项拖到第 [to] 位。 */ + fun moveMultidrawExceptionItem(entry: MultidrawEntry, from: Int, to: Int) { + update { + val order = it.multidraw.effectiveOrderFor(entry).toMutableList() + if (!order.moveItem(from, to)) return@update it + it.copy(multidraw = it.multidraw.withExceptionOrder(entry, order)) + } + } + + /** 拖动排序是「抽出来再插进去」,不是相邻交换——跨多位时两者结果不一样。 */ + private fun MutableList.moveItem(from: Int, to: Int): Boolean { + if (from == to || from !in indices || to !in indices) return false + add(to, removeAt(from)) + return true + } + + // ---- MultiDraw 跑分 ---- + + /** + * 跑分对象:所有函数各测各的,或只测某一个函数。 + * + * 没有「测出一份全局排序」这回事了:全局排序要一个次序同时适配五个函数,而跑分本来就是 + * 分函数测的,硬合成一份反而把每个函数上都不是最优的顺序说成最优。 + */ + sealed interface BenchTarget { + data object AllEntries : BenchTarget + data class Entry(val entry: MultidrawEntry) : BenchTarget + } + + /** + * 跑分流程状态:null(无)→ Running →(Done | Failed),弹窗由 UI 依此渲染。 + * + * [Running.progress] 是 0f..1f;渲染器版本太老、拿不到进度时为 null,UI 退回不定进度条。 + */ + sealed interface BenchState { + data class Running( + val target: BenchTarget, + val progress: Float? = null, + /** 第几次测量(1 起)。抖得厉害时 native 会放大场景重测,最多 [BENCH_MAX_ATTEMPTS] 次。 */ + val attempt: Int = 1, + /** + * 上一趟把 GL 上下文撑爆了,这一趟在更小的场景上从头再来。 + * + * 是「重来」不是「继续」:微秒数随场景规模变,两种规模下的数字摆在一起排名 + * 就是拿两把尺子量。所以崩掉那一趟的结果整份作废。 + */ + val retryingAtSections: Int? = null, + ) : BenchState + + data class Done( + val target: BenchTarget, + /** 每个函数一份排名,按 [MultidrawEntry] 的声明顺序。 */ + val rankings: Map>>, + /** 每个函数各自的成色:测了几轮、抖多少、是不是抖到没法信。 */ + val quality: Map = emptyMap(), + /** 这次测量与 ANGLE 的关系里有值得告诉用户的一句话;null = 一切如预期。 */ + val angleNote: BenchAngleNote? = null, + ) : BenchState { + /** 有函数抖到压不下去,采用这份结果就得用户自己拍板。 */ + val anyNoisy: Boolean get() = quality.values.any { it.noisy } + + /** 测的驱动和游戏要用的不是同一个,这份名次搬过去不成立。 */ + val driverMismatch: Boolean + get() = angleNote == BenchAngleNote.SystemInsteadOfAngle || + angleNote == BenchAngleNote.BorrowFailed + } + + data class Failed(val message: String) : BenchState + } + + /** + * 跑分结束后关于驱动的那句话。三种都不是错误状态——错误走 [BenchState.Failed]—— + * 而是「你以为测的驱动」和「实际测的驱动」之间需要说明的差异。 + */ + enum class BenchAngleNote(@param:StringRes val messageRes: Int) { + /** 配置会让游戏用 ANGLE,而这次(用户自己选的)测的是系统驱动。 */ + SystemInsteadOfAngle(R.string.md_bench_wrong_driver), + + /** 借了 ANGLE 但没加载成(来源损坏、被启动器更新掉了……),实测是系统驱动。 */ + BorrowFailed(R.string.md_bench_borrow_failed), + + /** 设备过不了 ANGLE 探测,借用被渲染器忽略;游戏同样会用系统驱动,结果依然有效。 */ + BorrowUnsupported(R.string.md_bench_borrow_unsupported), + } + + private val mutableBenchState = MutableStateFlow(null) + val benchState: StateFlow = mutableBenchState.asStateFlow() + + /** + * MultiDraw 还是出厂那份顺序——没人调过,也没采用过跑分结果。 + * + * 默认顺序是照着「一般来说什么快」定的,不是在这台设备上量出来的,所以首页可以轻轻 + * 提一句。一旦排序变成非默认(自己拖过,或采用了跑分),这条提示自己就消失了。 + */ + val multidrawUntuned: StateFlow = configStore.config + .map { it != null && it.multidraw == MultidrawSettings.Default } + .stateIn(scope, SharingStarted.Eagerly, false) + + /** 借 ANGLE 是为了哪件事:跑分,还是查 MobileGlues 信息。 */ + sealed interface AngleUse { + data class Bench(val target: BenchTarget) : AngleUse + data object GlInfo : AngleUse + } + + /** + * 该选 ANGLE 来源了。 + * + * 每一次都问,不因为上次信过就替他决定——载入的是另一个应用的原生代码,而那个应用 + * 随时可能被更新成别的东西。上次选的只是排在最前面并标出来,省一次找。 + */ + data class AngleSourcePrompt( + val use: AngleUse, + val sources: List, + val lastChosen: String?, + ) + + private val mutableAngleSourcePrompt = MutableStateFlow(null) + val angleSourcePrompt: StateFlow = mutableAngleSourcePrompt.asStateFlow() + + /** 弹一次来源选择:上次选过的排最前,其余按发现顺序。 */ + private fun promptForAngle(use: AngleUse) { + val last = pluginConfig.angleSourcePackage.value + val found = AngleProvider.sources(context) + mutableAngleSourcePrompt.value = AngleSourcePrompt( + use = use, + sources = found.sortedByDescending { it.packageName == last }, + lastChosen = last, + ) + } + + /** + * 配置最终会用 ANGLE 吗。 + * + * 与 native 的判断保持一致:强制启用就是用,「尽可能启用」要设备支持才用——后者本 App + * 判断不了(那要问 Vulkan 和 GPU 型号),所以按会用来算,宁可多问一次也别测错驱动。 + */ + private fun benchNeedsAngle(): Boolean = when (configStore.config.value?.angle) { + AngleConfig.ForceEnable, AngleConfig.EnableIfPossible -> true + else -> false + } + + /** + * 立即跑分。渲染器被 dlopen 进本进程、在同一块 GPU 上轮流测量每种实现, + * 结束后弹出推荐排序,用户点「采用」才写入配置。 + * + * 配置若要用 ANGLE,得先借到 ANGLE:它随启动器分发,本 App 里没有,而渲染器借不到时 + * 会不声不响退回系统驱动——那样测出来的名次搬进游戏里根本不成立。所以先问用户信任谁。 + * + * native 侧默认花 8 秒把每个候选反复测上几十轮再取中位数,所以这里要边跑边报进度。 + */ + fun runMultidrawBench(target: BenchTarget) { + if (mutableBenchState.value is BenchState.Running) return + if (benchNeedsAngle()) { + promptForAngle(AngleUse.Bench(target)) + return + } + startMultidrawBench(target, null) + } + + /** + * 从 adb 直接跑分,不经过界面。没有任何入口指向它——它存在是为了让一次跑分能被脚本 + * 驱动,而不是靠猜坐标点屏幕。 + * + * adb shell am start -n com.fcl.plugin.mobileglues/.MainActivity \ + * --es mg_bench all # 或某个函数名,见 MultidrawEntry.glFunction + * --es mg_angle borrow # borrow=用记住的那个来源 / system=系统驱动 + * + * 与 [runMultidrawBench] 的区别只有一处:它不弹来源选择框。自动化点不掉对话框,而 + * 这个入口的全部意义就是不需要有人在场。[angle] 为 borrow 时用上次记住的来源,没记住 + * 就退回系统驱动并在结果里如实写明——和用户自己选「用系统驱动测」是同一条路径。 + */ + fun runMultidrawBenchHeadless(entryName: String?, angle: String?) { + if (mutableBenchState.value is BenchState.Running) return + val target = entryName + ?.takeUnless { it.isEmpty() || it.equals("all", ignoreCase = true) } + ?.let { name -> MultidrawEntry.entries.firstOrNull { it.glFunction.equals(name, true) } } + ?.let { BenchTarget.Entry(it) } + ?: BenchTarget.AllEntries + + val borrow = angle == null || angle.equals("borrow", ignoreCase = true) + val dir = if (borrow && benchNeedsAngle()) { + val last = pluginConfig.angleSourcePackage.value + val found = AngleProvider.sources(context) + (found.firstOrNull { it.packageName == last } ?: found.firstOrNull())?.libraryDir + } else { + null + } + startMultidrawBench(target, dir) + } + + /** 用户选定了这一次要信任的来源:记下它(下次排最前),然后把刚才拦下的事接着做。 */ + fun confirmAngleSource(source: AngleSource) { + val pending = mutableAngleSourcePrompt.value ?: return + pluginConfig.setAngleSourcePackage(source.packageName) + mutableAngleSourcePrompt.value = null + resumeWithAngle(pending.use, source.libraryDir) + } + + /** 一个来源都不信(或一个都没有):照样做,但结果会写明这是系统驱动上的。 */ + fun continueWithoutAngle() { + val pending = mutableAngleSourcePrompt.value ?: return + mutableAngleSourcePrompt.value = null + resumeWithAngle(pending.use, null) + } + + fun dismissAngleSourcePrompt() { + mutableAngleSourcePrompt.value = null + } + + private fun resumeWithAngle(use: AngleUse, angleDirectory: String?) { + when (use) { + is AngleUse.Bench -> startMultidrawBench(use.target, angleDirectory) + is AngleUse.GlInfo -> startGlInfo(angleDirectory) + } + } + + private fun startMultidrawBench(target: BenchTarget, angleDirectory: String?) { + if (mutableBenchState.value is BenchState.Running) return + mutableBenchState.value = BenchState.Running(target) + scope.launch { + val directory = app.cacheExporter.export().getOrElse { app.cacheExporter.directory } + val report = runBenchWithBackoff(target, directory.path, angleDirectory) + val rankings = when (target) { + is BenchTarget.AllEntries -> MultidrawEntry.entries + .associateWith { MultidrawBenchAnalyzer.rankEntry(report, it) } + // 一个方案都没测出来的函数没什么可采用的——那份「排名」就是默认顺序本身。 + .filterValues { ranking -> ranking.any { it.relativeCost != null } } + is BenchTarget.Entry -> + mapOf(target.entry to MultidrawBenchAnalyzer.rankEntry(report, target.entry)) + } + mutableBenchState.value = when { + report.error != null -> BenchState.Failed(benchErrorMessage(report.error)) + rankings.isEmpty() -> BenchState.Failed( + context.getString(R.string.md_bench_failed, context.getString(R.string.md_bench_nothing)), + ) + else -> BenchState.Done( + target = target, + rankings = rankings, + quality = report.quality.filterKeys { it in rankings }, + angleNote = benchAngleNote(report, borrowed = angleDirectory != null), + ) + } + } + } + + /** + * 跑一趟;上下文被撑爆就换个更小的场景从头再跑一趟。 + * + * 为什么退让必须发生在这一层,而不是渲染器内部:撑爆的不只是 GL 上下文,还有它底下 + * 那个 VkDevice。同一个进程里再建一个是碰运气,而 [MgQuery] 每次绑定都是一个全新的 + * 查询进程——干净是结构给的,不是驱动给的。 + * + * 退让也一定是「重来」而不是「接着跑」:微秒数随场景规模变,两种规模的数字混在一份 + * 排名里就是拿两把尺子量同一件事。崩掉那一趟的结果整份丢掉。 + * + * 上限只活在这一次点击里,不落盘。设备当时忙不忙、温度高不高都会挪动那条线,把某一 + * 次的坏运气记成这台机器的属性,往后每一次跑分都要替它背着。 + */ + private suspend fun runBenchWithBackoff( + target: BenchTarget, + mgDirectory: String, + angleDirectory: String?, + ): MultidrawBenchReport { + var start = BENCH_START_SECTIONS + var ceiling = 0 // 0 = 还没撞到过天花板 + var report = runBenchOnce(mgDirectory, angleDirectory, start, ceiling) + + var retries = 0 + while (report.error == "context-lost" && retries < BENCH_MAX_BACKOFFS) { + // 渲染器报的是「撑爆时场景多大」。它以下的第一档就是本次的上限,此后不再越过。 + val crashed = report.sections.takeIf { it > 0 } ?: start + val next = crashed / 2 + if (next < BENCH_MIN_SECTIONS) break + + ++retries + start = next + ceiling = next + mutableBenchState.value = BenchState.Running(target, retryingAtSections = next) + report = runBenchOnce(mgDirectory, angleDirectory, start, ceiling) + } + return report + } + + /** 一趟:绑一个查询进程、跑完、解绑(进程随之自杀)。 */ + private suspend fun runBenchOnce( + mgDirectory: String, + angleDirectory: String?, + startSections: Int, + maxSections: Int, + ): MultidrawBenchReport = try { + // 跑在一次性的查询进程里(见 MgQuery):渲染器只在初次载入时读 MG_ANGLE_DIR + // 和配置,本进程里跑第二次就永远是第一次的驱动。binder 调用是阻塞的,占的是 + // IO 线程;查询进程崩了(驱动崩溃)这边收到异常,兜成一份错误报告。 + mgQuery.use { query -> + // coroutineScope 而不是外层的 scope:测量和轮询都是这一趟的孩子,函数 + // 返回时它们必须已经收干净——退让重来会再起一趟,两趟的轮询协程重叠着 + // 写同一个 benchState 就会互相盖掉。 + coroutineScope { + // 这里必须自己吞掉异常。async 一旦失败就立刻把异常抛给父 scope, + // await() 外面的 try/catch 接到的只是副本——父 scope 已经炸了, + // 整个 App 跟着崩。查询进程本来就可能死(驱动在里面崩溃正是它存在 + // 的理由之一),所以失败在这里就变成一份错误报告。 + val measuring = async(Dispatchers.IO) { + runCatching { + MultidrawBenchReport.parse( + query.runBench( + mgDirectory, + angleDirectory.orEmpty(), + startSections, + maxSections, + ), + ) + }.getOrElse { MultidrawBenchReport(emptyMap(), error = queryFailure(it)) } + } + val polling = launch { + while (isActive) { + delay(BENCH_PROGRESS_POLL_MS) + val raw = withContext(Dispatchers.IO) { + runCatching { query.benchProgress() }.getOrDefault(-1) + } + val progress = MGBench.decodeProgress(raw) ?: continue + val running = mutableBenchState.value as? BenchState.Running ?: break + mutableBenchState.value = running.copy( + progress = progress.fraction, + attempt = progress.attempt.coerceIn(1, BENCH_MAX_ATTEMPTS), + ) + } + } + try { + measuring.await() + } finally { + polling.cancel() + } + } + } + } catch (e: Exception) { + MultidrawBenchReport(timings = emptyMap(), error = queryFailure(e)) + } + + /** + * 渲染器报的错翻成人话。 + * + * "context-lost" 是其中最要紧的一个:驱动在测量途中把上下文丢了,之后每次查询都 + * 回零、每次绘制都成空操作,所以那之后的数字是虚构的,而依赖前置条件的方案会因为 + * 读到零而"回退",看上去就像设备不支持。跑分因此整体作废,而不是交出半份结果。 + * + * 走到这里说明退让也没救回来:[runBenchWithBackoff] 已经在更小的场景上重来过了。 + */ + private fun benchErrorMessage(error: String): String = when (error) { + "context-lost" -> context.getString(R.string.md_bench_context_lost) + else -> context.getString(R.string.md_bench_failed, error) + } + + /** + * 查询进程失败时给用户看的那句话。 + * + * DeadObjectException 是最要紧的一种:查询进程没了,几乎总是渲染器在里面崩了。 + * 把它说成人话,而不是把异常类名甩给用户——而且这恰恰是隔离进程挣来的东西, + * 换在以前这一下会把整个 App 带走。 + */ + private fun queryFailure(e: Throwable): String = when (e) { + is android.os.DeadObjectException -> context.getString(R.string.md_bench_process_died) + else -> e.message ?: e.javaClass.simpleName + } + + /** + * 这次测量与 ANGLE 的关系需要说明吗? + * + * 依据是渲染器的自报而不是本 App 的意图:借没借是意图,加载没加载上是事实, + * 两者对不上的时候恰恰是最需要说明的时候。 + */ + private fun benchAngleNote(report: MultidrawBenchReport, borrowed: Boolean): BenchAngleNote? = when { + borrowed && report.angleInUse -> null // 借了也用上了,如预期 + borrowed && report.angleConfigured == AngleConfig.EnableIfPossible.wire && !report.angleSupported -> + BenchAngleNote.BorrowUnsupported + borrowed -> BenchAngleNote.BorrowFailed + report.wrongDriver -> BenchAngleNote.SystemInsteadOfAngle + else -> null + } + + /** + * 采用跑分给出的排序:每个测出结果的函数各自启用例外,写入自己那份顺序。 + * + * 全局排序原样不动——它是「没有单独说法的函数走这里」的兜底,跑分说不了它的话。 + */ + fun adoptBenchResult() { + val done = mutableBenchState.value as? BenchState.Done ?: return + update { config -> + val multidraw = done.rankings.entries.fold(config.multidraw) { settings, (entry, ranking) -> + settings.withExceptionOrder(entry, ranking.map { it.item }) + } + config.copy(multidraw = multidraw) + } + mutableBenchState.value = null + } + + fun dismissBench() { + if (mutableBenchState.value is BenchState.Running) return // 跑分中断没有意义,让它跑完 + mutableBenchState.value = null + } + + // ---- 首页配置摘要 ---- + + /** + * 「ANGLE 尽可能启用 · 缓存 32 MiB」式的一行只读摘要。 + * + * 取值本身(「尽可能启用」「不启用」)离开设置页就没有意义了,所以这里带上是谁的取值; + * GL 版本只在被自定义过的时候才出现——默认那一档说了等于没说。 + */ + fun configSummary(config: MGConfig): String = listOfNotNull( + context.getString(R.string.home_summary_angle, config.angle.label(context)), + config.glVersion.takeIf { it != GlVersion.Default }?.label(context)?.toString(), + context.getString( + R.string.home_summary_cache, + (config.glslCache as? GlslCacheSize.Limited)?.let { + context.getString(R.string.option_glsl_cache_value, it.mebibytes) + } ?: context.getString(R.string.option_glsl_cache_off), + ), + ).joinToString(" · ") + + /** 与滑块保持同一套单位(MiB / KiB),不用 SI 的 MB。 */ + fun formatCacheSize(bytes: Long): String { + val mebibytes = bytes / (1024.0 * 1024.0) + return if (mebibytes >= 1.0) { + context.getString(R.string.option_glsl_cache_size_mib, mebibytes) + } else { + context.getString(R.string.option_glsl_cache_size_kib, bytes / 1024.0) + } + } + + // ---- 外链 ---- + + private val mutableRepoPicker = MutableStateFlow(false) + + /** 三个仓库的选择框。 */ + val repoPicker: StateFlow = mutableRepoPicker.asStateFlow() + + fun openSourceRepositories() { + mutableRepoPicker.value = true + } + + fun dismissRepoPicker() { + mutableRepoPicker.value = false + } + + fun onRepositorySelected(link: LinkEntry) { + openUrl(link.url) + mutableRepoPicker.value = false + } + + fun openThirdPartyComponent(component: ThirdPartyComponent) = openUrl(component.url) + + fun openContributor(contributor: Contributor) = + openUrl("https://github.com/${contributor.login}") + + private fun openUrl(url: String) { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } + } + + val appVersionName: String get() = BuildConfig.VERSION_NAME + + // ---- 赞助弹窗 ---- + + private val mutableSponsorPrompt = MutableStateFlow(null) + val sponsorPrompt: StateFlow = mutableSponsorPrompt.asStateFlow() + + /** + * 首页出现时调用。每个进程最多弹一次;判定见 [SponsorPrompt]。 + * + * 启动次数要去 MG 目录里读(native 库记的),所以未授权时问不出结果——那就先不问, + * 等用户授权之后自然会再走到这里。 + */ + fun maybeShowSponsorPrompt() { + if (app.sponsorPromptedThisProcess) return + if (!pluginConfig.privacyAccepted.value) return + if (pluginConfig.donated.value) return + val storage = auth.state.value.storage ?: return + + scope.launch { + val stats = withContext(Dispatchers.IO) { MgStats.parse(storage.readStats()) } + if (app.sponsorPromptedThisProcess) return@launch + if (!SponsorPrompt.shouldPrompt( + stats.launchCount, + pluginConfig.lastSponsorPromptAt, + pluginConfig.donated.value, + ) + ) { + return@launch + } + app.sponsorPromptedThisProcess = true + // 记在弹出的这一刻,而不是等用户点某个按钮:点了「下次再说」就走人的话, + // 下次启动会立刻又弹一遍。 + pluginConfig.markSponsorPromptedAt(stats.launchCount) + mutableSponsorPrompt.value = SponsorPromptState.Ask(stats.launchCount) + } + } + + private val mutableSponsorPicker = MutableStateFlow(false) + + /** 赞助渠道选择框。信息页和赞助弹窗都开这一个。 */ + val sponsorPicker: StateFlow = mutableSponsorPicker.asStateFlow() + + /** + * 选完渠道之后要不要接着问「已经捐赠了吗」。 + * + * 从信息页点进来的不问——那是用户自己想去看看,不是我们开口要的; + * 从赞助弹窗点进来的才问,那句话是那次询问的下半截。 + */ + private var askAfterPicking = false + + /** 信息页的「赞助我们」。 */ + fun openSponsorChannels() { + askAfterPicking = false + mutableSponsorPicker.value = true + } + + /** 「去支持一下」:先让用户挑渠道,回来再委婉地二次确认。 */ + fun onSponsorDonate() { + askAfterPicking = true + mutableSponsorPrompt.value = null + mutableSponsorPicker.value = true + } + + fun onSponsorChannelSelected(channel: LinkEntry) { + openUrl(channel.url) + mutableSponsorPicker.value = false + if (askAfterPicking) { + askAfterPicking = false + mutableSponsorPrompt.value = SponsorPromptState.Confirm + } + } + + /** 没挑就退出来了,那就什么都没发生——别追着问「捐了吗」。 */ + fun dismissSponsorPicker() { + askAfterPicking = false + mutableSponsorPicker.value = false + } + + fun onSponsorLater() { + mutableSponsorPrompt.value = null + } + + /** 「已捐赠」:记录并永不再弹。 */ + fun onSponsorDonated() { + pluginConfig.markDonated() + mutableSponsorPrompt.value = null + } + + fun onSponsorNotYet() { + mutableSponsorPrompt.value = null + } + + // ---- GL 信息 ---- + + private val mutableGlInfo = MutableStateFlow(null) + val glInfo: StateFlow = mutableGlInfo.asStateFlow() + + private val mutableGlInfoLoading = MutableStateFlow(false) + val glInfoLoading: StateFlow = mutableGlInfoLoading.asStateFlow() + + /** + * 这份 GL 信息是经谁读出来的。 + * + * [Borrowed] 与 [BorrowIneffective] 的分界来自渲染器的自报(信息文本里的 + * "ANGLE in use" 一行),不是「传没传目录」——传了目录而设备不支持、或加载失败时, + * 渲染器照样退回系统驱动,此时页面必须说实话。老渲染器没有这行自报,只好按意图归类。 + */ + enum class GlInfoAngle { + /** 没借,读的就是系统驱动。 */ + System, + + /** 借了,渲染器确认用上了。 */ + Borrowed, + + /** 借了,但没用上(设备不支持或加载失败),实际读的是系统驱动。 */ + BorrowIneffective, + } + + private val mutableGlInfoAngle = MutableStateFlow(GlInfoAngle.System) + + /** 当前这份信息经谁读出。 */ + val glInfoAngle: StateFlow = mutableGlInfoAngle.asStateFlow() + + /** + * 配置要 ANGLE,而当前这份信息是在系统驱动上查的——它讲的不是游戏里的那个驱动。 + * + * 进页面不弹窗:借 ANGLE 是把别的应用的原生代码载进查询进程,这种事不该在用户只想 + * 看一眼信息的时候自己发生。所以先照实查一份、把话说明白,要不要借由用户点。 + * 借过而未生效([GlInfoAngle.BorrowIneffective])不再劝借:再借一次也是同样下场。 + */ + val glInfoNeedsAngle: StateFlow = + combine(glInfo, glInfoAngle, configStore.config) { info, angle, _ -> + info != null && angle == GlInfoAngle.System && benchNeedsAngle() + }.stateIn(scope, SharingStarted.Eagerly, false) + + /** 每次进入 GL 信息页都重新查询(渲染器库可能刚被游戏更新过)。 */ + fun loadGlInfo() = startGlInfo(null) + + /** 「借 ANGLE 重新查一次」:先问信任谁,再查。 */ + fun reloadGlInfoWithAngle() { + if (mutableGlInfoLoading.value) return + promptForAngle(AngleUse.GlInfo) + } + + private fun startGlInfo(angleDirectory: String?) { + if (mutableGlInfoLoading.value) return + mutableGlInfoLoading.value = true + mutableGlInfo.value = null + scope.launch { + val directory = app.cacheExporter.export().getOrElse { app.cacheExporter.directory } + // 一次性查询进程(见 MgQuery):不然本进程里第一次查询的驱动会钉死后面每一次。 + val info = try { + mgQuery.use { query -> + withContext(Dispatchers.IO) { query.glInfo(directory.path, angleDirectory.orEmpty()) } + } + } catch (e: Exception) { + "Error: ${queryFailure(e)}" + } + mutableGlInfo.value = info + mutableGlInfoAngle.value = when { + angleDirectory == null -> GlInfoAngle.System + // 渲染器自报了用没用上,照它说的办。 + info.contains("ANGLE in use: yes") -> GlInfoAngle.Borrowed + info.contains("ANGLE in use:") -> GlInfoAngle.BorrowIneffective + // 老渲染器没有自报,只能按意图归类——历史行为,宁可标成借到。 + else -> GlInfoAngle.Borrowed + } + mutableGlInfoLoading.value = false + } + } + + // ---- 移除 MobileGlues ---- + + private val mutableRemoving = MutableStateFlow(false) + val removing: StateFlow = mutableRemoving.asStateFlow() + + private val mutableFarewell = MutableStateFlow(null) + + /** + * 收尾对话框。 + * + * 撤销和移除都会走到这里:用户刚刚收回了让这个 App 工作所需要的东西,界面继续留着 + * 只会立刻把隐私政策弹窗糊到他脸上。谢一声然后退出,是这时候唯一体面的收场。 + */ + val farewell: StateFlow = mutableFarewell.asStateFlow() + + fun removeMobileGlues() { + mutableResetPrompt.value = false + scope.launch { + if (!confirm( + R.string.remove_mg_files_message, + REMOVE_COOLDOWN_SECONDS, + messageIsHtml = true, + ) + ) { + return@launch + } + mutableRemoving.value = true + val result = withContext(Dispatchers.IO) { + runCatching { + // 没有存储就等于什么都没删掉,不能报「移除完成」。 + val storage = auth.state.value.storage + ?: throw java.io.IOException("MG directory is not authorized") + storage.deleteAll() + // MG 目录之外,本 App 自己也留了东西:查 GL 信息时导出的那份配置副本。 + app.cacheExporter.clear() + } + } + mutableRemoving.value = false + result + .onSuccess { + // revoke 会触发 auth.state 翻转,存储随即被 detach,配置回到未加载。 + auth.revoke() + // 说了删干净就得删干净:授权方式、上次问赞助的次数、是否已捐赠, + // 这些本地偏好一并清掉,回到刚安装的样子。 + pluginConfig.clearAll() + mutableFarewell.value = Farewell.Removed + } + .onFailure { + snackbar( + context.getString( + R.string.remove_failed, + it.message ?: it.javaClass.simpleName, + ), + ) + } + } + } + + // ---- 撤销 / 重置 ---- + + private val mutableResetPrompt = MutableStateFlow(false) + + /** + * 「撤销授权或重置」的选择框。 + * + * 两件事是包含关系而不是并列关系——删文件必然要先有访问权,撤了权限就删不动了—— + * 所以入口只有一个,进去再选做到哪一步,而不是两个会互相锁死的按钮。 + */ + val resetPrompt: StateFlow = mutableResetPrompt.asStateFlow() + + fun openResetPrompt() { + mutableResetPrompt.value = true + } + + fun dismissResetPrompt() { + mutableResetPrompt.value = false + } + + /** + * 把用户给过的两样东西一起收回:存储授权,以及对隐私政策的同意。 + * + * 不删任何文件——MG 目录里的配置原封不动,用户重新同意并授权之后还是老样子。 + * 收回之后隐私政策弹窗会立刻回来:同意是使用这个 App 的前提,撤了就得重新表态。 + */ + fun revokeAuthorization() { + mutableResetPrompt.value = false + scope.launch { + if (!confirm(R.string.warning_revoke_authorization)) return@launch + auth.revoke() + pluginConfig.revokePrivacyAcceptance() + // MG 目录里的文件是用户的,留着;缓存里这份副本是我们自己拷的,权限都收回了就别留了。 + withContext(Dispatchers.IO) { app.cacheExporter.clear() } + mutableFarewell.value = Farewell.Revoked + } + } + + /** 收尾对话框的唯一出口:退出应用。 */ + fun exitAfterFarewell() = launcher.exitApp() + + fun destroy() = scope.cancel() + + companion object { + + const val CUSTOM_GL_VERSION_COOLDOWN_SECONDS = 41 + const val REMOVE_COOLDOWN_SECONDS = 10 + + /** 跑分进度的轮询间隔。native 那边是个原子计数器,问一次几乎不要钱。 */ + private const val BENCH_PROGRESS_POLL_MS = 100L + + /** 与 native 的 BENCH_MAX_ATTEMPTS 对齐:抖得压不下去时最多重测这么多次。 */ + const val BENCH_MAX_ATTEMPTS = 4 + + /** + * 起手的场景规模,与 native 的 BENCH_START_SECTIONS 对齐。 + * + * 每次点击都从这里起步、从这里重新往上探,不记上一次探到哪。 + */ + private const val BENCH_START_SECTIONS = 256 + + /** 小到这个地步还撑爆,问题就不在场景大小上了。对齐 native 的 BENCH_MIN_SECTIONS。 */ + private const val BENCH_MIN_SECTIONS = 32 + + /** + * 最多这样退让几次。 + * + * 天花板不是一条硬线——同一台机器忙起来能提前几百个 section 撞上——所以退一步之后 + * 再崩一次是正常的。三次之后还崩就不是场景大小的事了,报错比接着试更诚实,何况 + * 每一次都要让用户干等十几秒。 + */ + private const val BENCH_MAX_BACKOFFS = 3 + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/AppRoot.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/AppRoot.kt new file mode 100644 index 00000000..9b5c64f2 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/AppRoot.kt @@ -0,0 +1,40 @@ +package com.fcl.plugin.mobileglues.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.settings.UiStyle +import com.fcl.plugin.mobileglues.ui.material.MaterialApp +import com.fcl.plugin.mobileglues.ui.miuix.MiuixApp + +/** + * 根:按「界面风格」选择皮肤。两套皮肤是完整独立的两套界面(不是换肤), + * 但它们吃进同一个 [AppController],所以操作逻辑严格一致。 + */ +@Composable +fun MobileGluesApp(controller: AppController) { + val style by controller.pluginConfig.uiStyle.collectAsStateWithLifecycle() + + // 跑分期间不许自动熄屏。一次跑分要到一分多钟,而全程没有任何触摸——正是系统认定 + // 「用户走开了」的样子。屏幕一灭,测的就不再是游戏里那块 GPU 的状态:合成停了、 + // 频率策略换了,结果既不可比也不可信,而用户回来只看到一份莫名其妙的排名。 + // + // 放在这一层而不是各皮肤的对话框里:跑分状态属于 controller,两套皮肤共用同一份, + // 这件事就不该有两个实现(也就不会有一套忘了做)。 + val benchState by controller.benchState.collectAsStateWithLifecycle() + val view = LocalView.current + val benchRunning = benchState is AppController.BenchState.Running + DisposableEffect(view, benchRunning) { + // View 自己的开关,不碰 Activity 的 window flag,也就不需要为「谁是 Activity」 + // 做上下文回溯;视图不可见时系统自动失效,兜住了跑分中途切走的情形。 + view.keepScreenOn = benchRunning + onDispose { view.keepScreenOn = false } + } + + when (style) { + UiStyle.Material -> MaterialApp(controller) + UiStyle.Miuix -> MiuixApp(controller) + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/Contributors.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/Contributors.kt new file mode 100644 index 00000000..b641b9d0 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/Contributors.kt @@ -0,0 +1,87 @@ +package com.fcl.plugin.mobileglues.ui + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.fcl.plugin.mobileglues.R + +/** + * 一位贡献者。头像随包内置,不是运行时下载的——本应用没有网络权限。 + */ +data class Contributor( + val login: String, + @param:DrawableRes val avatar: Int, +) + +/** 一个仓库的贡献者名单。 */ +data class ContributorGroup( + @param:StringRes val title: Int, + val contributors: List, +) + +/** + * 三个仓库的贡献者,按提交数从多到少。 + * + * 这份名单和头像由 tools/fetch_contributors.py 生成,不要手改:改了下次刷新就没了。 + * 也正因为是生成的,它停在生成的那一刻——把它做成实时的需要 INTERNET 权限, + * 而隐私政策里承诺了没有这个权限,一份致谢名单不值得拿那条承诺去换。 + */ +val ContributorGroups = listOf( + // MobileGlues + ContributorGroup( + R.string.third_party_renderer, + listOf( + Contributor("BZLZHH", R.drawable.avatar_bzlzhh), // 256 + Contributor("Swung0x48", R.drawable.avatar_swung0x48), // 154 + Contributor("Tungstend", R.drawable.avatar_tungstend), // 78 + Contributor("alexytomi", R.drawable.avatar_alexytomi), // 5 + Contributor("youfeng11", R.drawable.avatar_youfeng11), // 2 + Contributor("Ahmet53535353", R.drawable.avatar_ahmet53535353), // 1 + Contributor("crystall1nedev", R.drawable.avatar_crystall1nedev), // 1 + ), + ), + // MobileGlues-plugin + ContributorGroup( + R.string.third_party_plugin, + listOf( + Contributor("BZLZHH", R.drawable.avatar_bzlzhh), // 89 + Contributor("Swung0x48", R.drawable.avatar_swung0x48), // 74 + Contributor("youfeng11", R.drawable.avatar_youfeng11), // 57 + Contributor("Tungstend", R.drawable.avatar_tungstend), // 40 + Contributor("ShirosakiMio", R.drawable.avatar_shirosakimio), // 1 + Contributor("MovTery", R.drawable.avatar_movtery), // 1 + ), + ), + // MobileGlues-release + ContributorGroup( + R.string.repo_release, + listOf( + Contributor("Swung0x48", R.drawable.avatar_swung0x48), // 116 + Contributor("chy2240", R.drawable.avatar_chy2240), // 54 + Contributor("BZLZHH", R.drawable.avatar_bzlzhh), // 34 + Contributor("lgc2333", R.drawable.avatar_lgc2333), // 26 + Contributor("Evsdrg", R.drawable.avatar_evsdrg), // 23 + Contributor("usernotfound999", R.drawable.avatar_usernotfound999), // 20 + Contributor("HappyDIY", R.drawable.avatar_happydiy), // 10 + Contributor("xiaoliyuanpp", R.drawable.avatar_xiaoliyuanpp), // 10 + Contributor("qisumei", R.drawable.avatar_qisumei), // 7 + Contributor("Mo-J8X", R.drawable.avatar_mo_j8x), // 4 + Contributor("ssbtt", R.drawable.avatar_ssbtt), // 3 + Contributor("dontknowhy", R.drawable.avatar_dontknowhy), // 3 + Contributor("TNTyep520", R.drawable.avatar_tntyep520), // 3 + Contributor("Liufeng258", R.drawable.avatar_liufeng258), // 3 + Contributor("ConfectionaryQwQ", R.drawable.avatar_confectionaryqwq), // 3 + Contributor("DHJComical", R.drawable.avatar_dhjcomical), // 3 + Contributor("ApartTUSITU", R.drawable.avatar_aparttusitu), // 3 + Contributor("Milk13995", R.drawable.avatar_milk13995), // 2 + Contributor("Nnkshv", R.drawable.avatar_nnkshv), // 2 + Contributor("Tungstend", R.drawable.avatar_tungstend), // 2 + Contributor("laoliu114514", R.drawable.avatar_laoliu114514), // 2 + Contributor("wyj5211", R.drawable.avatar_wyj5211), // 2 + Contributor("zhongjiening", R.drawable.avatar_zhongjiening), // 2 + Contributor("zheshiqunzhu", R.drawable.avatar_zheshiqunzhu), // 1 + Contributor("EldenThrone588", R.drawable.avatar_eldenthrone588), // 1 + Contributor("leemwood", R.drawable.avatar_leemwood), // 1 + Contributor("yzcthinking", R.drawable.avatar_yzcthinking), // 1 + ), + ), +) diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/DragReorder.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/DragReorder.kt new file mode 100644 index 00000000..d411e589 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/DragReorder.kt @@ -0,0 +1,358 @@ +package com.fcl.plugin.mobileglues.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.abs + +/** + * 拖动排序的运行时状态。 + * + * 列表在 composition 里的物理顺序**始终**是 [DragReorderColumn] 收到的 items 顺序, + * 拖动过程中一行也不重排——重排只发生在松手那一刻。拖动中看到的位移全部是绘制层的 + * translationY:被拖的那行跟手,被它挤开的那些行整体让出一个行高。这样既不用在拖动 + * 途中反复写配置,也避开了非 Lazy 布局里换位必然出现的跳变。 + */ +@Stable +class DragReorderState { + + /** 每行的实测高度(px),下标即物理下标。 */ + private val heights = mutableStateListOf() + + /** 列表容器左上角在窗口里的 y,用来判断手指有没有贴到屏幕边缘。 */ + private var containerTopInWindow by mutableFloatStateOf(0f) + + /** 正在被拖的行;-1 表示没有。 */ + var dragIndex by mutableIntStateOf(-1) + private set + + /** 松手后它会落到的位置。 */ + var targetIndex by mutableIntStateOf(-1) + private set + + /** 被拖那行相对原位的位移(px)。 */ + var dragOffset by mutableFloatStateOf(0f) + private set + + /** 行数,由 composition 同步过来;拖动中的边界判断要用最新值。 */ + var count by mutableIntStateOf(0) + + val isDragging: Boolean get() = dragIndex >= 0 + + // 报告方是 onGloballyPositioned,拖动期间每帧都会来一次;写回相同的值也算改动, + // 会把整列拖进重组,所以变了才写。 + fun setHeight(index: Int, height: Int) { + while (heights.size <= index) heights.add(0) + if (heights[index] != height) heights[index] = height + } + + fun setContainerTop(y: Float) { + containerTopInWindow = y + } + + private fun heightOf(index: Int): Int = heights.getOrElse(index) { 0 } + + /** 第 [index] 行在未发生任何位移时的顶边。 */ + private fun topOf(index: Int): Float { + var sum = 0f + for (i in 0 until minOf(index, heights.size)) sum += heights[i] + return sum + } + + /** 第 [index] 行当前的绘制位移:让开被拖行的那些行整体挪一个行高。 */ + fun displacementOf(index: Int): Float { + if (!isDragging || index == dragIndex) return 0f + val dragHeight = heightOf(dragIndex).toFloat() + return when { + targetIndex > dragIndex && index in (dragIndex + 1)..targetIndex -> -dragHeight + targetIndex < dragIndex && index in targetIndex..(dragIndex - 1) -> dragHeight + else -> 0f + } + } + + private fun visualTopOf(index: Int): Float = topOf(index) + displacementOf(index) + + /** + * 开始拖第 [index] 行;上一次的落位动画还没走完时返回 false。 + * + * 落位动画只有一两百毫秒,中途抢跑得先把上一次提交掉,而提交会当场换掉行的顺序, + * 新拖动手里的下标就对不上了。宁可放过这次触摸。 + */ + fun tryStartDrag(index: Int): Boolean { + if (isDragging) return false + dragIndex = index + targetIndex = index + dragOffset = 0f + return true + } + + /** + * 手指移动 [delta] px 后重算落点。 + * + * 判据是被拖行的中心压进了相邻那行的地界——盖住对方一半就换,拖满一整行才换会很迟钝。 + * 相邻行取的是它**当前看到的**位置(已经让开过的要算上位移),否则一口气跨好几行时 + * 阈值会一格格错开。返回 true 表示落点变了,调用方可以借此给一下振动。 + */ + fun onDrag(delta: Float): Boolean { + dragOffset += delta + val before = targetIndex + val center = topOf(dragIndex) + dragOffset + heightOf(dragIndex) / 2f + while (targetIndex < count - 1) { + val over = if (targetIndex >= dragIndex) targetIndex + 1 else targetIndex + if (center <= visualTopOf(over)) break + targetIndex++ + } + while (targetIndex > 0) { + val over = if (targetIndex > dragIndex) targetIndex else targetIndex - 1 + if (center >= visualTopOf(over) + heightOf(over)) break + targetIndex-- + } + return targetIndex != before + } + + /** 被拖行中心此刻在窗口里的 y,供边缘自动滚动使用。 */ + fun draggedCenterInWindow(): Float = + containerTopInWindow + topOf(dragIndex) + dragOffset + heightOf(dragIndex) / 2f + + /** 自动滚动消耗掉的距离要补进位移里,手指没动,行相对内容却确实挪了。 */ + fun onAutoScrolled(consumed: Float) { + onDrag(consumed) + } + + /** 松手动画专用:只挪绘制位置,落点已经定下来了,不再跟着重算。 */ + fun setOffset(offset: Float) { + dragOffset = offset + } + + /** 被拖行落到 [targetIndex] 时应有的位移——松手动画的终点。 */ + fun settleOffset(): Float { + val from = dragIndex + val to = targetIndex + return when { + to > from -> topOf(to) + heightOf(to) - topOf(from) - heightOf(from) + to < from -> topOf(to) - topOf(from) + else -> 0f + } + } + + fun finish() { + dragIndex = -1 + targetIndex = -1 + dragOffset = 0f + } +} + +/** + * 一列可以按住手柄上下拖动来重排的行。 + * + * [row] 收到的 handle 必须挂到行内某个抓手上(那个 ☰),拖动只从它开始—— + * 整行可拖会和外层设置页的竖向滚动打架。[onMove] 在松手时调用一次,参数是物理下标。 + */ +@Composable +fun DragReorderColumn( + items: List, + onMove: (from: Int, to: Int) -> Unit, + modifier: Modifier = Modifier, + row: @Composable (index: Int, item: T, dragging: Boolean, handle: Modifier) -> Unit, +) { + val state = remember { DragReorderState() } + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + val windowHeight = LocalWindowInfo.current.containerSize.height.toFloat() + + // 松手后到新顺序从配置那边绕回来之前,先照我们自己算出的顺序渲染。 + // 手势是一次性捕获的,读 items / onMove 都得走 rememberUpdatedState 才拿得到当前值。 + val pending = remember { mutableStateOf?>(null) } + val latestItems = rememberUpdatedState(items) + val latestOnMove = rememberUpdatedState(onMove) + val shown = pending.value ?: items + + SideEffect { state.count = shown.size } + + // 贴边自动滚动:离边越近滚得越快。滚的是外面那个设置页,但这里不去拿它的 + // ScrollState——把滚动量当作嵌套滚动事件抛出去,谁在滚谁接住,控件本身不用知道。 + val dispatcher = remember { NestedScrollDispatcher() } + val noopConnection = remember { object : NestedScrollConnection {} } + val edge = with(density) { 88.dp.toPx() } + val maxStep = with(density) { 9.dp.toPx() } + LaunchedEffect(state.isDragging) { + if (!state.isDragging) return@LaunchedEffect + while (true) { + withFrameNanos { } + val y = state.draggedCenterInWindow() + val step = when { + y < edge -> -(edge - y) / edge * maxStep + y > windowHeight - edge -> (y - (windowHeight - edge)) / edge * maxStep + else -> 0f + } + if (abs(step) <= 0.5f) continue + // 嵌套滚动的 y 是手指方向:内容要上滚,手指就是往上走。 + val consumed = dispatcher.dispatchPostScroll( + consumed = Offset.Zero, + available = Offset(0f, -step), + source = NestedScrollSource.UserInput, + ) + state.onAutoScrolled(-consumed.y) + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .nestedScroll(noopConnection, dispatcher) + .onGloballyPositioned { state.setContainerTop(it.positionInWindow().y) }, + ) { + shown.forEachIndexed { index, item -> + // 按 item 认人,不按位置。Column 默认是位置复用,行一换位,「被拖起来」那套 + // 动效(底色、阴影)的动画状态就留在原来的位置上淡出,而 item 已经走了。 + // 这里的 items 是一个排列,item 本身就是唯一的 key。 + key(item) { + val dragging = state.dragIndex == index + val displacement by animateFloatAsState( + targetValue = state.displacementOf(index), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "reorder-displacement", + ) + Box( + modifier = Modifier + .zIndex(if (dragging) 1f else 0f) + .graphicsLayer { + // 让位的位移只在拖动期间读。松手那一帧行已经换到新位置了, + // 这时候还去读正在往回走的动画值,看着就是闪一下。 + translationY = when { + dragging -> state.dragOffset + state.isDragging -> displacement + else -> 0f + } + } + // onSizeChanged 在这里不够:认了 item 之后,行换位而尺寸不变就不会 + // 回调,heights 会一直按旧位置记着。位置变了也得重新报一次。 + .onGloballyPositioned { state.setHeight(index, it.size.height) }, + ) { + val handle = Modifier.pointerInput(state, index) { + detectDragGestures( + onDragStart = { + if (state.tryStartDrag(index)) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + }, + onDrag = { change, amount -> + if (state.dragIndex == index) { + change.consume() + if (state.onDrag(amount.y)) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + }, + onDragEnd = { + if (state.dragIndex == index) { + scope.launch { settle(state, haptic, latestItems, latestOnMove, pending) } + } + }, + onDragCancel = { + if (state.dragIndex == index) { + scope.launch { settle(state, haptic, latestItems, latestOnMove, pending) } + } + }, + ) + } + row(index, item, dragging, handle) + } + } + } + } +} + +/** + * 松手:先把被拖的行滑到落点,滑到位了再换位。 + * + * 顺序反过来的话(先换位再收动画)会跳一下——动画结束时的绘制位置正好等于换位后的 + * 布局位置,这一帧交接才是无缝的。 + * + * 换位却不能等配置绕回来:[onMove] 写进 config,新顺序要经 StateFlow 再经 + * collectAsStateWithLifecycle 才回到 composition,中间那一两帧是「旧顺序 + 位移已清零」, + * 行会先弹回原位再跳到新位置。所以这里自己先把重排后的列表摆出来,等真的顺序到手了再让位。 + */ +private suspend fun settle( + state: DragReorderState, + haptic: HapticFeedback, + items: State>, + onMove: State<(from: Int, to: Int) -> Unit>, + pending: MutableState?>, +) { + if (!state.isDragging) return + val from = state.dragIndex + val to = state.targetIndex + animate( + initialValue = state.dragOffset, + targetValue = state.settleOffset(), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) { value, _ -> state.setOffset(value) } + + val current = pending.value ?: items.value + if (from == to || from !in current.indices || to !in current.indices) { + state.finish() + return + } + + // 这两行必须挨着:换位和清零位移落在同一帧,画面才不会闪。 + val reordered = current.toMutableList().apply { add(to, removeAt(from)) } + pending.value = reordered + state.finish() + + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMove.value(from, to) + + // 配置若是拒了这次改动(顺序原样传回来),超时之后照配置说的显示,别一直挂着。 + withTimeoutOrNull(REORDER_COMMIT_TIMEOUT_MS) { + snapshotFlow { items.value }.first { it == reordered } + } + pending.value = null +} + +private const val REORDER_COMMIT_TIMEOUT_MS = 250L diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/Links.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/Links.kt new file mode 100644 index 00000000..c80e1f3c --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/Links.kt @@ -0,0 +1,132 @@ +package com.fcl.plugin.mobileglues.ui + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.fcl.plugin.mobileglues.R + +/** + * 一条外链。 + * + * [label] 说这是什么,[url] 原样展示——用户点下去之前有权知道要去哪里。 + */ +data class LinkEntry(val label: String, val url: String) + +/** + * 赞助渠道。 + * + * 项目账号排在最前,然后按 [R.string.info_author] 的顺序列出三位开发者各自的收款页—— + * 这是一份名单,不是一个排行榜,顺序就该和署名一致。爱发电有三个域名在用、收款方各不 + * 相同,所以网址必须写出来。 + */ +val SponsorChannels = listOf( + LinkEntry("爱发电 · MobileGlues", "https://afdian.com/a/MobileGlues"), + LinkEntry("Buy Me a Coffee · Swung", "https://www.buymeacoffee.com/Swung0x48"), + LinkEntry("爱发电 · BZLZHH", "https://www.ifdian.net/a/bzlzhh"), + LinkEntry("爱发电 · Tungsten", "https://afdian.net/a/tungs"), +) + +/** 三个仓库:发行、渲染器本体、以及你正在用的这个插件。 */ +@Composable +fun sourceRepositories(): List = listOf( + LinkEntry( + stringResource(R.string.repo_release), + "https://github.com/MobileGL-Dev/MobileGlues-release", + ), + LinkEntry( + stringResource(R.string.repo_renderer), + "https://github.com/MobileGL-Dev/MobileGlues", + ), + LinkEntry( + stringResource(R.string.repo_plugin), + "https://github.com/MobileGL-Dev/MobileGlues-plugin", + ), +) + +/** + * 一个第三方开源组件。 + * + * [license] 用许可证本来的英文名字,不翻译:那是法律文件的名称,译名不具备同等效力。 + */ +data class ThirdPartyComponent( + val name: String, + val author: String, + val license: String, + val url: String, +) + +/** 第三方组件分成两组:渲染器用的,和这个插件用的。 */ +data class ThirdPartyGroup( + @param:StringRes val title: Int, + val components: List, +) + +/** + * 用到的全部第三方开源项目。 + * + * 分成「渲染器」和「插件」两组,因为它们是两个仓库、两套构建:用户看到 SPIRV-Cross + * 的时候,应该同时知道它是被游戏里那个 .so 用的,而不是被这个设置界面用的。 + */ +val ThirdPartyGroups = listOf( + ThirdPartyGroup( + R.string.third_party_renderer, + listOf( + ThirdPartyComponent( + "SPIRV-Cross", "KhronosGroup", "Apache License 2.0", + "https://github.com/KhronosGroup/SPIRV-Cross", + ), + ThirdPartyComponent( + "glslang", "KhronosGroup", "Various Licenses", + "https://github.com/KhronosGroup/glslang", + ), + ThirdPartyComponent( + "cJSON", "DaveGamble", "MIT License", + "https://github.com/DaveGamble/cJSON", + ), + ThirdPartyComponent( + "FidelityFX-FSR", "AMD", "MIT License", + "https://github.com/GPUOpen-Effects/FidelityFX-FSR", + ), + ThirdPartyComponent( + "Perfetto", "Google", "Apache License 2.0", + "https://github.com/google/perfetto", + ), + ThirdPartyComponent( + "xxHash", "Yann Collet", "BSD 2-Clause License", + "https://github.com/Cyan4973/xxHash", + ), + // 用的是 MobileGL-Dev 的 fork:上游 2018 年起无人维护,而它的素数表 + // 是个 size_t[],其中三分之二大于 2^32,导致 32 位构建连头文件都包不进来。 + // 署名仍归原作者,链接指向实际编译进去的那一份。 + ThirdPartyComponent( + "flat_hash_map", "Malte Skarupke", "Boost Software License 1.0", + "https://github.com/MobileGL-Dev/flat_hash_map", + ), + ), + ), + ThirdPartyGroup( + R.string.third_party_plugin, + listOf( + ThirdPartyComponent( + "Jetpack Compose", "Android Open Source Project", "Apache License 2.0", + "https://developer.android.com/jetpack/compose", + ), + ThirdPartyComponent( + "AndroidX", "Android Open Source Project", "Apache License 2.0", + "https://developer.android.com/jetpack/androidx", + ), + ThirdPartyComponent( + "Miuix", "compose-miuix-ui", "Apache License 2.0", + "https://github.com/compose-miuix-ui/miuix", + ), + ThirdPartyComponent( + "kotlinx.coroutines", "JetBrains", "Apache License 2.0", + "https://github.com/Kotlin/kotlinx.coroutines", + ), + ThirdPartyComponent( + "Gson", "Google", "Apache License 2.0", + "https://github.com/google/gson", + ), + ), + ), +) diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/PrivacyPolicy.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/PrivacyPolicy.kt new file mode 100644 index 00000000..eb50f301 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/PrivacyPolicy.kt @@ -0,0 +1,24 @@ +package com.fcl.plugin.mobileglues.ui + +import androidx.annotation.StringRes +import com.fcl.plugin.mobileglues.R + +/** 隐私政策的一节。 */ +data class PrivacySection( + @param:StringRes val title: Int, + @param:StringRes val body: Int, +) + +/** + * 隐私政策全文的条目。 + * + * 首启的同意弹窗和信息页里的隐私政策页读的是同一份:用户点「同意」时看到的, + * 必须就是他事后能翻回去看的那些字,一字不差。 + */ +val PrivacySections = listOf( + PrivacySection(R.string.privacy_files_title, R.string.privacy_files_body), + PrivacySection(R.string.privacy_permission_title, R.string.privacy_permission_body), + PrivacySection(R.string.privacy_local_title, R.string.privacy_local_body), + PrivacySection(R.string.privacy_links_title, R.string.privacy_links_body), + PrivacySection(R.string.privacy_network_title, R.string.privacy_network_body), +) diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/Responsive.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/Responsive.kt new file mode 100644 index 00000000..4b8f03ab --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/Responsive.kt @@ -0,0 +1,49 @@ +package com.fcl.plugin.mobileglues.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * 横屏与竖屏的差别,在这个 App 里只体现为一件事:垂直方向变得很紧。 + * + * 所以这里量的是高度而不是问朝向。一台折叠屏展开后是宽的,但高度依然充裕,它不该被当成 + * 横屏对待;而一台普通手机横过来只剩三百多 dp,无论朝向枚举怎么说,它就是紧。 + */ +object Responsive { + + /** 低于这个高度就按「竖直方向紧张」处理:导航移到侧边,弹窗把内容区压到能放下按钮。 */ + private val CompactHeight = 480.dp + + /** 正文超过这个宽度就不再拉长:一行几十个字读起来比一行一百个字轻松。 */ + val MaxContentWidth = 720.dp + + @Composable + @ReadOnlyComposable + fun isHeightCompact(): Boolean = LocalConfiguration.current.screenHeightDp.dp < CompactHeight + + /** + * 弹窗里那块可滚动内容最多能有多高。 + * + * 之前这里写死 420.dp。竖屏下它小于可用高度,看不出问题;横屏可用高度只有三百多 dp, + * 内容区自己就要 420,标题和按钮被挤出屏幕——用户看得到文字却按不到「确定」。 + * + * 现在从真实窗口高度减去要留给标题、按钮和系统栏的余量,再夹在一个下限上:内容再长也 + * 至少给出一屏可读的量,而按钮永远在。 + */ + @Composable + fun dialogMaxContentHeight(): Dp { + val insets = WindowInsets.safeDrawing.asPaddingValues() + val available = LocalConfiguration.current.screenHeightDp.dp - + insets.calculateTopPadding() - insets.calculateBottomPadding() + // 标题 + 按钮 + 弹窗自身的内外边距,实测这一圈在两套皮肤上都不超过 200dp。 + val reserved = 200.dp + val cap = available - reserved + return if (cap < 160.dp) 160.dp else cap + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialApp.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialApp.kt new file mode 100644 index 00000000..b8bd5f31 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialApp.kt @@ -0,0 +1,216 @@ +package com.fcl.plugin.mobileglues.ui.material + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import com.fcl.plugin.mobileglues.ui.Responsive +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +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.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.AppSubPage +import com.fcl.plugin.mobileglues.ui.AppTab + +/** + * MD3 皮肤的外壳:底部导航三页 + 子页面,全部对话框挂在这一层。 + * + * 页面本身不持有任何状态——它们从 [AppController] 读,把操作回调回去, + * 所以和 Miuix 皮肤看到的是同一份真相。 + */ +@Composable +fun MaterialApp(controller: AppController) { + MgMaterialTheme { + val tab by controller.tab.collectAsStateWithLifecycle() + val subPage by controller.subPage.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(controller) { + controller.snackbar.collect { snackbarHostState.showSnackbar(it.toString()) } + } + + // 换了 GLES 驱动,手上那份排序是在旧驱动上量的。用 snackbar 而不是对话框: + // 这只是句提醒,用户正忙着调设置,不该被拦下来。 + val outdatedMessage = stringResource(R.string.md_bench_outdated) + val outdatedAction = stringResource(R.string.md_bench_outdated_action) + LaunchedEffect(controller) { + controller.benchOutdated.collect { + val result = snackbarHostState.showSnackbar( + message = outdatedMessage, + actionLabel = outdatedAction, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + controller.runMultidrawBench(AppController.BenchTarget.AllEntries) + } + } + } + + // 子页面吃掉返回键;没有子页面时交还系统(退出应用)。 + BackHandler(enabled = subPage != null) { controller.navigateBack() } + + // 垂直方向紧张(通常就是手机横屏)时,导航从底部让到侧边:底栏在横屏吃掉的是 + // 本来就稀缺的高度,而左侧的宽度反而有富余。判断的是高度而不是朝向,理由见 + // Responsive。 + val heightCompact = Responsive.isHeightCompact() + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + contentWindowInsets = WindowInsets.safeDrawing, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + AnimatedVisibility( + visible = subPage == null && !heightCompact, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + ) { + MaterialNavigationBar(current = tab, onSelect = controller::navigateTab) + } + }, + ) { innerPadding -> + Row(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + AnimatedVisibility( + visible = subPage == null && heightCompact, + enter = slideInHorizontally { -it } + fadeIn(), + exit = slideOutHorizontally { -it } + fadeOut(), + ) { + MaterialNavigationRail(current = tab, onSelect = controller::navigateTab) + } + // 每页的滚动位置各自存一份:从子页面退回来时,列表还停在原处。 + val pageState = rememberSaveableStateHolder() + AnimatedContent( + targetState = subPage ?: tab, + transitionSpec = { pageTransition(initialState, targetState) }, + modifier = Modifier.fillMaxSize(), + label = "page", + ) { destination -> + pageState.SaveableStateProvider(destination) { + Column(modifier = Modifier.fillMaxSize()) { + when (destination) { + AppTab.Home -> MaterialHomePage(controller) + AppTab.Settings -> MaterialSettingsPage(controller) + AppTab.Info -> MaterialInfoPage(controller) + AppSubPage.GlInfo -> MaterialGlInfoPage(controller) + AppSubPage.Privacy -> MaterialPrivacyPage(controller) + AppSubPage.ThirdParty -> MaterialThirdPartyPage(controller) + } + } + } + } + } + } + } + + MaterialDialogHost(controller) + // 跑分可以从主页的提示、设置页的按钮、切驱动后的 snackbar 三处发起, + // 对话框因此挂在这一层,而不是某一页里。 + MultidrawBenchDialogs(controller) + } +} + +@Composable +private fun MaterialNavigationBar(current: AppTab, onSelect: (AppTab) -> Unit) { + NavigationBar(modifier = Modifier.fillMaxWidth()) { + NavigationDestinations.forEach { (destination, icon, label) -> + NavigationBarItem( + selected = current == destination, + onClick = { onSelect(destination) }, + icon = { + Icon(painter = painterResource(icon), contentDescription = stringResource(label)) + }, + label = { Text(stringResource(label)) }, + ) + } + } +} + +/** 底栏的侧边形态,条目与顺序同一份声明——两种形态永远不会各说各话。 */ +@Composable +private fun MaterialNavigationRail(current: AppTab, onSelect: (AppTab) -> Unit) { + NavigationRail { + // Rail 默认从顶部排;底栏的条目是水平居中的,侧边形态没理由不垂直居中。 + Spacer(Modifier.weight(1f)) + NavigationDestinations.forEach { (destination, icon, label) -> + NavigationRailItem( + selected = current == destination, + onClick = { onSelect(destination) }, + icon = { + Icon(painter = painterResource(icon), contentDescription = stringResource(label)) + }, + label = { Text(stringResource(label)) }, + ) + } + Spacer(Modifier.weight(1f)) + } +} + +private data class NavigationDestination(val tab: AppTab, val icon: Int, val label: Int) + +private val NavigationDestinations = listOf( + NavigationDestination(AppTab.Home, R.drawable.ic_home, R.string.nav_home), + NavigationDestination(AppTab.Settings, R.drawable.ic_settings, R.string.nav_settings), + NavigationDestination(AppTab.Info, R.drawable.ic_info, R.string.nav_info), +) + +/** + * 过场动画:同级切页是「淡入淡出 + 顺着方向的小位移」,进出子页面是「从右侧推入」。 + * 方向取自底部导航的顺序,所以位移方向和用户的手指方向一致。 + */ +private fun pageTransition(from: Any, to: Any): ContentTransform { + val fadeInSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing) + val fadeOutSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing) + val slide = tween(durationMillis = 320, easing = FastOutSlowInEasing) + + // 位移是屏宽的几分之一:进出子页面推得多一点,同级切页只要一点点。 + val (enterFraction, exitFraction) = when { + to is AppSubPage -> 3 to -10 + from is AppSubPage -> -10 to 3 + from is AppTab && to is AppTab && to.ordinal > from.ordinal -> 6 to -6 + else -> -6 to 6 + } + + return ContentTransform( + targetContentEnter = slideInHorizontally(slide) { it / enterFraction } + fadeIn(fadeInSpec), + initialContentExit = slideOutHorizontally(slide) { it / exitFraction } + fadeOut(fadeOutSpec), + // 两页高度不同时不要裁剪,否则退场那一页会被切掉一块。 + sizeTransform = SizeTransform(clip = false), + ) +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialComponents.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialComponents.kt new file mode 100644 index 00000000..b63894cb --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialComponents.kt @@ -0,0 +1,493 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package com.fcl.plugin.mobileglues.ui.material + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.ConfirmRequest +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +/** 页面左右留白。三个页面用同一个值,滚动时各组卡片的边缘才对得齐。 */ +val ScreenPadding = 16.dp + +/** MD3 皮肤主题:Android 12+ 用动态取色(Material You),以下回落到默认色板。 */ +@Composable +fun MgMaterialTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + + darkTheme -> darkColorScheme() + else -> lightColorScheme() + } + MaterialTheme(colorScheme = colorScheme, content = content) +} + +/** + * 解析含 HTML 的警告文案。`@colorError` 是作者写在 strings.xml 里的占位符, + * 在这里换成当前皮肤的 error 色——同一套文案两个皮肤各自上色。 + */ +@Composable +fun rememberHtmlAnnotatedString(html: String, errorColor: Color): AnnotatedString { + return remember(html, errorColor) { + val hex = String.format("#%06X", 0xFFFFFF and errorColor.toArgb()) + AnnotatedString.fromHtml(html.replace("@colorError", hex)) + } +} + +/** + * 「先问再改」的确认对话框,可选倒计时(确认键读秒,结束后变警示色)。 + * 返回键算「否」,点遮罩不关闭——和旧界面的语义一致。 + */ +@Composable +fun MgConfirmDialog(request: ConfirmRequest) { + var secondsLeft by remember(request) { mutableIntStateOf(request.countdownSeconds) } + LaunchedEffect(request) { + while (secondsLeft > 0) { + delay(1000) + secondsLeft-- + } + } + + val counting = secondsLeft > 0 + AlertDialog( + onDismissRequest = { request.resolve(false) }, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), + title = { Text(stringResource(request.titleRes)) }, + text = { + // 警告文案可能很长(移除 MobileGlues、自定义 GL 版本),对话框内部要能滚。 + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + if (request.messageIsHtml) { + Text(rememberHtmlAnnotatedString(request.message, MaterialTheme.colorScheme.error)) + } else { + Text(request.message) + } + } + }, + confirmButton = { + TextButton(onClick = { request.resolve(true) }, enabled = !counting) { + Text( + text = if (counting) { + stringResource(R.string.ok_with_countdown, secondsLeft) + } else { + stringResource(request.positiveRes) + }, + color = if (request.errorAccent && !counting) { + MaterialTheme.colorScheme.error + } else { + Color.Unspecified + }, + ) + } + }, + dismissButton = { + TextButton(onClick = { request.resolve(false) }) { + Text(stringResource(R.string.dialog_negative)) + } + }, + ) +} + +/** 通用标题 + 文案对话框。 */ +@Composable +fun MgTextDialog( + title: String, + text: String, + positive: String, + onPositive: () -> Unit, + negative: String? = null, + onNegative: (() -> Unit)? = null, + onDismiss: () -> Unit = {}, + cancelable: Boolean = true, +) { + AlertDialog( + onDismissRequest = { if (cancelable) onDismiss() }, + properties = DialogProperties( + dismissOnBackPress = cancelable, + dismissOnClickOutside = cancelable, + ), + title = { Text(title) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text(text) } + }, + confirmButton = { TextButton(onClick = onPositive) { Text(positive) } }, + dismissButton = if (negative != null) { + { TextButton(onClick = { onNegative?.invoke() }) { Text(negative) } } + } else { + null + }, + ) +} + +/** 页面大标题。三个页面共用,代替系统 ActionBar。 */ +@Composable +fun PageTitle(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier.padding(start = ScreenPadding + 8.dp, top = 24.dp, bottom = 8.dp), + ) +} + +/** 设置分组:小节标题 + 一张圆角卡片,卡片里是若干行。 */ +@Composable +fun PreferenceGroup( + title: String? = null, + modifier: Modifier = Modifier, + // 分组标签用主色;文档式的小标题(隐私政策)传 onSurface,那不是一组控件的名字。 + titleColor: Color = MaterialTheme.colorScheme.primary, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth().padding(horizontal = ScreenPadding)) { + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = titleColor, + modifier = Modifier.padding(start = 12.dp, top = 20.dp, bottom = 8.dp), + ) + } + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), content = content) + } + } +} + +/** + * 一行设置:标题 +(可选)副标题,尾部可放图标。 + * + * 副标题在标题下方而不是行尾:本 App 的选项名普遍很长(「Enable Incomplete + * 'ARB_compute_shader' Extension」),挤在一行里两边都会被截断。 + */ +@Composable +fun TextPreferenceRow( + title: String, + summary: String? = null, + enabled: Boolean = true, + titleColor: Color = Color.Unspecified, + trailing: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled && onClick != null) { onClick?.invoke() } + .padding(horizontal = 20.dp, vertical = 14.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = DisabledAlpha) + titleColor != Color.Unspecified -> titleColor + else -> MaterialTheme.colorScheme.onSurface + }, + ) + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = if (enabled) 1f else DisabledAlpha), + modifier = Modifier.padding(top = 2.dp), + ) + } + } + if (trailing != null) { + Spacer(Modifier.size(12.dp)) + trailing() + } + } +} + +/** 一行开关设置。 */ +@Composable +fun SwitchPreferenceRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + summary: String? = null, + enabled: Boolean = true, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { onCheckedChange(!checked) } + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = DisabledAlpha) + }, + ) + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + Spacer(Modifier.size(12.dp)) + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + } +} + +/** 一行滑块设置:标题 + 当前值 + 滑块。取值是「档位」,换算由调用方负责。 */ +@Composable +fun SliderPreferenceRow( + title: String, + valueLabel: String, + position: Int, + steps: Int, + onPositionChange: (Int) -> Unit, + onDragFinished: () -> Unit = {}, +) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + text = valueLabel, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 12.dp), + ) + } + Slider( + value = position.toFloat(), + onValueChange = { onPositionChange(it.roundToInt()) }, + onValueChangeFinished = onDragFinished, + valueRange = 0f..steps.toFloat(), + // 档位太密,画刻度点只会糊成一条线:连续拖动、落点取整。 + steps = 0, + ) + } +} + +/** 可展开的一节:点标题行展开,内容带高度动画。 */ +@Composable +fun ExpandableSection( + title: String, + summary: String, + expanded: Boolean, + onToggle: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "chevron") + Column(modifier = Modifier.fillMaxWidth()) { + TextPreferenceRow( + title = title, + summary = summary, + onClick = onToggle, + trailing = { + Icon( + painter = painterResource(R.drawable.ic_expand_more), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(rotation), + ) + }, + ) + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(modifier = Modifier.fillMaxWidth(), content = content) + } + } +} + +/** 分段控件(界面风格切换)。 */ +@Composable +fun SegmentedChoiceRow( + title: String, + options: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 10.dp)) { + options.forEachIndexed { index, label -> + SegmentedButton( + selected = index == selectedIndex, + onClick = { onSelect(index) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + ) { + Text(label, maxLines = 1) + } + } + } + } +} + +/** 单选对话框:Spinner 的 Compose 替代。 */ +@Composable +fun SingleChoiceDialog( + title: String, + options: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .selectableGroup(), + ) { + options.forEachIndexed { index, label -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onSelect(index) + onDismiss() + } + .padding(vertical = 10.dp), + ) { + RadioButton(selected = index == selectedIndex, onClick = null) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_negative)) } + }, + ) +} + +/** 整页居中的加载指示。 */ +@Composable +fun CenteredLoading(text: String? = null, modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier.fillMaxWidth().padding(32.dp), + ) { + CircularProgressIndicator() + if (text != null) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp), + ) + } + } +} + +/** 可选中的整段文本(GL 信息)。 */ +@Composable +fun SelectableBody(text: String, modifier: Modifier = Modifier) { + SelectionContainer(modifier = modifier) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** 页面底部留白:让最后一张卡片不会紧贴导航栏。 */ +@Composable +fun BottomSpacer() { + Spacer(Modifier.height(24.dp)) +} + +/** MD3 规范里「禁用」态的内容透明度。 */ +const val DisabledAlpha = 0.38f diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialContributors.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialContributors.kt new file mode 100644 index 00000000..e90cbded --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialContributors.kt @@ -0,0 +1,144 @@ +@file:OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + +package com.fcl.plugin.mobileglues.ui.material + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.Contributor +import com.fcl.plugin.mobileglues.ui.ContributorGroups +import kotlin.math.max + +/** + * 贡献者致谢:三个仓库各一面头像墙。 + * + * 头像是随包内置的,不是运行时拉的——本应用没有 INTERNET 权限,而那是隐私政策里 + * 请用户自己去核实的一条。名单因此停在打包的那一刻,这个代价比作废那条承诺小得多。 + */ +@Composable +fun MaterialContributorsSection(controller: AppController) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.contributors_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding( + start = ScreenPadding + 12.dp, + top = 20.dp, + bottom = 4.dp, + ), + ) + Text( + text = stringResource(R.string.contributors_intro), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = ScreenPadding + 12.dp), + ) + + ContributorGroups.forEach { group -> + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = ScreenPadding + 12.dp, + top = 16.dp, + bottom = 8.dp, + ), + ) + // The columns are measured rather than fixed. A fixed cell width + // leaves whatever does not divide evenly at the end of the row, and + // FlowRow puts all of it on the right -- at some densities that is + // nearly a whole column of empty space on one side and none on the + // other. Fitting as many cells of at least FaceMinWidth as the width + // allows and then sharing the remainder among them keeps both edges + // flush, keeps every row on the same columns, and gives the names a + // little more room before they have to be cut short. + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ScreenPadding + 4.dp), + ) { + val density = LocalDensity.current + // Divided in whole pixels, not in dp. A dp cell width is a float, + // every child rounds it to pixels on its own, and `columns` of + // them can add up to a few pixels more than the row they were + // meant to fill -- at which point the last one wraps and leaves a + // column-wide hole on the right, which is the shape of the + // problem this is here to avoid. Integer division cannot exceed + // the row; the leftover is under one pixel per column. + // + // Hoisted out of FlowRow, whose scope shadows maxWidth. + val availablePx = with(density) { maxWidth.roundToPx() } + val columns = max(1, availablePx / with(density) { FaceMinWidth.roundToPx() }) + val cellWidth = with(density) { (availablePx / columns).toDp() } + FlowRow(modifier = Modifier.fillMaxWidth()) { + group.contributors.forEach { contributor -> + ContributorFace( + contributor = contributor, + width = cellWidth, + onClick = { controller.openContributor(contributor) }, + ) + } + } + } + } + } +} + +/** 一张头像加一个名字。名字比格子窄的时候截断,不去挤旁边那位。 */ +@Composable +private fun ContributorFace(contributor: Contributor, width: Dp, onClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(width) + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 2.dp), + ) { + Image( + painter = painterResource(contributor.avatar), + contentDescription = contributor.login, + contentScale = ContentScale.Crop, + modifier = Modifier.size(44.dp).clip(CircleShape), + ) + Spacer(Modifier.height(6.dp)) + Text( + text = contributor.login, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** 一格至少这么宽;实际宽度按可用宽度均分后略大于它。 */ +private val FaceMinWidth = 72.dp diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialDialogs.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialDialogs.kt new file mode 100644 index 00000000..6a8c776c --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialDialogs.kt @@ -0,0 +1,441 @@ +package com.fcl.plugin.mobileglues.ui.material + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.settings.AuthMethod +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.Responsive +import com.fcl.plugin.mobileglues.ui.AuthPrompt +import com.fcl.plugin.mobileglues.ui.Farewell +import com.fcl.plugin.mobileglues.ui.PrivacySections +import com.fcl.plugin.mobileglues.ui.LinkEntry +import com.fcl.plugin.mobileglues.ui.SponsorChannels +import com.fcl.plugin.mobileglues.ui.sourceRepositories +import com.fcl.plugin.mobileglues.ui.SponsorPromptState +import com.fcl.plugin.mobileglues.utils.Constants + +/** + * 全部全局对话框的宿主。 + * + * 挂在皮肤最外层而不是各页面里:确认框、授权引导、赞助弹窗都可能在切页之后才被解决, + * 挂在页面上会随着页面一起被销毁。 + */ +@Composable +fun MaterialDialogHost(controller: AppController) { + val confirm by controller.confirmRequest.collectAsStateWithLifecycle() + val corrupt by controller.corruptPrompt.collectAsStateWithLifecycle() + val authPrompt by controller.authPrompt.collectAsStateWithLifecycle() + val sponsor by controller.sponsorPrompt.collectAsStateWithLifecycle() + val removing by controller.removing.collectAsStateWithLifecycle() + val farewell by controller.farewell.collectAsStateWithLifecycle() + val privacyConsentNeeded by controller.privacyConsentNeeded.collectAsStateWithLifecycle() + val resetPrompt by controller.resetPrompt.collectAsStateWithLifecycle() + val sponsorPicker by controller.sponsorPicker.collectAsStateWithLifecycle() + val repoPicker by controller.repoPicker.collectAsStateWithLifecycle() + val auth by controller.auth.state.collectAsStateWithLifecycle() + + // 道别排在最前:这时候隐私同意已经被收回了,别让同意弹窗抢在道别前面糊上来。 + farewell?.let { reason -> + MgTextDialog( + title = stringResource( + if (reason == Farewell.Removed) { + R.string.remove_complete_title + } else { + R.string.revoke_complete_title + }, + ), + text = stringResource( + if (reason == Farewell.Removed) { + R.string.remove_complete_message + } else { + R.string.revoke_complete_message + }, + ), + positive = stringResource(R.string.exit), + onPositive = controller::exitAfterFarewell, + cancelable = false, + ) + return + } + + // 首次启动:先讲清楚这个 App 会碰什么,再谈别的。 + if (privacyConsentNeeded) { + PrivacyConsentDialog( + onAccept = controller::acceptPrivacy, + onDecline = controller::declinePrivacy, + ) + return + } + + confirm?.let { MgConfirmDialog(it) } + + corrupt?.let { result -> + MgTextDialog( + title = stringResource(R.string.dialog_config_corrupt_title), + text = stringResource( + R.string.dialog_config_corrupt_message, + result.backupName ?: "-", + result.cause.message ?: result.cause.javaClass.simpleName, + ), + positive = stringResource(R.string.dialog_config_corrupt_reset), + onPositive = controller::resetCorruptConfig, + negative = stringResource(R.string.dialog_negative), + onNegative = controller::dismissCorruptConfig, + onDismiss = controller::dismissCorruptConfig, + ) + } + + when (authPrompt) { + AuthPrompt.ChooseMethod -> AuthMethodDialog( + onSelect = controller::onAuthMethodSelected, + onDismiss = controller::dismissAuthPrompt, + ) + + AuthPrompt.AllFilesIntro -> MgTextDialog( + title = stringResource(R.string.dialog_permission_title), + text = stringResource(R.string.dialog_permission_msg_android_Q, Constants.MG_DIRECTORY), + positive = stringResource(R.string.dialog_positive), + onPositive = controller::proceedAllFiles, + negative = stringResource(R.string.dialog_negative), + onNegative = controller::dismissAuthPrompt, + onDismiss = controller::dismissAuthPrompt, + ) + + AuthPrompt.SafGuide -> MgTextDialog( + title = stringResource(R.string.auth_guide_saf_title), + text = stringResource(R.string.auth_guide_saf_msg), + positive = stringResource(R.string.dialog_positive), + onPositive = controller::proceedSaf, + negative = stringResource(R.string.dialog_negative), + onNegative = controller::dismissAuthPrompt, + onDismiss = controller::dismissAuthPrompt, + ) + + AuthPrompt.LegacyDenied -> MgTextDialog( + title = stringResource(R.string.dialog_permission_title), + text = stringResource(R.string.dialog_permission_msg), + positive = stringResource(R.string.dialog_positive), + onPositive = controller::proceedAppDetails, + negative = stringResource(R.string.dialog_negative), + onNegative = controller::dismissAuthPrompt, + onDismiss = controller::dismissAuthPrompt, + ) + + null -> Unit + } + + when (val state = sponsor) { + is SponsorPromptState.Ask -> MgTextDialog( + title = stringResource(R.string.sponsor_dialog_title), + text = stringResource(R.string.sponsor_dialog_msg, state.launchCount), + positive = stringResource(R.string.sponsor_action_donate), + onPositive = controller::onSponsorDonate, + negative = stringResource(R.string.sponsor_action_later), + onNegative = controller::onSponsorLater, + onDismiss = controller::onSponsorLater, + ) + + SponsorPromptState.Confirm -> MgTextDialog( + title = stringResource(R.string.sponsor_confirm_title), + text = stringResource(R.string.sponsor_confirm_msg), + positive = stringResource(R.string.sponsor_action_donated), + onPositive = controller::onSponsorDonated, + negative = stringResource(R.string.sponsor_action_not_yet), + onNegative = controller::onSponsorNotYet, + onDismiss = controller::onSponsorNotYet, + ) + + null -> Unit + } + + if (sponsorPicker) { + LinkChoiceDialog( + title = stringResource(R.string.dialog_sponsor), + message = stringResource(R.string.sponsor_channels_msg), + links = SponsorChannels, + onSelect = controller::onSponsorChannelSelected, + onDismiss = controller::dismissSponsorPicker, + ) + } + + if (repoPicker) { + LinkChoiceDialog( + title = stringResource(R.string.dialog_github), + message = null, + links = sourceRepositories(), + onSelect = controller::onRepositorySelected, + onDismiss = controller::dismissRepoPicker, + ) + } + + if (resetPrompt) { + ResetDialog( + canDelete = auth.granted, + onRevoke = controller::revokeAuthorization, + onRemove = controller::removeMobileGlues, + onDismiss = controller::dismissResetPrompt, + ) + } + + if (removing) ProgressDialog(text = stringResource(R.string.removing_mobileglues)) + +} + +/** + * 首次启动的隐私政策同意框。 + * + * 里面是政策全文而不是摘要——同意的那一刻用户看到的,就该是他事后能翻回去看的那些字。 + * 不可取消:返回键和点遮罩都不算表态。 + */ +@Composable +private fun PrivacyConsentDialog(onAccept: () -> Unit, onDecline: () -> Unit) { + AlertDialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + title = { Text(stringResource(R.string.privacy_consent_title)) }, + text = { + Column( + modifier = Modifier + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.privacy_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PrivacySections.forEach { (title, body) -> + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp), + ) + Text( + text = stringResource(body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + Text( + text = stringResource(R.string.privacy_consent_footer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 20.dp), + ) + } + }, + confirmButton = { + TextButton(onClick = onAccept) { + Text(stringResource(R.string.privacy_consent_accept)) + } + }, + dismissButton = { + TextButton(onClick = onDecline) { + Text(stringResource(R.string.privacy_consent_decline)) + } + }, + ) +} + +/** 授权方式二选一。两种方式最终写的是同一个 MG 目录,差别只在授权的方式。 */ +@Composable +private fun AuthMethodDialog(onSelect: (AuthMethod) -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.auth_choose_title)) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.auth_choose_msg), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.size(16.dp)) + AuthMethodOption( + title = stringResource(R.string.auth_method_all_files), + description = stringResource(R.string.auth_method_all_files_desc), + onClick = { onSelect(AuthMethod.AllFiles) }, + ) + Spacer(Modifier.size(8.dp)) + AuthMethodOption( + title = stringResource(R.string.auth_method_saf), + description = stringResource(R.string.auth_method_saf_desc), + onClick = { onSelect(AuthMethod.Saf) }, + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_negative)) } + }, + ) +} + +@Composable +private fun AuthMethodOption( + title: String, + description: String, + onClick: () -> Unit, + enabled: Boolean = true, + titleColor: Color = Color.Unspecified, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = DisabledAlpha) + titleColor != Color.Unspecified -> titleColor + else -> MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = if (enabled) 1f else DisabledAlpha), + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +/** + * 一组外链,选一个打开。赞助渠道和三个仓库共用它。 + * + * 每条都把网址写出来:爱发电有三个域名在用、收款方也各不相同,仓库也有三个, + * 只写个名字的话用户点下去之前并不知道会去哪里。 + */ +@Composable +private fun LinkChoiceDialog( + title: String, + message: String?, + links: List, + onSelect: (LinkEntry) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + // 一句都没有也行:三个仓库的名字自己说得清楚,硬凑一句解释反而是噪音。 + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.size(16.dp)) + } + links.forEachIndexed { index, link -> + if (index > 0) Spacer(Modifier.size(8.dp)) + AuthMethodOption( + title = link.label, + description = link.url, + onClick = { onSelect(link) }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_negative)) } + }, + ) +} + +/** 撤销 / 重置的二选一。删文件那条在未授权时点不动——没有访问权就删不了。 */ +@Composable +private fun ResetDialog( + canDelete: Boolean, + onRevoke: () -> Unit, + onRemove: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.menu_item_reset)) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + AuthMethodOption( + title = stringResource(R.string.reset_option_revoke), + description = stringResource(R.string.reset_option_revoke_desc), + onClick = onRevoke, + ) + Spacer(Modifier.size(8.dp)) + AuthMethodOption( + title = stringResource(R.string.reset_option_remove), + description = if (canDelete) { + stringResource(R.string.reset_option_remove_desc) + } else { + stringResource(R.string.reset_option_remove_needs_auth) + }, + onClick = onRemove, + enabled = canDelete, + titleColor = MaterialTheme.colorScheme.error, + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_negative)) } + }, + ) +} + +/** 不可取消的进度对话框(移除 MobileGlues 期间)。 */ +@Composable +private fun ProgressDialog(text: String) { + AlertDialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + text = { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp) + Spacer(Modifier.width(20.dp)) + Text(text = text, style = MaterialTheme.typography.bodyLarge) + } + }, + confirmButton = {}, + ) +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialHome.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialHome.kt new file mode 100644 index 00000000..a78d1810 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialHome.kt @@ -0,0 +1,365 @@ +package com.fcl.plugin.mobileglues.ui.material + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Column +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.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.AppTab + +/** + * 首页:一个大字标,加几件真正值得一眼看到的小事——有没有授权、跑在什么 GPU 上、 + * 当前配置是什么。别的都在另外两页里。 + */ +@Composable +fun MaterialHomePage(controller: AppController) { + val auth by controller.auth.state.collectAsStateWithLifecycle() + val deviceInfo by controller.deviceInfo.collectAsStateWithLifecycle() + val config by controller.configStore.config.collectAsStateWithLifecycle() + val untuned by controller.multidrawUntuned.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { controller.ensureDeviceInfo() } + + // 启动次数记在 MG 目录里,未授权时读不到;授权建立之后再问一次。 + LaunchedEffect(auth.granted) { + if (auth.granted) controller.maybeShowSponsorPrompt() + } + + // 进场:字标先到,其余元素依次跟上。visible 一开始为 false,组合完成后才翻成 true。 + var entered by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { entered = true } + + // 可滚动,并且只在放得下时才居中。这一页的内容高度是固定的(字标、授权状态、设备 + // 信息、配置摘要,加上它们之间的定距),竖屏放得下,横屏放不下——原来它既不滚动又 + // 强制居中,于是横屏下两端被裁掉且够不到。verticalScroll 加上 heightIn(min) 让它在 + // 高屏上照旧居中,在矮屏上变成一列可滚的内容。 + val scroll = rememberScrollState() + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val minHeight = maxHeight + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scroll) + .heightIn(min = minHeight) + .padding(horizontal = ScreenPadding), + ) { + EnterUp(entered, delayMillis = 0) { Wordmark(controller.appVersionName) } + + Spacer(Modifier.height(28.dp)) + + EnterUp(entered, delayMillis = 90) { + AuthPill( + granted = auth.granted, + onClick = { if (!auth.granted) controller.requestAccess() }, + ) + } + + Spacer(Modifier.height(28.dp)) + + EnterUp(entered, delayMillis = 160) { DeviceInfoBlock(deviceInfo) } + + Spacer(Modifier.height(24.dp)) + + EnterUp(entered, delayMillis = 230) { + Crossfade(targetState = config, label = "summary") { current -> + if (current != null) { + ConfigSummaryCard( + summary = controller.configSummary(current), + onClick = { controller.navigateTab(AppTab.Settings) }, + ) + } else { + Spacer(Modifier.height(1.dp)) + } + } + } + + // 排序还是出厂那份,没人量过这台设备。 + // 两层动画各管各的:外层跟着首页那串进场依次上来,内层负责跑完分采用之后自己收走。 + EnterUp(entered, delayMillis = 300) { + AnimatedVisibility( + visible = untuned && auth.granted, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(16.dp)) + BenchmarkNudge( + onClick = { + controller.runMultidrawBench(AppController.BenchTarget.AllEntries) + }, + ) + } + } + } + + // 视觉重心略高于几何中心:底部这段留白把整组内容往上顶。矮屏上它是滚动 + // 内容的一部分,不再是够不到的死区。 + Spacer(Modifier.height(72.dp)) + } + } +} + +/** 大号字标。「Glues」用主色,是整页唯一的一处强调。 */ +@Composable +private fun Wordmark(version: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = MaterialTheme.colorScheme.onSurface)) { append("Mobile") } + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { append("Glues") } + }, + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 40.sp, + letterSpacing = (-0.5).sp, + ), + textAlign = TextAlign.Center, + maxLines = 1, + ) + Text( + text = "v$version", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + ) + } +} + +/** 授权状态。未授权时是一个能直接发起授权流的按钮,授权后只是一枚状态标。 */ +@Composable +private fun AuthPill(granted: Boolean, onClick: () -> Unit) { + val container by animateColorAsState( + targetValue = if (granted) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + animationSpec = tween(320), + label = "pill-container", + ) + val content by animateColorAsState( + targetValue = if (granted) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + }, + animationSpec = tween(320), + label = "pill-content", + ) + + Surface(shape = CircleShape, color = container) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(CircleShape) + .clickable(enabled = !granted, onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(content), + ) + Spacer(Modifier.size(10.dp)) + Text( + text = stringResource( + if (granted) R.string.home_status_granted else R.string.home_status_denied, + ), + style = MaterialTheme.typography.labelLarge, + color = content, + ) + } + } +} + +/** GPU / GLES / 内存。查询没回来之前留占位,不让布局跳一下。 */ +@Composable +private fun DeviceInfoBlock(info: com.fcl.plugin.mobileglues.DeviceInfo?) { + val unknown = stringResource(R.string.home_device_unknown) + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(), + ) { + DeviceInfoRow( + label = stringResource(R.string.home_device_gpu), + value = info?.gpuRenderer?.takeIf { it.isNotBlank() } ?: unknown, + loaded = info != null, + ) + DeviceInfoRow( + label = stringResource(R.string.home_device_gles), + value = info?.glesVersion?.takeIf { it.isNotBlank() } ?: unknown, + loaded = info != null, + ) + DeviceInfoRow( + label = stringResource(R.string.home_device_ram), + value = info?.let { + stringResource(R.string.home_ram_value, it.totalRamBytes / GIBIBYTE) + } ?: unknown, + loaded = info != null, + ) + } +} + +@Composable +private fun DeviceInfoRow(label: String, value: String, loaded: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.size(16.dp)) + Crossfade(targetState = loaded, label = "device-value", modifier = Modifier.weight(1f)) { + Text( + text = if (it) value else "…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +/** 当前配置的一行只读摘要,点进设置页。 */ +@Composable +private fun ConfigSummaryCard(summary: String, onClick: () -> Unit) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + ) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.home_config_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +/** + * 「还没量过这台设备」的提示。 + * + * 说一句、给一个按钮,不解释原理——首页不是讲道理的地方。一旦排序不再是默认(自己拖过, + * 或采用了跑分结果),它自己就不出现了,所以不需要「不再提示」。 + */ +@Composable +private fun BenchmarkNudge(onClick: () -> Unit) { + // 用主题自己的 errorContainer 而不是手调一个红:动态取色下它会跟着壁纸走, + // 深色模式也自带一份,比钉死一个色号稳当,也不会跟这一页其它颜色打架。 + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + ) { + Text( + text = stringResource(R.string.md_home_untuned), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.md_home_untuned_action), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +/** 进场动画:淡入 + 上移,靠 [delayMillis] 排出先后。 */ +@Composable +private fun EnterUp(visible: Boolean, delayMillis: Int, content: @Composable () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(durationMillis = 420, delayMillis = delayMillis)) + + slideInVertically( + animationSpec = tween( + durationMillis = 420, + delayMillis = delayMillis, + easing = FastOutSlowInEasing, + ), + ) { it / 3 }, + exit = ExitTransition.None, + ) { + content() + } +} + +private const val GIBIBYTE = 1024.0 * 1024.0 * 1024.0 diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialInfo.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialInfo.kt new file mode 100644 index 00000000..c18862db --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialInfo.kt @@ -0,0 +1,120 @@ +package com.fcl.plugin.mobileglues.ui.material + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.AppSubPage + +/** + * 信息页:应用信息、可核实的东西(GL 信息、隐私政策)、以及危险区域。 + * + * 「移除 MobileGlues」从旧界面的溢出菜单搬到了这里——一个会删掉用户全部配置的操作 + * 不该藏在三个点后面。 + */ +@Composable +fun MaterialInfoPage(controller: AppController) { + val auth by controller.auth.state.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + PageTitle(stringResource(R.string.nav_info)) + + PreferenceGroup(title = stringResource(R.string.info_section_app)) { + TextPreferenceRow( + title = stringResource(R.string.info_version_label), + summary = controller.appVersionName, + ) + TextPreferenceRow( + title = stringResource(R.string.dialog_github), + summary = stringResource(R.string.repo_summary), + onClick = controller::openSourceRepositories, + ) + TextPreferenceRow( + title = stringResource(R.string.dialog_sponsor), + summary = stringResource(R.string.sponsor_channels_summary), + onClick = controller::openSponsorChannels, + ) + } + + PreferenceGroup(title = stringResource(R.string.info_section_about)) { + TextPreferenceRow( + title = label(R.string.view_author), + summary = stringResource(R.string.info_author), + ) + TextPreferenceRow( + title = label(R.string.view_copyright), + summary = stringResource(R.string.info_copyright), + ) + TextPreferenceRow( + title = label(R.string.view_launcher), + summary = stringResource(R.string.info_launcher), + ) + TextPreferenceRow( + title = label(R.string.view_logo), + summary = stringResource(R.string.info_logo), + ) + TextPreferenceRow( + title = stringResource(R.string.third_party_title), + summary = stringResource(R.string.third_party_summary), + onClick = { controller.openSubPage(AppSubPage.ThirdParty) }, + ) + } + + PreferenceGroup(title = stringResource(R.string.info_section_details)) { + TextPreferenceRow( + title = stringResource(R.string.info_mg_info), + onClick = { controller.openSubPage(AppSubPage.GlInfo) }, + ) + TextPreferenceRow( + title = stringResource(R.string.info_privacy), + onClick = { controller.openSubPage(AppSubPage.Privacy) }, + ) + } + + PreferenceGroup(title = stringResource(R.string.info_danger_zone)) { + // 撤销和删除是包含关系,摆成两个平级按钮会互相锁死:撤了就删不动, + // 删完了也没什么可撤。所以入口只有一个,进去再选做到哪一步。 + TextPreferenceRow( + title = stringResource(R.string.menu_item_reset), + titleColor = MaterialTheme.colorScheme.error, + onClick = controller::openResetPrompt, + ) + } + + // 致谢摆在最后:它是这一页读到底之后的落款,不是一个要跳过去的功能。 + MaterialContributorsSection(controller) + + BottomSpacer() + } +} + +@Composable +private fun ExternalLinkIcon() { + Icon( + painter = painterResource(R.drawable.ic_open_in_new), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) +} + +/** 「版本:」这类文案自带冒号,当行标题用的时候要去掉。 */ +@Composable +private fun label(@StringRes id: Int): String = stringResource(id).trimEnd(' ', ':', ':') diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialMultidraw.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialMultidraw.kt new file mode 100644 index 00000000..cd2e99cc --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialMultidraw.kt @@ -0,0 +1,544 @@ +package com.fcl.plugin.mobileglues.ui.material + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.settings.MGConfig +import com.fcl.plugin.mobileglues.settings.MultidrawBenchQuality +import com.fcl.plugin.mobileglues.settings.MultidrawEntry +import com.fcl.plugin.mobileglues.settings.MultidrawOrderItem +import com.fcl.plugin.mobileglues.settings.MultidrawSettings +import com.fcl.plugin.mobileglues.settings.RankedItem +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.Responsive +import com.fcl.plugin.mobileglues.ui.DragReorderColumn +import androidx.compose.ui.unit.dp +import java.util.Locale + +/** 折叠状态下的一句话摘要。 */ +@Composable +fun multidrawSummary(settings: MultidrawSettings): String { + val exceptionCount = settings.exceptions.size + if (!settings.globalCustomized && exceptionCount == 0) { + return stringResource(R.string.md_summary_default) + } + val global = if (settings.globalCustomized) { + stringResource(R.string.md_summary_global_custom) + } else { + stringResource(R.string.md_summary_global_default) + } + return if (exceptionCount == 0) { + global + } else { + "$global · ${stringResource(R.string.md_summary_exception_count, exceptionCount)}" + } +} + +/** + * MultiDraw 排序设置:全局 8 项排序 + 每函数例外排序 + benchmark。 + * 放在「高级」组的可展开小节里。 + */ +@Composable +fun ColumnScope.MultidrawOrderContent(controller: AppController, config: MGConfig) { + val context = LocalContext.current + val settings = config.multidraw + + SectionHint(stringResource(R.string.md_order_hint)) + + DragReorderColumn( + items = settings.globalOrder, + onMove = controller::moveMultidrawGlobalItem, + ) { index, item, dragging, handle -> + OrderRow( + position = index + 1, + label = item.label(context).toString(), + sublabel = if (item == MultidrawOrderItem.Native) { + stringResource(R.string.md_item_native_desc) + } else { + null + }, + dragging = dragging, + handle = handle, + ) + } + + AnimatedVisibility(visible = settings.globalCustomized, enter = fadeIn(), exit = fadeOut()) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)) { + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = controller::resetMultidrawGlobalOrder) { + Text(stringResource(R.string.md_reset_default)) + } + } + } + + // ---- 例外函数 ---- + + Text( + text = stringResource(R.string.md_exceptions_group), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 20.dp, top = 12.dp), + ) + + // 跑分只测得出「这个函数上哪个方案快」,那就把结果按函数交出去,别硬合成一份全局顺序。 + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)) { + FilledTonalButton( + onClick = { controller.runMultidrawBench(AppController.BenchTarget.AllEntries) }, + ) { + Text(stringResource(R.string.md_bench_run_all)) + } + } + + MultidrawEntry.entries.forEach { entry -> + val hasException = settings.hasException(entry) + SwitchPreferenceRow( + title = entry.glFunction, + summary = if (hasException) { + stringResource(R.string.md_exception_on) + } else { + stringResource(R.string.md_exception_off) + }, + checked = hasException, + onCheckedChange = { controller.setMultidrawException(entry, it) }, + ) + AnimatedVisibility( + visible = hasException, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp)) { + DragReorderColumn( + items = settings.effectiveOrderFor(entry), + onMove = { from, to -> + controller.moveMultidrawExceptionItem(entry, from, to) + }, + ) { index, backend, dragging, handle -> + OrderRow( + position = index + 1, + label = backend.label(context).toString(), + sublabel = null, + dragging = dragging, + handle = handle, + ) + } + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)) { + FilledTonalButton( + onClick = { + controller.runMultidrawBench(AppController.BenchTarget.Entry(entry)) + }, + ) { + Text(stringResource(R.string.md_bench_run_entry)) + } + Spacer(Modifier.weight(1f)) + AnimatedVisibility( + visible = settings.exceptionCustomized(entry), + enter = fadeIn(), + exit = fadeOut(), + ) { + OutlinedButton(onClick = { controller.resetMultidrawExceptionOrder(entry) }) { + Text(stringResource(R.string.md_reset_default)) + } + } + } + } + } + } +} + +@Composable +private fun SectionHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) +} + +/** 一行可排序项:序号 + 名称 + 拖动手柄。被拖起来的那行浮起来一层。 */ +@Composable +private fun OrderRow( + position: Int, + label: String, + sublabel: String?, + dragging: Boolean, + handle: Modifier, +) { + val elevation by animateDpAsState(if (dragging) 6.dp else 0.dp, label = "drag-elevation") + val background by animateColorAsState( + targetValue = if (dragging) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + Color.Transparent + }, + label = "drag-background", + ) + Surface( + color = background, + shadowElevation = elevation, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(start = 12.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(24.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = position.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + Column(modifier = Modifier.weight(1f).padding(start = 12.dp, top = 6.dp, bottom = 6.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (sublabel != null) { + Text( + text = sublabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Box( + contentAlignment = Alignment.Center, + modifier = handle.size(48.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = stringResource(R.string.md_order_drag_handle), + tint = if (dragging) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(24.dp), + ) + } + } + } +} + +// ---- Benchmark 对话框 ---- + +/** + * 选一个启动器把 ANGLE 借来。 + * + * 这是把别的应用的原生代码载入本进程,所以选择必须是用户明确做出的,而且要把这句话 + * 当着他的面说清楚——不能因为「只有一个来源」就替他默认。 + */ +@Composable +private fun AngleSourceDialog(controller: AppController) { + val prompt by controller.angleSourcePrompt.collectAsStateWithLifecycle() + val pending = prompt ?: return + + AlertDialog( + onDismissRequest = controller::dismissAngleSourcePrompt, + title = { Text(stringResource(R.string.md_angle_title)) }, + text = { + Column( + modifier = Modifier + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + Text(stringResource(R.string.md_angle_intro)) + Spacer(Modifier.heightIn(min = 12.dp)) + if (pending.sources.isEmpty()) { + Text(stringResource(R.string.md_angle_none)) + } else { + Text( + text = AnnotatedString.fromHtml(stringResource(R.string.md_angle_trust)), + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.heightIn(min = 8.dp)) + pending.sources.forEach { source -> + Surface( + onClick = { controller.confirmAngleSource(source) }, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(source.label, style = MaterialTheme.typography.bodyLarge) + if (source.packageName == pending.lastChosen) { + Text( + text = stringResource(R.string.md_angle_last_used), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + Text( + text = source.packageName + + (source.versionName?.let { + " · " + stringResource(R.string.md_angle_source_version, it) + } ?: ""), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = controller::continueWithoutAngle) { + Text(stringResource(R.string.md_angle_skip)) + } + }, + dismissButton = { + TextButton(onClick = controller::dismissAngleSourcePrompt) { + Text(stringResource(R.string.dialog_negative)) + } + }, + ) +} + +@Composable +fun MultidrawBenchDialogs(controller: AppController) { + val context = LocalContext.current + val state by controller.benchState.collectAsStateWithLifecycle() + + AngleSourceDialog(controller) + + when (val s = state) { + is AppController.BenchState.Running -> AlertDialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + title = { Text(stringResource(R.string.md_bench_running_title)) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + // 进度是 native 那边的原子计数器;老渲染器没有,那就转圈。 + val progress = s.progress + if (progress == null) { + CircularProgressIndicator(modifier = Modifier.size(28.dp)) + } else { + val animated by animateFloatAsState(progress, label = "bench-progress") + CircularProgressIndicator( + progress = { animated }, + modifier = Modifier.size(28.dp), + ) + } + Text( + text = when { + // 退让优先于「第几次测量」:上一趟整份作废了,说「第 2 次」 + // 会让人以为前面那趟还算数。 + s.retryingAtSections != null -> stringResource( + R.string.md_bench_running_smaller, + s.retryingAtSections, + ) + s.attempt > 1 -> stringResource( + R.string.md_bench_running_retry, + s.attempt, + AppController.BENCH_MAX_ATTEMPTS, + ) + else -> stringResource(R.string.md_bench_running_msg) + }, + modifier = Modifier.padding(start = 16.dp), + ) + } + }, + confirmButton = {}, + ) + + is AppController.BenchState.Done -> AlertDialog( + onDismissRequest = controller::dismissBench, + title = { Text(stringResource(R.string.md_bench_result_title)) }, + text = { + Column( + modifier = Modifier + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = when (val target = s.target) { + is AppController.BenchTarget.AllEntries -> + stringResource(R.string.md_bench_result_all_intro) + is AppController.BenchTarget.Entry -> + stringResource( + R.string.md_bench_result_entry_intro, + target.entry.glFunction, + ) + }, + ) + // 驱动错了就不只是「不够准」,是整份名次搬不过去,得说在最前面。 + val angleNote = s.angleNote + if (angleNote != null) { + Spacer(Modifier.heightIn(min = 12.dp)) + Text( + text = stringResource(angleNote.messageRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + // 单个函数时函数名已经在开头那句里了,再标一次是废话。 + val showHeadings = s.target is AppController.BenchTarget.AllEntries + s.rankings.forEach { (entry, ranking) -> + Spacer(Modifier.heightIn(min = 12.dp)) + if (showHeadings) { + Text( + text = entry.glFunction, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + ranking.forEachIndexed { index, ranked -> + RankedRow(index + 1, ranked.item.label(context).toString(), ranked.relativeCost) + } + // 只有一个方案测得出时,「排名」这个词就名不副实——没有可比较的对象, + // ×1.00 也失去含义。说破,免得用户把孤例当选择。 + if (ranking.count { it.relativeCost != null } == 1) { + Text( + text = stringResource(R.string.md_bench_single_candidate), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // 成色跟着它描述的那份排名走:抖的是某个函数,不是整场跑分。 + BenchQualityNote(s.quality[entry]) + } + } + }, + confirmButton = { + TextButton(onClick = controller::adoptBenchResult) { + Text( + stringResource( + if (s.anyNoisy || s.driverMismatch) { + R.string.md_bench_adopt_anyway + } else { + R.string.md_bench_adopt + }, + ), + ) + } + }, + dismissButton = { + TextButton(onClick = controller::dismissBench) { + Text(stringResource(R.string.md_bench_discard)) + } + }, + ) + + is AppController.BenchState.Failed -> MgTextDialog( + title = stringResource(R.string.md_bench_result_title), + text = s.message, + positive = stringResource(R.string.ok), + onPositive = controller::dismissBench, + onDismiss = controller::dismissBench, + ) + + null -> Unit + } +} + +/** + * 一份排名底下的一行小字:这个函数测了几轮、抖动多大。 + * + * 抖动比相邻两名的差距还大时,这份排名就只能算「大致如此」,说出来比装作精确要好。 + * [MultidrawBenchQuality.noisy] 是这个函数反复加长重测到头也没压下来——要用户自己拍板。 + */ +@Composable +private fun BenchQualityNote(quality: MultidrawBenchQuality?) { + if (quality == null || quality.rounds <= 0) return + val noise = String.format(Locale.US, "%.1f", quality.noise * 100) + Text( + text = stringResource(R.string.md_bench_quality, quality.rounds, noise), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (quality.noisy) { + Text( + text = stringResource(R.string.md_bench_noisy, quality.attempts, noise), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } +} + +@Composable +private fun RankedRow(position: Int, label: String, relativeCost: Double?) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Text( + text = "$position.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + if (relativeCost == null) { + Text( + text = stringResource(R.string.md_bench_unmeasured), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (relativeCost != null) { + Text( + text = stringResource( + R.string.md_bench_cost, + String.format(Locale.US, "%.2f", relativeCost), + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialSettings.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialSettings.kt new file mode 100644 index 00000000..6d4d2e79 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialSettings.kt @@ -0,0 +1,308 @@ +@file:OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + +package com.fcl.plugin.mobileglues.ui.material + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.settings.AngleConfig +import com.fcl.plugin.mobileglues.settings.DepthClearFixMode +import com.fcl.plugin.mobileglues.settings.GlVersion +import com.fcl.plugin.mobileglues.settings.GlslCacheScale +import com.fcl.plugin.mobileglues.settings.MGConfig +import com.fcl.plugin.mobileglues.settings.NoErrorConfig +import com.fcl.plugin.mobileglues.settings.SpinnerOption +import com.fcl.plugin.mobileglues.settings.UiStyle +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.SettingsLoadState + +/** + * 设置页。 + * + * 权限门只拦渲染器配置那一段:「Plugin 配置」(界面风格)是本 App 自己的偏好, + * 和 MG 目录无关,所以放在门之上——未授权时这一页也不是一片空白。 + */ +@Composable +fun MaterialSettingsPage(controller: AppController) { + val auth by controller.auth.state.collectAsStateWithLifecycle() + val loadState by controller.loadState.collectAsStateWithLifecycle() + val config by controller.configStore.config.collectAsStateWithLifecycle() + val uiStyle by controller.pluginConfig.uiStyle.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { controller.ensureDeviceInfo() } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + PageTitle(stringResource(R.string.nav_settings)) + + PreferenceGroup(title = stringResource(R.string.settings_group_plugin)) { + SegmentedChoiceRow( + title = stringResource(R.string.ui_style), + options = listOf( + stringResource(R.string.ui_style_material), + stringResource(R.string.ui_style_miuix), + ), + selectedIndex = uiStyle.ordinal, + onSelect = { controller.pluginConfig.setUiStyle(UiStyle.entries[it]) }, + ) + } + + Crossfade( + targetState = auth.granted to (loadState == SettingsLoadState.Ready && config != null), + label = "settings-gate", + ) { (granted, ready) -> + Column(modifier = Modifier.fillMaxWidth()) { + when { + !granted -> PermissionGate(onGrant = controller::requestAccess) + ready -> ConfigSections(controller, config ?: MGConfig.Default) + else -> CenteredLoading(modifier = Modifier.padding(top = 48.dp)) + } + } + } + + BottomSpacer() + } +} + +/** 未授权时的权限门。 */ +@Composable +private fun PermissionGate(onGrant: () -> Unit) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ScreenPadding, vertical = 20.dp), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) { + Text( + text = stringResource(R.string.settings_gate_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.settings_gate_msg), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Button(onClick = onGrant, modifier = Modifier.padding(top = 20.dp)) { + Text(stringResource(R.string.settings_gate_grant)) + } + } + } +} + +/** 权限门之内:渲染 / 着色器缓存 / 扩展 / 高级。 */ +@Composable +private fun ConfigSections(controller: AppController, config: MGConfig) { + val context = LocalContext.current + val deviceInfo by controller.deviceInfo.collectAsStateWithLifecycle() + val cacheBytes by controller.configStore.glslCacheBytes.collectAsStateWithLifecycle() + + var choice by remember { mutableStateOf(null) } + var multidrawExpanded by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxWidth()) { + PreferenceGroup(title = stringResource(R.string.settings_group_render)) { + TextPreferenceRow( + title = stringResource(R.string.option_angle), + summary = config.angle.label(context).toString(), + onClick = { choice = ChoiceTarget.Angle }, + ) + TextPreferenceRow( + title = stringResource(R.string.option_no_error), + summary = config.noError.label(context).toString(), + onClick = { choice = ChoiceTarget.NoError }, + ) + TextPreferenceRow( + title = stringResource(R.string.option_angle_clear_workaround), + summary = config.depthClearFix.label(context).toString(), + onClick = { choice = ChoiceTarget.DepthClear }, + ) + SwitchPreferenceRow( + title = stringResource(R.string.option_enable_fsr1), + checked = config.fsr1Enabled, + onCheckedChange = controller::setFsr1, + ) + } + + PreferenceGroup(title = stringResource(R.string.settings_group_cache)) { + GlslCacheSlider(controller, config, deviceInfo?.totalRamBytes) + // 没有缓存文件时不摆一个删不了东西的按钮:它按需浮现,删完收回。 + AnimatedVisibility( + visible = cacheBytes != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + TextPreferenceRow( + title = stringResource( + R.string.option_glsl_cache_delete, + controller.formatCacheSize(cacheBytes ?: 0L), + ), + titleColor = MaterialTheme.colorScheme.error, + onClick = controller::deleteGlslCache, + ) + } + } + + PreferenceGroup(title = stringResource(R.string.settings_group_ext)) { + SwitchPreferenceRow( + title = stringResource(R.string.option_ext_cs), + checked = config.extComputeShader, + onCheckedChange = controller::setExtComputeShader, + ) + SwitchPreferenceRow( + // 磁盘上记的是「启用」,界面上问的是「禁用」,取反只发生在这一行。 + title = stringResource(R.string.option_ext_timer_query), + checked = !config.extTimerQuery, + onCheckedChange = controller::setExtTimerQueryDisabled, + ) + SwitchPreferenceRow( + title = stringResource(R.string.option_ext_direct_state_access), + checked = config.extDirectStateAccess, + onCheckedChange = controller::setExtDirectStateAccess, + ) + } + + PreferenceGroup(title = stringResource(R.string.settings_group_advanced)) { + TextPreferenceRow( + title = stringResource(R.string.option_custom_gl_version), + summary = config.glVersion.label(context).toString(), + onClick = { choice = ChoiceTarget.GlVersion }, + ) + + ExpandableSection( + title = stringResource(R.string.option_multidraw), + summary = multidrawSummary(config.multidraw), + expanded = multidrawExpanded, + onToggle = { multidrawExpanded = !multidrawExpanded }, + ) { + MultidrawOrderContent(controller, config) + } + } + } + + + // ---- 选项对话框 ---- + + when (choice) { + ChoiceTarget.Angle -> OptionDialog( + title = stringResource(R.string.option_angle), + options = AngleConfig.entries, + selected = config.angle, + onSelect = controller::selectAngle, + onDismiss = { choice = null }, + ) + + ChoiceTarget.NoError -> OptionDialog( + title = stringResource(R.string.option_no_error), + options = NoErrorConfig.entries, + selected = config.noError, + onSelect = controller::selectNoError, + onDismiss = { choice = null }, + ) + + ChoiceTarget.DepthClear -> OptionDialog( + title = stringResource(R.string.option_angle_clear_workaround), + options = DepthClearFixMode.entries, + selected = config.depthClearFix, + onSelect = controller::selectDepthClearFix, + onDismiss = { choice = null }, + ) + + ChoiceTarget.GlVersion -> OptionDialog( + title = stringResource(R.string.option_custom_gl_version), + options = GlVersion.entries, + selected = config.glVersion, + onSelect = controller::selectGlVersion, + onDismiss = { choice = null }, + ) + + null -> Unit + } + +} + +/** + * 缓存上限滑块。 + * + * 拖动期间用本地档位,松手才交还给配置:档位 → MiB → 档位 的换算有取整, + * 直接跟着配置画的话手指底下的滑块会自己抖。 + */ +@Composable +private fun GlslCacheSlider(controller: AppController, config: MGConfig, totalRamBytes: Long?) { + val mebibytes = config.glslCache.mebibytesOrZero + // 内存还没查回来时先按最小量程画,查到之后量程只会变大,滑块位置不会倒退。 + val base = totalRamBytes?.let { GlslCacheScale.baseCeiling(it) } + ?: GlslCacheScale.MIN_UPPER_BOUND_MIB.toInt() + val ceiling = maxOf(base, mebibytes) + var dragPosition by remember { mutableStateOf(null) } + + SliderPreferenceRow( + title = stringResource(R.string.option_glsl_cache), + valueLabel = if (mebibytes > 0) { + stringResource(R.string.option_glsl_cache_value, mebibytes) + } else { + stringResource(R.string.option_glsl_cache_off) + }, + position = dragPosition ?: GlslCacheScale.positionFor(mebibytes, ceiling), + steps = GlslCacheScale.STEPS, + onPositionChange = { position -> + dragPosition = position + controller.setGlslCacheSliderPosition(position, ceiling) + }, + onDragFinished = { dragPosition = null }, + ) +} + +/** Spinner 的替代:枚举 → 单选对话框,选项顺序就是枚举的声明顺序。 */ +@Composable +private fun OptionDialog( + title: String, + options: List, + selected: T, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + SingleChoiceDialog( + title = title, + options = options.map { it.label(context).toString() }, + selectedIndex = options.indexOf(selected), + onSelect = { onSelect(options[it]) }, + onDismiss = onDismiss, + ) +} + +private enum class ChoiceTarget { Angle, NoError, DepthClear, GlVersion } diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialSubPages.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialSubPages.kt new file mode 100644 index 00000000..86c253cc --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/material/MaterialSubPages.kt @@ -0,0 +1,256 @@ +package com.fcl.plugin.mobileglues.ui.material + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +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.padding +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.Surface +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.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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.PrivacySections +import com.fcl.plugin.mobileglues.ui.ThirdPartyGroups + +/** + * GL 信息页:每次进来都重新查一次(渲染器的 .so 可能刚被游戏更新过), + * 查询期间给进度指示,回来之后文本可选中、可一键复制。 + */ +@Composable +fun MaterialGlInfoPage(controller: AppController) { + val context = LocalContext.current + val info by controller.glInfo.collectAsStateWithLifecycle() + val loading by controller.glInfoLoading.collectAsStateWithLifecycle() + val needsAngle by controller.glInfoNeedsAngle.collectAsStateWithLifecycle() + val angleState by controller.glInfoAngle.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { controller.loadGlInfo() } + + SubPageScaffold( + title = stringResource(R.string.dialog_mg_gl_info_title), + onBack = { controller.navigateBack() }, + actions = { + // 没有内容可复制的时候按钮不该在那儿等着被按。 + AnimatedVisibility( + visible = !info.isNullOrBlank(), + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut(targetScale = 0.8f), + ) { + IconButton(onClick = { copyGlInfo(context, controller, info.orEmpty()) }) { + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.copy), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) { + Crossfade(targetState = loading, label = "gl-info") { busy -> + if (busy) { + CenteredLoading( + text = stringResource(R.string.gl_info_loading), + modifier = Modifier.padding(top = 48.dp), + ) + } else { + Column { + // ANGLE 随启动器走,本 App 里没有;不借的话这一页讲的是系统驱动, + // 不是游戏里那个。借不借由用户点——不能因为他只想看一眼就自作主张 + // 把别人的原生代码载进来。 + if (needsAngle) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth() + .padding(horizontal = ScreenPadding, vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = stringResource(R.string.md_glinfo_needs_angle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + TextButton( + onClick = { controller.reloadGlInfoWithAngle() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text(stringResource(R.string.md_glinfo_borrow)) + } + } + } + } else if (angleState == AppController.GlInfoAngle.Borrowed) { + Text( + text = stringResource(R.string.md_glinfo_borrowed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = ScreenPadding, vertical = 4.dp), + ) + } else if (angleState == AppController.GlInfoAngle.BorrowIneffective) { + Text( + text = stringResource(R.string.md_glinfo_borrow_ineffective), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = ScreenPadding, vertical = 4.dp), + ) + } + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenPadding), + ) { + SelectableBody( + text = info.orEmpty(), + modifier = Modifier.padding(20.dp), + ) + } + } + } + } + BottomSpacer() + } +} + +private fun copyGlInfo(context: Context, controller: AppController, text: String) { + val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return + clipboard.setPrimaryClip(ClipData.newPlainText(GL_INFO_CLIP_LABEL, text)) + // Android 13 起系统自己会弹一个复制提示,再来一条就是重复了。 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + controller.snackbar(context.getString(R.string.copied)) + } +} + +/** + * 隐私政策页。 + * + * 逐条写清楚碰哪些文件、为什么要存储权限、本地存了什么,最后给出一条可以自己核实的事实: + * 清单里没有 INTERNET 权限,所以「上传」在技术上根本做不到。 + */ +@Composable +fun MaterialPrivacyPage(controller: AppController) { + SubPageScaffold( + title = stringResource(R.string.info_privacy), + onBack = { controller.navigateBack() }, + ) { + Text( + text = stringResource(R.string.privacy_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = ScreenPadding + 4.dp, vertical = 8.dp), + ) + // 标题在卡片外、正文在卡片内——和设置页的分组是同一套语法。 + PrivacySections.forEach { (title, body) -> + PreferenceGroup( + title = stringResource(title), + titleColor = MaterialTheme.colorScheme.onSurface, + ) { + Text( + text = stringResource(body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), + ) + } + } + BottomSpacer() + } +} + +/** + * 第三方开源项目。 + * + * 分「渲染器」和「插件」两组:用户看到 SPIRV-Cross 的时候,应该同时知道它是被游戏里 + * 那个 .so 用的,而不是被这个设置界面用的。每一项都能点开自己的主页去看许可证原文—— + * 在这里抄一份许可证全文,既没人读,也保证不了和上游一致。 + */ +@Composable +fun MaterialThirdPartyPage(controller: AppController) { + SubPageScaffold( + title = stringResource(R.string.third_party_title), + onBack = { controller.navigateBack() }, + ) { + Text( + text = stringResource(R.string.third_party_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = ScreenPadding + 4.dp, vertical = 8.dp), + ) + ThirdPartyGroups.forEach { group -> + PreferenceGroup(title = stringResource(group.title)) { + group.components.forEach { component -> + TextPreferenceRow( + title = component.name, + summary = "${component.author} · ${component.license}", + onClick = { controller.openThirdPartyComponent(component) }, + ) + } + } + } + BottomSpacer() + } +} + +/** 子页面的骨架:返回键 + 标题 + 可选操作,下面是可滚动的内容。 */ +@Composable +private fun SubPageScaffold( + title: String, + onBack: () -> Unit, + actions: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp), + ) { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f).padding(start = 4.dp), + ) + actions() + } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + content() + } + } +} + +private const val GL_INFO_CLIP_LABEL = "MobileGlues GL info" diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixApp.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixApp.kt new file mode 100644 index 00000000..e796ec3f --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixApp.kt @@ -0,0 +1,217 @@ +package com.fcl.plugin.mobileglues.ui.miuix + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.isSystemInDarkTheme +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.AppSubPage +import com.fcl.plugin.mobileglues.ui.AppTab +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import top.yukonga.miuix.kmp.basic.NavigationRail +import top.yukonga.miuix.kmp.basic.NavigationRailItem +import com.fcl.plugin.mobileglues.ui.Responsive +import top.yukonga.miuix.kmp.basic.NavigationBar +import top.yukonga.miuix.kmp.basic.NavigationBarItem +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SnackbarHost +import top.yukonga.miuix.kmp.basic.SnackbarDuration +import top.yukonga.miuix.kmp.basic.SnackbarHostState +import top.yukonga.miuix.kmp.basic.SnackbarResult +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.darkColorScheme +import top.yukonga.miuix.kmp.theme.lightColorScheme + +/** + * Miuix 皮肤的外壳。 + * + * 结构与 MD3 皮肤一一对应,用的却是完全不同的一套组件:这里是两套界面,不是换个配色。 + * 唯一共享的是 [AppController]——所有操作逻辑因此只有一份。 + */ +@Composable +fun MiuixApp(controller: AppController) { + val dark = isSystemInDarkTheme() + MiuixTheme(colors = if (dark) darkColorScheme() else lightColorScheme()) { + val tab by controller.tab.collectAsStateWithLifecycle() + val subPage by controller.subPage.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(controller) { + controller.snackbar.collect { snackbarHostState.showSnackbar(it.toString()) } + } + + // 换了 GLES 驱动,手上那份排序是在旧驱动上量的。用 snackbar 而不是对话框: + // 这只是句提醒,用户正忙着调设置,不该被拦下来。 + val outdatedMessage = stringResource(R.string.md_bench_outdated) + val outdatedAction = stringResource(R.string.md_bench_outdated_action) + LaunchedEffect(controller) { + controller.benchOutdated.collect { + val result = snackbarHostState.showSnackbar( + message = outdatedMessage, + actionLabel = outdatedAction, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + controller.runMultidrawBench(AppController.BenchTarget.AllEntries) + } + } + } + + BackHandler(enabled = subPage != null) { controller.navigateBack() } + + // 垂直方向紧张(通常是手机横屏)时导航让到侧边,理由与 Material 皮肤相同: + // 底栏吃掉的是横屏下最稀缺的高度。判断的是高度而不是朝向,见 Responsive。 + val heightCompact = Responsive.isHeightCompact() + Scaffold( + modifier = Modifier.fillMaxSize(), + // Miuix 的语义与 MD3 相反:页面是 surface(深色下是纯黑),卡片才是 surfaceContainer。 + // 把页面画成 background 会和卡片同色,分组就看不见了。 + containerColor = MiuixTheme.colorScheme.surface, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + AnimatedVisibility( + visible = subPage == null && !heightCompact, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + ) { + MiuixNavigationBar(current = tab, onSelect = controller::navigateTab) + } + }, + ) { innerPadding -> + // 对话框宿主要在 Scaffold 之内:Miuix 的弹窗渲染进 Scaffold 提供的 popup host。 + Box(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + AnimatedVisibility( + visible = subPage == null && heightCompact, + enter = slideInHorizontally { -it } + fadeIn(), + exit = slideOutHorizontally { -it } + fadeOut(), + ) { + MiuixNavigationRail(current = tab, onSelect = controller::navigateTab) + } + // 每页的滚动位置各自存一份:从子页面退回来时,列表还停在原处。 + val pageState = rememberSaveableStateHolder() + AnimatedContent( + targetState = subPage ?: tab, + transitionSpec = { miuixPageTransition(initialState, targetState) }, + modifier = Modifier.fillMaxSize(), + label = "page", + ) { destination -> + pageState.SaveableStateProvider(destination) { + Column(modifier = Modifier.fillMaxSize()) { + when (destination) { + AppTab.Home -> MiuixHomePage(controller) + AppTab.Settings -> MiuixSettingsPage(controller) + AppTab.Info -> MiuixInfoPage(controller) + AppSubPage.GlInfo -> MiuixGlInfoPage(controller) + AppSubPage.Privacy -> MiuixPrivacyPage(controller) + AppSubPage.ThirdParty -> MiuixThirdPartyPage(controller) + } + } + } + } + } + MiuixDialogHost(controller) + // 跑分可以从主页的提示、设置页的按钮、切驱动后的 snackbar 三处发起, + // 对话框因此挂在这一层,而不是某一页里。 + MiuixMultidrawBenchDialogs(controller) + } + } + } +} + +@Composable +private fun MiuixNavigationBar(current: AppTab, onSelect: (AppTab) -> Unit) { + NavigationBar { + NavigationBarItem( + selected = current == AppTab.Home, + onClick = { onSelect(AppTab.Home) }, + icon = rememberVectorIcon(R.drawable.ic_home), + label = stringResource(R.string.nav_home), + ) + NavigationBarItem( + selected = current == AppTab.Settings, + onClick = { onSelect(AppTab.Settings) }, + icon = rememberVectorIcon(R.drawable.ic_settings), + label = stringResource(R.string.nav_settings), + ) + NavigationBarItem( + selected = current == AppTab.Info, + onClick = { onSelect(AppTab.Info) }, + icon = rememberVectorIcon(R.drawable.ic_info), + label = stringResource(R.string.nav_info), + ) + } +} + +/** 底栏的侧边形态,条目与顺序同一份语义——两种形态永远不会各说各话。 */ +@Composable +private fun MiuixNavigationRail(current: AppTab, onSelect: (AppTab) -> Unit) { + NavigationRail { + // 同 Material 侧的理由:底栏条目水平居中,侧边形态就该垂直居中。 + Spacer(Modifier.weight(1f)) + NavigationRailItem( + selected = current == AppTab.Home, + onClick = { onSelect(AppTab.Home) }, + icon = rememberVectorIcon(R.drawable.ic_home), + label = stringResource(R.string.nav_home), + ) + NavigationRailItem( + selected = current == AppTab.Settings, + onClick = { onSelect(AppTab.Settings) }, + icon = rememberVectorIcon(R.drawable.ic_settings), + label = stringResource(R.string.nav_settings), + ) + NavigationRailItem( + selected = current == AppTab.Info, + onClick = { onSelect(AppTab.Info) }, + icon = rememberVectorIcon(R.drawable.ic_info), + label = stringResource(R.string.nav_info), + ) + Spacer(Modifier.weight(1f)) + } +} + +/** 与 MD3 皮肤同样的过场语义:同级切页小位移,进出子页面从右侧推入。 */ +private fun miuixPageTransition(from: Any, to: Any): ContentTransform { + val fadeInSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing) + val fadeOutSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing) + val slide = tween(durationMillis = 320, easing = FastOutSlowInEasing) + + val (enterFraction, exitFraction) = when { + to is AppSubPage -> 3 to -10 + from is AppSubPage -> -10 to 3 + from is AppTab && to is AppTab && to.ordinal > from.ordinal -> 6 to -6 + else -> -6 to 6 + } + + return ContentTransform( + targetContentEnter = slideInHorizontally(slide) { it / enterFraction } + fadeIn(fadeInSpec), + initialContentExit = slideOutHorizontally(slide) { it / exitFraction } + fadeOut(fadeOutSpec), + sizeTransform = SizeTransform(clip = false), + ) +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixComponents.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixComponents.kt new file mode 100644 index 00000000..f19111c2 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixComponents.kt @@ -0,0 +1,346 @@ +package com.fcl.plugin.mobileglues.ui.miuix + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +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.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +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.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.fcl.plugin.mobileglues.R +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.BasicComponentColors +import top.yukonga.miuix.kmp.basic.BasicComponentDefaults +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.extra.SuperArrow +import top.yukonga.miuix.kmp.extra.SuperDropdown +import top.yukonga.miuix.kmp.extra.SuperSwitch +import top.yukonga.miuix.kmp.basic.CardDefaults +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator +import top.yukonga.miuix.kmp.basic.SmallTitle +import top.yukonga.miuix.kmp.basic.Slider +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme +import kotlin.math.roundToInt + +/** Miuix 的卡片列表左右留白。 */ +val MiuixScreenPadding = 12.dp + +/** 页面大标题。 */ +@Composable +fun MiuixPageTitle(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = MiuixTheme.textStyles.title2, + color = MiuixTheme.colorScheme.onBackground, + modifier = modifier.padding(start = MiuixScreenPadding + 16.dp, top = 24.dp, bottom = 8.dp), + ) +} + +/** 设置分组:小标题 + 一张卡片,和 HyperOS 设置里的分组一致。 */ +@Composable +fun MiuixGroup( + title: String? = null, + modifier: Modifier = Modifier, + // 分组标签用 Miuix 默认的弱化色;文档式的小标题(隐私政策)传 onSurface。 + titleColor: Color? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + if (title != null) { + if (titleColor != null) { + SmallTitle(text = title, textColor = titleColor) + } else { + SmallTitle(text = title) + } + } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MiuixScreenPadding), + cornerRadius = CardDefaults.CornerRadius, + insideMargin = PaddingValues(vertical = 4.dp), + content = content, + ) + } +} + +/** 一行只读信息:标题 +(可选)副标题。 */ +@Composable +fun MiuixTextRow( + title: String, + summary: String? = null, + enabled: Boolean = true, + titleColor: Color? = null, + endActions: @Composable (RowScope.() -> Unit)? = null, +) { + BasicComponent( + title = title, + titleColor = titleColors(titleColor), + summary = summary, + endActions = endActions, + enabled = enabled, + ) +} + +/** 一行「点进去」:右侧带箭头,是 Miuix 里表示「还有下一步」的方式。 */ +@Composable +fun MiuixArrowRow( + title: String, + summary: String? = null, + enabled: Boolean = true, + titleColor: Color? = null, + onClick: () -> Unit, +) { + SuperArrow( + title = title, + titleColor = titleColors(titleColor), + summary = summary, + enabled = enabled, + onClick = onClick, + ) +} + +/** 一行开关。 */ +@Composable +fun MiuixSwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + summary: String? = null, + enabled: Boolean = true, +) { + SuperSwitch( + title = title, + summary = summary, + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) +} + +/** 一行下拉选择:点开就地弹出选项列表,这是 Miuix 版的 Spinner。 */ +@Composable +fun MiuixDropdownRow( + title: String, + options: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + enabled: Boolean = true, +) { + SuperDropdown( + title = title, + items = options, + selectedIndex = selectedIndex, + enabled = enabled, + onSelectedIndexChange = onSelect, + ) +} + +@Composable +private fun titleColors(color: Color?): BasicComponentColors = if (color != null) { + BasicComponentDefaults.titleColor(color = color, disabledColor = color.copy(alpha = 0.4f)) +} else { + BasicComponentDefaults.titleColor() +} + +/** 一行滑块:标题 + 当前值 + 滑块。 */ +@Composable +fun MiuixSliderRow( + title: String, + valueLabel: String, + position: Int, + steps: Int, + onPositionChange: (Int) -> Unit, + onDragFinished: () -> Unit = {}, +) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = title, + style = MiuixTheme.textStyles.body1, + color = MiuixTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + text = valueLabel, + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.primary, + modifier = Modifier.padding(start = 12.dp), + ) + } + Spacer(Modifier.height(10.dp)) + Slider( + value = position.toFloat(), + onValueChange = { onPositionChange(it.roundToInt()) }, + onValueChangeFinished = onDragFinished, + valueRange = 0f..steps.toFloat(), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** 可展开的一节。 */ +@Composable +fun MiuixExpandableSection( + title: String, + summary: String, + expanded: Boolean, + onToggle: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "chevron") + Column(modifier = Modifier.fillMaxWidth()) { + BasicComponent( + title = title, + summary = summary, + onClick = onToggle, + endActions = { + Icon( + painter = painterResource(R.drawable.ic_expand_more), + contentDescription = null, + tint = MiuixTheme.colorScheme.onSurfaceVariantActions, + modifier = Modifier.size(22.dp).rotate(rotation), + ) + }, + ) + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(modifier = Modifier.fillMaxWidth(), content = content) + } + } +} + +/** 一枚可开关的小标签(MultiDraw 的「禁用这些后端」)。 */ +@Composable +fun MiuixToggleChip(text: String, selected: Boolean, onClick: () -> Unit) { + // 这七个标签就是七个开关,触感要和 Miuix 的 Switch / Checkbox 一致, + // 而不是因为长得像标签就悄无声息。 + val haptic = LocalHapticFeedback.current + val container by animateColorAsState( + targetValue = if (selected) { + MiuixTheme.colorScheme.primary + } else { + MiuixTheme.colorScheme.secondaryContainer + }, + label = "chip-container", + ) + val content by animateColorAsState( + targetValue = if (selected) { + MiuixTheme.colorScheme.onPrimary + } else { + MiuixTheme.colorScheme.onSecondaryContainer + }, + label = "chip-content", + ) + Surface( + shape = RoundedCornerShape(percent = 50), + color = container, + modifier = Modifier.padding(end = 8.dp, bottom = 8.dp), + ) { + Text( + text = text, + style = MiuixTheme.textStyles.footnote1, + color = content, + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .clickable { + haptic.performHapticFeedback( + if (selected) HapticFeedbackType.ToggleOff else HapticFeedbackType.ToggleOn, + ) + onClick() + } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) + } +} + +/** 居中的加载指示。 */ +@Composable +fun MiuixLoading(text: String? = null, modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier.fillMaxWidth().padding(32.dp), + ) { + InfiniteProgressIndicator(color = MiuixTheme.colorScheme.primary) + if (text != null) { + Text( + text = text, + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(top = 16.dp), + ) + } + } +} + +/** 可选中的整段文本(GL 信息)。 */ +@Composable +fun MiuixSelectableBody(text: String, modifier: Modifier = Modifier) { + SelectionContainer(modifier = modifier) { + Text( + text = text, + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurface, + ) + } +} + +/** 页面底部留白。 */ +@Composable +fun MiuixBottomSpacer() { + Spacer(Modifier.height(24.dp)) +} + +/** 矢量图标(Miuix 的组件要 [ImageVector],不收 painter)。 */ +@Composable +fun rememberVectorIcon(id: Int): ImageVector = ImageVector.vectorResource(id) + +/** + * 记住最后一个非空值。 + * + * Miuix 的对话框靠 `show` 参数播放退场动画,状态一旦被清成 null,内容就跟着消失了—— + * 退场动画会变成「原地闪没」。留住上一份内容,动画才播得完。 + */ +@Composable +fun rememberLastNonNull(value: T?): T? { + var last by remember { mutableStateOf(value) } + if (value != null) last = value + return last +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixContributors.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixContributors.kt new file mode 100644 index 00000000..a4a023d2 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixContributors.kt @@ -0,0 +1,137 @@ +@file:OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + +package com.fcl.plugin.mobileglues.ui.miuix + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.Contributor +import com.fcl.plugin.mobileglues.ui.ContributorGroups +import kotlin.math.max +import top.yukonga.miuix.kmp.basic.SmallTitle +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 贡献者致谢(Miuix):三个仓库各一面头像墙。 + * + * 头像是随包内置的,不是运行时拉的——本应用没有 INTERNET 权限,而那是隐私政策里 + * 请用户自己去核实的一条。名单因此停在打包的那一刻,这个代价比作废那条承诺小得多。 + */ +@Composable +fun MiuixContributorsSection(controller: AppController) { + Column(modifier = Modifier.fillMaxWidth()) { + SmallTitle(text = stringResource(R.string.contributors_title)) + Text( + text = stringResource(R.string.contributors_intro), + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onBackgroundVariant, + modifier = Modifier.padding(horizontal = MiuixScreenPadding + 16.dp), + ) + + ContributorGroups.forEach { group -> + Text( + text = stringResource(group.title), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onBackgroundVariant, + modifier = Modifier.padding( + start = MiuixScreenPadding + 16.dp, + top = 16.dp, + bottom = 8.dp, + ), + ) + // The columns are measured rather than fixed. A fixed cell width + // leaves whatever does not divide evenly at the end of the row, and + // FlowRow puts all of it on the right -- at some densities that is + // nearly a whole column of empty space on one side and none on the + // other. Fitting as many cells of at least FaceMinWidth as the width + // allows and then sharing the remainder among them keeps both edges + // flush, keeps every row on the same columns, and gives the names a + // little more room before they have to be cut short. + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MiuixScreenPadding + 4.dp), + ) { + val density = LocalDensity.current + // Divided in whole pixels, not in dp. A dp cell width is a float, + // every child rounds it to pixels on its own, and `columns` of + // them can add up to a few pixels more than the row they were + // meant to fill -- at which point the last one wraps and leaves a + // column-wide hole on the right, which is the shape of the + // problem this is here to avoid. Integer division cannot exceed + // the row; the leftover is under one pixel per column. + // + // Hoisted out of FlowRow, whose scope shadows maxWidth. + val availablePx = with(density) { maxWidth.roundToPx() } + val columns = max(1, availablePx / with(density) { FaceMinWidth.roundToPx() }) + val cellWidth = with(density) { (availablePx / columns).toDp() } + FlowRow(modifier = Modifier.fillMaxWidth()) { + group.contributors.forEach { contributor -> + ContributorFace( + contributor = contributor, + width = cellWidth, + onClick = { controller.openContributor(contributor) }, + ) + } + } + } + } + } +} + +/** 一张头像加一个名字。名字比格子窄的时候截断,不去挤旁边那位。 */ +@Composable +private fun ContributorFace(contributor: Contributor, width: Dp, onClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(width) + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 2.dp), + ) { + Image( + painter = painterResource(contributor.avatar), + contentDescription = contributor.login, + contentScale = ContentScale.Crop, + modifier = Modifier.size(44.dp).clip(CircleShape), + ) + Spacer(Modifier.height(6.dp)) + Text( + text = contributor.login, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onBackgroundVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** 一格至少这么宽;实际宽度按可用宽度均分后略大于它。 */ +private val FaceMinWidth = 72.dp diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixDialogs.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixDialogs.kt new file mode 100644 index 00000000..99edf251 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixDialogs.kt @@ -0,0 +1,589 @@ +package com.fcl.plugin.mobileglues.ui.miuix + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.settings.AuthMethod +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.Responsive +import com.fcl.plugin.mobileglues.ui.AuthPrompt +import com.fcl.plugin.mobileglues.ui.ConfirmRequest +import com.fcl.plugin.mobileglues.ui.Farewell +import com.fcl.plugin.mobileglues.ui.PrivacySections +import com.fcl.plugin.mobileglues.ui.LinkEntry +import com.fcl.plugin.mobileglues.ui.SponsorChannels +import com.fcl.plugin.mobileglues.ui.sourceRepositories +import com.fcl.plugin.mobileglues.ui.SponsorPromptState +import com.fcl.plugin.mobileglues.utils.Constants +import kotlinx.coroutines.delay +import top.yukonga.miuix.kmp.basic.ButtonDefaults +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.extra.SuperDialog +import top.yukonga.miuix.kmp.theme.MiuixTheme +import androidx.compose.ui.graphics.toArgb + +/** Miuix 皮肤的全部全局对话框。与 MD3 皮肤一一对应,行为完全相同。 */ +@Composable +fun MiuixDialogHost(controller: AppController) { + val confirm by controller.confirmRequest.collectAsStateWithLifecycle() + val corrupt by controller.corruptPrompt.collectAsStateWithLifecycle() + val authPrompt by controller.authPrompt.collectAsStateWithLifecycle() + val sponsor by controller.sponsorPrompt.collectAsStateWithLifecycle() + val removing by controller.removing.collectAsStateWithLifecycle() + val farewell by controller.farewell.collectAsStateWithLifecycle() + val privacyConsentNeeded by controller.privacyConsentNeeded.collectAsStateWithLifecycle() + val resetPrompt by controller.resetPrompt.collectAsStateWithLifecycle() + val sponsorPicker by controller.sponsorPicker.collectAsStateWithLifecycle() + val repoPicker by controller.repoPicker.collectAsStateWithLifecycle() + val auth by controller.auth.state.collectAsStateWithLifecycle() + + // 道别排在最前:这时候隐私同意已经被收回了,别让同意弹窗抢在道别前面糊上来。 + farewell?.let { reason -> + MiuixMessageDialog( + show = true, + title = stringResource( + if (reason == Farewell.Removed) { + R.string.remove_complete_title + } else { + R.string.revoke_complete_title + }, + ), + message = stringResource( + if (reason == Farewell.Removed) { + R.string.remove_complete_message + } else { + R.string.revoke_complete_message + }, + ), + positive = stringResource(R.string.exit), + onPositive = controller::exitAfterFarewell, + cancelable = false, + ) + return + } + + // 首次启动:先讲清楚这个 App 会碰什么,再谈别的。 + if (privacyConsentNeeded) { + MiuixPrivacyConsentDialog( + onAccept = controller::acceptPrivacy, + onDecline = controller::declinePrivacy, + ) + return + } + + MiuixConfirmDialog(confirm) + + // 退场动画期间状态已经是 null 了,内容要从上一份非空值里取。 + val lastCorrupt = rememberLastNonNull(corrupt) + MiuixMessageDialog( + show = corrupt != null, + title = stringResource(R.string.dialog_config_corrupt_title), + message = lastCorrupt?.let { + stringResource( + R.string.dialog_config_corrupt_message, + it.backupName ?: "-", + it.cause.message ?: it.cause.javaClass.simpleName, + ) + }.orEmpty(), + positive = stringResource(R.string.dialog_config_corrupt_reset), + onPositive = controller::resetCorruptConfig, + negative = stringResource(R.string.dialog_negative), + onNegative = controller::dismissCorruptConfig, + onDismiss = controller::dismissCorruptConfig, + ) + + MiuixAuthMethodDialog( + show = authPrompt == AuthPrompt.ChooseMethod, + onSelect = controller::onAuthMethodSelected, + onDismiss = controller::dismissAuthPrompt, + ) + MiuixMessageDialog( + show = authPrompt == AuthPrompt.AllFilesIntro, + title = stringResource(R.string.dialog_permission_title), + message = stringResource(R.string.dialog_permission_msg_android_Q, Constants.MG_DIRECTORY), + positive = stringResource(R.string.dialog_positive), + onPositive = controller::proceedAllFiles, + negative = stringResource(R.string.dialog_negative), + onNegative = controller::dismissAuthPrompt, + onDismiss = controller::dismissAuthPrompt, + ) + MiuixMessageDialog( + show = authPrompt == AuthPrompt.SafGuide, + title = stringResource(R.string.auth_guide_saf_title), + message = stringResource(R.string.auth_guide_saf_msg), + positive = stringResource(R.string.dialog_positive), + onPositive = controller::proceedSaf, + negative = stringResource(R.string.dialog_negative), + onNegative = controller::dismissAuthPrompt, + onDismiss = controller::dismissAuthPrompt, + ) + MiuixMessageDialog( + show = authPrompt == AuthPrompt.LegacyDenied, + title = stringResource(R.string.dialog_permission_title), + message = stringResource(R.string.dialog_permission_msg), + positive = stringResource(R.string.dialog_positive), + onPositive = controller::proceedAppDetails, + negative = stringResource(R.string.dialog_negative), + onNegative = controller::dismissAuthPrompt, + onDismiss = controller::dismissAuthPrompt, + ) + + val lastSponsor = rememberLastNonNull(sponsor) + MiuixMessageDialog( + show = sponsor is SponsorPromptState.Ask, + title = stringResource(R.string.sponsor_dialog_title), + message = stringResource( + R.string.sponsor_dialog_msg, + (lastSponsor as? SponsorPromptState.Ask)?.launchCount ?: 0, + ), + positive = stringResource(R.string.sponsor_action_donate), + onPositive = controller::onSponsorDonate, + negative = stringResource(R.string.sponsor_action_later), + onNegative = controller::onSponsorLater, + onDismiss = controller::onSponsorLater, + ) + MiuixMessageDialog( + show = sponsor == SponsorPromptState.Confirm, + title = stringResource(R.string.sponsor_confirm_title), + message = stringResource(R.string.sponsor_confirm_msg), + positive = stringResource(R.string.sponsor_action_donated), + onPositive = controller::onSponsorDonated, + negative = stringResource(R.string.sponsor_action_not_yet), + onNegative = controller::onSponsorNotYet, + onDismiss = controller::onSponsorNotYet, + ) + + MiuixLinkChoiceDialog( + show = sponsorPicker, + title = stringResource(R.string.dialog_sponsor), + message = stringResource(R.string.sponsor_channels_msg), + links = SponsorChannels, + onSelect = controller::onSponsorChannelSelected, + onDismiss = controller::dismissSponsorPicker, + ) + + MiuixLinkChoiceDialog( + show = repoPicker, + title = stringResource(R.string.dialog_github), + message = null, + links = sourceRepositories(), + onSelect = controller::onRepositorySelected, + onDismiss = controller::dismissRepoPicker, + ) + + MiuixResetDialog( + show = resetPrompt, + canDelete = auth.granted, + onRevoke = controller::revokeAuthorization, + onRemove = controller::removeMobileGlues, + onDismiss = controller::dismissResetPrompt, + ) + + MiuixProgressDialog(show = removing, text = stringResource(R.string.removing_mobileglues)) + +} + +/** + * 首次启动的隐私政策同意框。 + * + * 里面是政策全文而不是摘要——同意的那一刻用户看到的,就该是他事后能翻回去看的那些字。 + * 不可取消:返回键和点遮罩都不算表态。 + */ +@Composable +private fun MiuixPrivacyConsentDialog(onAccept: () -> Unit, onDecline: () -> Unit) { + SuperDialog( + show = true, + title = stringResource(R.string.privacy_consent_title), + onDismissRequest = null, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.privacy_intro), + fontSize = MiuixTheme.textStyles.body1.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + ) + PrivacySections.forEach { (title, body) -> + Text( + text = stringResource(title), + style = MiuixTheme.textStyles.title4, + color = MiuixTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp), + ) + Text( + text = stringResource(body), + fontSize = MiuixTheme.textStyles.body1.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + modifier = Modifier.padding(top = 4.dp), + ) + } + Text( + text = stringResource(R.string.privacy_consent_footer), + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + modifier = Modifier.padding(top = 20.dp), + ) + } + Spacer(Modifier.size(20.dp)) + DialogButtons( + negative = stringResource(R.string.privacy_consent_decline), + onNegative = onDecline, + positive = stringResource(R.string.privacy_consent_accept), + onPositive = onAccept, + ) + } + } +} + +/** 「先问再改」确认框,可选倒计时。 */ +@Composable +private fun MiuixConfirmDialog(request: ConfirmRequest?) { + val current = rememberLastNonNull(request) + var secondsLeft by remember(current) { mutableIntStateOf(current?.countdownSeconds ?: 0) } + LaunchedEffect(current, request != null) { + if (request == null) return@LaunchedEffect + while (secondsLeft > 0) { + delay(1000) + secondsLeft-- + } + } + if (current == null) return + + val counting = secondsLeft > 0 + val errorColor = MiuixTheme.colorScheme.error + val message = if (current.messageIsHtml) { + remember(current, errorColor) { + AnnotatedString.fromHtml( + current.message.replace("@colorError", String.format("#%06X", 0xFFFFFF and errorColor.toArgb())), + ) + } + } else { + remember(current) { AnnotatedString(current.message) } + } + + SuperDialog( + show = request != null, + title = stringResource(current.titleRes), + onDismissRequest = { request?.resolve(false) }, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = message, + fontSize = MiuixTheme.textStyles.body1.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + // 短警告居中,跟 Miuix 自己的 summary 一样;长警告是一串项目符号,居中没法读。 + textAlign = if (current.messageIsHtml) TextAlign.Start else TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.size(20.dp)) + DialogButtons( + negative = stringResource(R.string.dialog_negative), + onNegative = { request?.resolve(false) }, + positive = if (counting) { + stringResource(R.string.ok_with_countdown, secondsLeft) + } else { + stringResource(current.positiveRes) + }, + onPositive = { request?.resolve(true) }, + positiveEnabled = !counting, + positiveColor = if (current.errorAccent && !counting) errorColor else null, + ) + } + } +} + +/** 标题 + 正文 + 一到两个按钮。 */ +@Composable +private fun MiuixMessageDialog( + show: Boolean, + title: String, + message: String, + positive: String, + onPositive: () -> Unit, + negative: String? = null, + onNegative: (() -> Unit)? = null, + onDismiss: (() -> Unit)? = null, + cancelable: Boolean = true, +) { + SuperDialog( + show = show, + title = title, + onDismissRequest = if (cancelable) ({ onDismiss?.invoke() }) else null, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = message, + fontSize = MiuixTheme.textStyles.body1.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.size(20.dp)) + DialogButtons( + negative = negative, + onNegative = { onNegative?.invoke() }, + positive = positive, + onPositive = onPositive, + ) + } + } +} + +/** 授权方式二选一。 */ +@Composable +private fun MiuixAuthMethodDialog( + show: Boolean, + onSelect: (AuthMethod) -> Unit, + onDismiss: () -> Unit, +) { + SuperDialog( + show = show, + title = stringResource(R.string.auth_choose_title), + summary = stringResource(R.string.auth_choose_msg), + onDismissRequest = onDismiss, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + AuthMethodOption( + title = stringResource(R.string.auth_method_all_files), + description = stringResource(R.string.auth_method_all_files_desc), + onClick = { onSelect(AuthMethod.AllFiles) }, + ) + Spacer(Modifier.size(10.dp)) + AuthMethodOption( + title = stringResource(R.string.auth_method_saf), + description = stringResource(R.string.auth_method_saf_desc), + onClick = { onSelect(AuthMethod.Saf) }, + ) + Spacer(Modifier.size(20.dp)) + DialogButtons( + negative = null, + onNegative = {}, + positive = stringResource(R.string.dialog_negative), + onPositive = onDismiss, + positiveIsPrimary = false, + ) + } + } +} + +/** + * 一组外链,选一个打开。赞助渠道和三个仓库共用它。 + * + * 每条都把网址写出来:爱发电有三个域名在用、收款方也各不相同,仓库也有三个, + * 只写个名字的话用户点下去之前并不知道会去哪里。 + */ +@Composable +private fun MiuixLinkChoiceDialog( + show: Boolean, + title: String, + message: String?, + links: List, + onSelect: (LinkEntry) -> Unit, + onDismiss: () -> Unit, +) { + SuperDialog( + show = show, + title = title, + summary = message, + onDismissRequest = onDismiss, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + links.forEachIndexed { index, link -> + if (index > 0) Spacer(Modifier.size(10.dp)) + AuthMethodOption( + title = link.label, + description = link.url, + onClick = { onSelect(link) }, + ) + } + } + Spacer(Modifier.size(20.dp)) + DialogButtons( + negative = null, + onNegative = {}, + positive = stringResource(R.string.dialog_negative), + onPositive = onDismiss, + positiveIsPrimary = false, + ) + } + } +} + +/** 撤销 / 重置的二选一。删文件那条在未授权时点不动——没有访问权就删不了。 */ +@Composable +private fun MiuixResetDialog( + show: Boolean, + canDelete: Boolean, + onRevoke: () -> Unit, + onRemove: () -> Unit, + onDismiss: () -> Unit, +) { + SuperDialog( + show = show, + title = stringResource(R.string.menu_item_reset), + onDismissRequest = onDismiss, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + AuthMethodOption( + title = stringResource(R.string.reset_option_revoke), + description = stringResource(R.string.reset_option_revoke_desc), + onClick = onRevoke, + ) + Spacer(Modifier.size(10.dp)) + AuthMethodOption( + title = stringResource(R.string.reset_option_remove), + description = if (canDelete) { + stringResource(R.string.reset_option_remove_desc) + } else { + stringResource(R.string.reset_option_remove_needs_auth) + }, + onClick = onRemove, + enabled = canDelete, + titleColor = MiuixTheme.colorScheme.error, + ) + Spacer(Modifier.size(20.dp)) + DialogButtons( + negative = null, + onNegative = {}, + positive = stringResource(R.string.dialog_negative), + onPositive = onDismiss, + positiveIsPrimary = false, + ) + } + } +} + +@Composable +private fun AuthMethodOption( + title: String, + description: String, + onClick: () -> Unit, + enabled: Boolean = true, + titleColor: Color? = null, +) { + Card(modifier = Modifier.fillMaxWidth(), onClick = if (enabled) onClick else null) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + text = title, + style = MiuixTheme.textStyles.title4, + color = (titleColor ?: MiuixTheme.colorScheme.onSurface) + .copy(alpha = if (enabled) 1f else 0.4f), + ) + Text( + text = description, + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary + .copy(alpha = if (enabled) 1f else 0.4f), + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +/** 不可取消的进度对话框。 */ +@Composable +private fun MiuixProgressDialog(show: Boolean, text: String) { + SuperDialog(show = show, onDismissRequest = null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + InfiniteProgressIndicator(color = MiuixTheme.colorScheme.primary) + Spacer(Modifier.width(20.dp)) + Text( + text = text, + style = MiuixTheme.textStyles.body1, + color = MiuixTheme.colorScheme.onSurface, + ) + } + } +} + +/** 对话框底部按钮:左取消右确认,等宽。 */ +@Composable +private fun DialogButtons( + negative: String?, + onNegative: () -> Unit, + positive: String, + onPositive: () -> Unit, + positiveEnabled: Boolean = true, + positiveIsPrimary: Boolean = true, + positiveColor: Color? = null, +) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (negative != null) { + TextButton( + text = negative, + onClick = onNegative, + modifier = Modifier.weight(1f), + ) + } + TextButton( + text = positive, + onClick = onPositive, + enabled = positiveEnabled, + modifier = Modifier.weight(1f), + colors = when { + positiveColor != null -> ButtonDefaults.textButtonColors( + textColor = positiveColor, + ) + + positiveIsPrimary -> ButtonDefaults.textButtonColorsPrimary() + else -> ButtonDefaults.textButtonColors() + }, + ) + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixHome.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixHome.kt new file mode 100644 index 00000000..2d343dc9 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixHome.kt @@ -0,0 +1,342 @@ +package com.fcl.plugin.mobileglues.ui.miuix + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Column +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.widthIn +import androidx.compose.foundation.shape.CircleShape +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.DeviceInfo +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.AppTab +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.CardDefaults +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 首页(Miuix):与 MD3 皮肤同样的内容与动画节奏,换成 Miuix 的字体与配色。 */ +@Composable +fun MiuixHomePage(controller: AppController) { + val auth by controller.auth.state.collectAsStateWithLifecycle() + val deviceInfo by controller.deviceInfo.collectAsStateWithLifecycle() + val config by controller.configStore.config.collectAsStateWithLifecycle() + val untuned by controller.multidrawUntuned.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { controller.ensureDeviceInfo() } + + // 启动次数记在 MG 目录里,未授权时读不到;授权建立之后再问一次。 + LaunchedEffect(auth.granted) { + if (auth.granted) controller.maybeShowSponsorPrompt() + } + + var entered by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { entered = true } + + // 可滚动,并且只在放得下时才居中。这一页的内容高度是固定的,竖屏放得下,横屏放 + // 不下——原来它既不滚动又强制居中,于是横屏下两端被裁掉且够不到。verticalScroll + // 加上 heightIn(min) 让它在高屏上照旧居中,在矮屏上变成一列可滚的内容。 + val scroll = rememberScrollState() + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val minHeight = maxHeight + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scroll) + .heightIn(min = minHeight) + .padding(horizontal = MiuixScreenPadding + 8.dp), + ) { + EnterUp(entered, delayMillis = 0) { Wordmark(controller.appVersionName) } + + Spacer(Modifier.height(28.dp)) + + EnterUp(entered, delayMillis = 90) { + AuthPill( + granted = auth.granted, + onClick = { if (!auth.granted) controller.requestAccess() }, + ) + } + + Spacer(Modifier.height(28.dp)) + + EnterUp(entered, delayMillis = 160) { DeviceInfoBlock(deviceInfo) } + + Spacer(Modifier.height(24.dp)) + + EnterUp(entered, delayMillis = 230) { + Crossfade(targetState = config, label = "summary") { current -> + if (current != null) { + ConfigSummaryCard( + summary = controller.configSummary(current), + onClick = { controller.navigateTab(AppTab.Settings) }, + ) + } else { + Spacer(Modifier.height(1.dp)) + } + } + } + + // 排序还是出厂那份,没人量过这台设备。 + // 两层动画各管各的:外层跟着首页那串进场依次上来,内层负责跑完分采用之后自己收走。 + EnterUp(entered, delayMillis = 300) { + AnimatedVisibility( + visible = untuned && auth.granted, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(16.dp)) + BenchmarkNudge( + onClick = { + controller.runMultidrawBench(AppController.BenchTarget.AllEntries) + }, + ) + } + } + } + + // 底部留白把重心顶高一点;矮屏上它是滚动内容的一部分,不再是够不到的死区。 + Spacer(Modifier.height(72.dp)) + } + } +} + +@Composable +private fun Wordmark(version: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = MiuixTheme.colorScheme.onBackground)) { append("Mobile") } + withStyle(SpanStyle(color = MiuixTheme.colorScheme.primary)) { append("Glues") } + }, + style = MiuixTheme.textStyles.title1, + fontSize = 40.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 1, + ) + Text( + text = "v$version", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onBackgroundVariant, + modifier = Modifier.padding(top = 6.dp), + ) + } +} + +@Composable +private fun AuthPill(granted: Boolean, onClick: () -> Unit) { + val container by animateColorAsState( + targetValue = if (granted) { + MiuixTheme.colorScheme.primaryContainer + } else { + MiuixTheme.colorScheme.errorContainer + }, + animationSpec = tween(320), + label = "pill-container", + ) + val content by animateColorAsState( + targetValue = if (granted) { + MiuixTheme.colorScheme.onPrimaryContainer + } else { + MiuixTheme.colorScheme.error + }, + animationSpec = tween(320), + label = "pill-content", + ) + + Surface(shape = CircleShape, color = container) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(CircleShape) + .clickable(enabled = !granted, onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(content)) + Spacer(Modifier.size(10.dp)) + Text( + text = stringResource( + if (granted) R.string.home_status_granted else R.string.home_status_denied, + ), + style = MiuixTheme.textStyles.body2, + color = content, + ) + } + } +} + +@Composable +private fun DeviceInfoBlock(info: DeviceInfo?) { + val unknown = stringResource(R.string.home_device_unknown) + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(), + ) { + DeviceInfoRow( + label = stringResource(R.string.home_device_gpu), + value = info?.gpuRenderer?.takeIf { it.isNotBlank() } ?: unknown, + loaded = info != null, + ) + DeviceInfoRow( + label = stringResource(R.string.home_device_gles), + value = info?.glesVersion?.takeIf { it.isNotBlank() } ?: unknown, + loaded = info != null, + ) + DeviceInfoRow( + label = stringResource(R.string.home_device_ram), + value = info?.let { stringResource(R.string.home_ram_value, it.totalRamBytes / GIBIBYTE) } + ?: unknown, + loaded = info != null, + ) + } +} + +@Composable +private fun DeviceInfoRow(label: String, value: String, loaded: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onBackgroundVariant, + ) + Spacer(Modifier.size(16.dp)) + Crossfade(targetState = loaded, label = "device-value", modifier = Modifier.weight(1f)) { + Text( + text = if (it) value else "…", + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onBackground, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun ConfigSummaryCard(summary: String, onClick: () -> Unit) { + Card( + modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(), + onClick = onClick, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), + ) { + Text( + text = summary, + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.home_config_hint), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +/** + * 「还没量过这台设备」的提示。 + * + * 说一句、给一个按钮,不解释原理——首页不是讲道理的地方。一旦排序不再是默认(自己拖过, + * 或采用了跑分结果),它自己就不出现了,所以不需要「不再提示」。 + */ +@Composable +private fun BenchmarkNudge(onClick: () -> Unit) { + // 用主题自己的 errorContainer 而不是手调一个红:深浅两套都由主题给, + // 不会跟这一页其它颜色打架。 + Card( + modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(), + colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.errorContainer), + onClick = onClick, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), + ) { + Text( + text = stringResource(R.string.md_home_untuned), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.md_home_untuned_action), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun EnterUp(visible: Boolean, delayMillis: Int, content: @Composable () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(durationMillis = 420, delayMillis = delayMillis)) + + slideInVertically( + animationSpec = tween( + durationMillis = 420, + delayMillis = delayMillis, + easing = FastOutSlowInEasing, + ), + ) { it / 3 }, + exit = ExitTransition.None, + ) { + content() + } +} + +private const val GIBIBYTE = 1024.0 * 1024.0 * 1024.0 diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixInfo.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixInfo.kt new file mode 100644 index 00000000..f2e88304 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixInfo.kt @@ -0,0 +1,104 @@ +package com.fcl.plugin.mobileglues.ui.miuix + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.AppSubPage +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.scrollEndHaptic + +/** 信息页(Miuix)。 */ +@Composable +fun MiuixInfoPage(controller: AppController) { + val auth by controller.auth.state.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + // 甩到顶或底时给一下振动——HyperOS 的滚动到此为止就是这个手感。 + .scrollEndHaptic() + .verticalScroll(rememberScrollState()), + ) { + MiuixPageTitle(stringResource(R.string.nav_info)) + + MiuixGroup(title = stringResource(R.string.info_section_app)) { + MiuixTextRow( + title = stringResource(R.string.info_version_label), + summary = controller.appVersionName, + ) + MiuixArrowRow( + title = stringResource(R.string.dialog_github), + summary = stringResource(R.string.repo_summary), + onClick = controller::openSourceRepositories, + ) + MiuixArrowRow( + title = stringResource(R.string.dialog_sponsor), + summary = stringResource(R.string.sponsor_channels_summary), + onClick = controller::openSponsorChannels, + ) + } + + MiuixGroup(title = stringResource(R.string.info_section_about)) { + MiuixTextRow( + title = label(R.string.view_author), + summary = stringResource(R.string.info_author), + ) + MiuixTextRow( + title = label(R.string.view_copyright), + summary = stringResource(R.string.info_copyright), + ) + MiuixTextRow( + title = label(R.string.view_launcher), + summary = stringResource(R.string.info_launcher), + ) + MiuixTextRow( + title = label(R.string.view_logo), + summary = stringResource(R.string.info_logo), + ) + MiuixArrowRow( + title = stringResource(R.string.third_party_title), + summary = stringResource(R.string.third_party_summary), + onClick = { controller.openSubPage(AppSubPage.ThirdParty) }, + ) + } + + MiuixGroup(title = stringResource(R.string.info_section_details)) { + MiuixArrowRow( + title = stringResource(R.string.info_mg_info), + onClick = { controller.openSubPage(AppSubPage.GlInfo) }, + ) + MiuixArrowRow( + title = stringResource(R.string.info_privacy), + onClick = { controller.openSubPage(AppSubPage.Privacy) }, + ) + } + + MiuixGroup(title = stringResource(R.string.info_danger_zone)) { + // 撤销和删除是包含关系,摆成两个平级按钮会互相锁死:撤了就删不动, + // 删完了也没什么可撤。所以入口只有一个,进去再选做到哪一步。 + MiuixArrowRow( + title = stringResource(R.string.menu_item_reset), + titleColor = MiuixTheme.colorScheme.error, + onClick = controller::openResetPrompt, + ) + } + + // 致谢摆在最后:它是这一页读到底之后的落款,不是一个要跳过去的功能。 + MiuixContributorsSection(controller) + + MiuixBottomSpacer() + } +} + +/** 「版本:」这类文案自带冒号,当行标题用的时候要去掉。 */ +@Composable +private fun label(@StringRes id: Int): String = stringResource(id).trimEnd(' ', ':', ':') diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixMultidraw.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixMultidraw.kt new file mode 100644 index 00000000..22b5bfeb --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixMultidraw.kt @@ -0,0 +1,594 @@ +package com.fcl.plugin.mobileglues.ui.miuix + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.settings.MGConfig +import com.fcl.plugin.mobileglues.settings.MultidrawBenchQuality +import com.fcl.plugin.mobileglues.settings.MultidrawEntry +import com.fcl.plugin.mobileglues.settings.MultidrawOrderItem +import com.fcl.plugin.mobileglues.settings.MultidrawSettings +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.Responsive +import com.fcl.plugin.mobileglues.ui.DragReorderColumn +import top.yukonga.miuix.kmp.basic.ButtonDefaults +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator +import top.yukonga.miuix.kmp.basic.LinearProgressIndicator +import top.yukonga.miuix.kmp.basic.SmallTitle +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.extra.SuperDialog +import top.yukonga.miuix.kmp.theme.MiuixTheme +import java.util.Locale + +/** 折叠状态下的一句话摘要。 */ +@Composable +fun miuixMultidrawSummary(settings: MultidrawSettings): String { + val exceptionCount = settings.exceptions.size + if (!settings.globalCustomized && exceptionCount == 0) { + return stringResource(R.string.md_summary_default) + } + val global = if (settings.globalCustomized) { + stringResource(R.string.md_summary_global_custom) + } else { + stringResource(R.string.md_summary_global_default) + } + return if (exceptionCount == 0) { + global + } else { + "$global · ${stringResource(R.string.md_summary_exception_count, exceptionCount)}" + } +} + +/** MultiDraw 排序设置(Miuix 版):全局 8 项排序 + 每函数例外排序 + benchmark。 */ +@Composable +fun ColumnScope.MiuixMultidrawOrderContent(controller: AppController, config: MGConfig) { + val context = LocalContext.current + val settings = config.multidraw + + MiuixSectionHint(stringResource(R.string.md_order_hint)) + + DragReorderColumn( + items = settings.globalOrder, + onMove = controller::moveMultidrawGlobalItem, + ) { index, item, dragging, handle -> + MiuixOrderRow( + position = index + 1, + label = item.label(context).toString(), + sublabel = if (item == MultidrawOrderItem.Native) { + stringResource(R.string.md_item_native_desc) + } else { + null + }, + dragging = dragging, + handle = handle, + ) + } + + AnimatedVisibility(visible = settings.globalCustomized, enter = fadeIn(), exit = fadeOut()) { + TextButton( + text = stringResource(R.string.md_reset_default), + onClick = controller::resetMultidrawGlobalOrder, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + // ---- 例外函数 ---- + + SmallTitle(text = stringResource(R.string.md_exceptions_group)) + + // 跑分只测得出「这个函数上哪个方案快」,那就把结果按函数交出去,别硬合成一份全局顺序。 + TextButton( + text = stringResource(R.string.md_bench_run_all), + onClick = { controller.runMultidrawBench(AppController.BenchTarget.AllEntries) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + + MultidrawEntry.entries.forEach { entry -> + val hasException = settings.hasException(entry) + MiuixSwitchRow( + title = entry.glFunction, + summary = if (hasException) { + stringResource(R.string.md_exception_on) + } else { + stringResource(R.string.md_exception_off) + }, + checked = hasException, + onCheckedChange = { controller.setMultidrawException(entry, it) }, + ) + AnimatedVisibility( + visible = hasException, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp)) { + DragReorderColumn( + items = settings.effectiveOrderFor(entry), + onMove = { from, to -> + controller.moveMultidrawExceptionItem(entry, from, to) + }, + ) { index, backend, dragging, handle -> + MiuixOrderRow( + position = index + 1, + label = backend.label(context).toString(), + sublabel = null, + dragging = dragging, + handle = handle, + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton( + text = stringResource(R.string.md_bench_run_entry), + onClick = { + controller.runMultidrawBench(AppController.BenchTarget.Entry(entry)) + }, + modifier = Modifier.weight(1f), + ) + AnimatedVisibility( + visible = settings.exceptionCustomized(entry), + enter = fadeIn(), + exit = fadeOut(), + ) { + TextButton( + text = stringResource(R.string.md_reset_default), + onClick = { controller.resetMultidrawExceptionOrder(entry) }, + ) + } + } + } + } + } +} + +@Composable +private fun MiuixSectionHint(text: String) { + Text( + text = text, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) +} + +/** 一行可排序项:序号 + 名称 + 拖动手柄;被拖起来的那行浮到卡片上方。 */ +@Composable +private fun MiuixOrderRow( + position: Int, + label: String, + sublabel: String?, + dragging: Boolean, + handle: Modifier, +) { + // HyperOS 的拖动是「托起来一块」而不是投影,所以只换底色不加阴影。 + val background by animateColorAsState( + targetValue = if (dragging) { + MiuixTheme.colorScheme.secondaryContainer + } else { + Color.Transparent + }, + label = "drag-background", + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(14.dp)) + .background(background) + .heightIn(min = 46.dp) + .padding(start = 8.dp), + ) { + Surface( + shape = CircleShape, + color = MiuixTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(22.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = position.toString(), + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSecondaryContainer, + ) + } + } + Column(modifier = Modifier.weight(1f).padding(start = 12.dp, top = 6.dp, bottom = 6.dp)) { + Text( + text = label, + style = MiuixTheme.textStyles.body1, + color = MiuixTheme.colorScheme.onSurface, + ) + if (sublabel != null) { + Text( + text = sublabel, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } + Box( + contentAlignment = Alignment.Center, + modifier = handle.size(44.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = stringResource(R.string.md_order_drag_handle), + tint = if (dragging) { + MiuixTheme.colorScheme.primary + } else { + MiuixTheme.colorScheme.onSurfaceVariantActions + }, + modifier = Modifier.size(22.dp), + ) + } + } +} + +// ---- Benchmark 对话框 ---- + +/** + * 选一个启动器把 ANGLE 借来。 + * + * 这是把别的应用的原生代码载入本进程,所以选择必须是用户明确做出的,而且要把这句话 + * 当着他的面说清楚——不能因为「只有一个来源」就替他默认。 + */ +@Composable +private fun MiuixAngleSourceDialog(controller: AppController) { + val prompt by controller.angleSourcePrompt.collectAsStateWithLifecycle() + val last = rememberLastNonNull(prompt) + + SuperDialog( + show = prompt != null, + title = stringResource(R.string.md_angle_title), + onDismissRequest = controller::dismissAngleSourcePrompt, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.md_angle_intro), + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + ) + Spacer(Modifier.height(12.dp)) + if (last?.sources.isNullOrEmpty()) { + Text( + text = stringResource(R.string.md_angle_none), + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + ) + } else { + Text( + text = AnnotatedString.fromHtml(stringResource(R.string.md_angle_trust)), + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = MiuixTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + last?.sources?.forEach { source -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MiuixTheme.colorScheme.secondaryContainer) + .clickable { controller.confirmAngleSource(source) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = source.label, + style = MiuixTheme.textStyles.body1, + color = MiuixTheme.colorScheme.onSurface, + ) + if (source.packageName == last?.lastChosen) { + Text( + text = stringResource(R.string.md_angle_last_used), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + Text( + text = source.packageName + + (source.versionName?.let { + " · " + stringResource(R.string.md_angle_source_version, it) + } ?: ""), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } + } + } + Spacer(Modifier.height(12.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + text = stringResource(R.string.dialog_negative), + onClick = controller::dismissAngleSourcePrompt, + modifier = Modifier.weight(1f), + ) + TextButton( + text = stringResource(R.string.md_angle_skip), + onClick = controller::continueWithoutAngle, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +fun MiuixMultidrawBenchDialogs(controller: AppController) { + val context = LocalContext.current + val state by controller.benchState.collectAsStateWithLifecycle() + + MiuixAngleSourceDialog(controller) + + // 退场动画期间内容要从上一份非空值里取。 + val lastState = rememberLastNonNull(state) + + SuperDialog( + show = state is AppController.BenchState.Running, + title = stringResource(R.string.md_bench_running_title), + onDismissRequest = null, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + InfiniteProgressIndicator(color = MiuixTheme.colorScheme.primary) + Spacer(Modifier.width(20.dp)) + val running = state as? AppController.BenchState.Running + val attempt = running?.attempt ?: 1 + val smaller = running?.retryingAtSections + Text( + text = when { + // 退让优先于「第几次测量」:上一趟整份作废了,说「第 2 次」 + // 会让人以为前面那趟还算数。 + smaller != null -> + stringResource(R.string.md_bench_running_smaller, smaller) + attempt > 1 -> stringResource( + R.string.md_bench_running_retry, + attempt, + AppController.BENCH_MAX_ATTEMPTS, + ) + else -> stringResource(R.string.md_bench_running_msg) + }, + fontSize = MiuixTheme.textStyles.body1.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + ) + } + // 进度是 native 那边的原子计数器;老渲染器没有,那就只剩上面那圈。 + val progress = (state as? AppController.BenchState.Running)?.progress + if (progress != null) { + val animated by animateFloatAsState(progress, label = "bench-progress") + LinearProgressIndicator( + progress = animated, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp), + ) + } + } + } + + val doneState = lastState as? AppController.BenchState.Done + SuperDialog( + show = state is AppController.BenchState.Done, + title = stringResource(R.string.md_bench_result_title), + onDismissRequest = controller::dismissBench, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = Responsive.dialogMaxContentHeight()) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = when (val target = doneState?.target) { + is AppController.BenchTarget.Entry -> stringResource( + R.string.md_bench_result_entry_intro, + target.entry.glFunction, + ) + else -> stringResource(R.string.md_bench_result_all_intro) + }, + fontSize = MiuixTheme.textStyles.body1.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + // 驱动错了就不只是「不够准」,是整份名次搬不过去,得说在最前面。 + val angleNote = doneState?.angleNote + if (angleNote != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(angleNote.messageRes), + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = MiuixTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + // 单个函数时函数名已经在开头那句里了,再标一次是废话。 + val showHeadings = doneState?.target !is AppController.BenchTarget.Entry + doneState?.rankings?.forEach { (entry, ranking) -> + Spacer(Modifier.heightIn(min = 12.dp)) + if (showHeadings) { + Text( + text = entry.glFunction, + style = MiuixTheme.textStyles.subtitle, + color = MiuixTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + ranking.forEachIndexed { index, ranked -> + MiuixRankedRow(index + 1, ranked.item.label(context).toString(), ranked.relativeCost) + } + // 只有一个方案测得出时,「排名」名不副实——没有可比较的对象。 + if (ranking.count { it.relativeCost != null } == 1) { + Text( + text = stringResource(R.string.md_bench_single_candidate), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + ) + } + // 成色跟着它描述的那份排名走:抖的是某个函数,不是整场跑分。 + MiuixBenchQualityNote(doneState.quality[entry]) + } + } + Spacer(Modifier.heightIn(min = 12.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + text = stringResource(R.string.md_bench_discard), + onClick = controller::dismissBench, + modifier = Modifier.weight(1f), + ) + TextButton( + text = stringResource( + if (doneState?.anyNoisy == true || doneState?.driverMismatch == true) { + R.string.md_bench_adopt_anyway + } else { + R.string.md_bench_adopt + }, + ), + onClick = controller::adoptBenchResult, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } + + val failedState = lastState as? AppController.BenchState.Failed + SuperDialog( + show = state is AppController.BenchState.Failed, + title = stringResource(R.string.md_bench_result_title), + onDismissRequest = controller::dismissBench, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = failedState?.message.orEmpty(), + fontSize = MiuixTheme.textStyles.body1.fontSize, + color = MiuixTheme.colorScheme.onSurfaceSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.heightIn(min = 12.dp)) + TextButton( + text = stringResource(R.string.ok), + onClick = controller::dismissBench, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } +} + +/** 一份排名底下的一行小字:这个函数测了几轮、抖动多大。抖动盖过名次差时得说出来。 */ +@Composable +private fun MiuixBenchQualityNote(quality: MultidrawBenchQuality?) { + if (quality == null || quality.rounds <= 0) return + val noise = String.format(Locale.US, "%.1f", quality.noise * 100) + Text( + text = stringResource(R.string.md_bench_quality, quality.rounds, noise), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + if (quality.noisy) { + Text( + text = stringResource(R.string.md_bench_noisy, quality.attempts, noise), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun MiuixRankedRow(position: Int, label: String, relativeCost: Double?) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Text( + text = "$position.", + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(end = 8.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurface, + ) + if (relativeCost == null) { + Text( + text = stringResource(R.string.md_bench_unmeasured), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } + if (relativeCost != null) { + Text( + text = stringResource( + R.string.md_bench_cost, + String.format(Locale.US, "%.2f", relativeCost), + ), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.primary, + ) + } + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixSettings.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixSettings.kt new file mode 100644 index 00000000..f54a8077 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixSettings.kt @@ -0,0 +1,280 @@ +@file:OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + +package com.fcl.plugin.mobileglues.ui.miuix + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.settings.AngleConfig +import com.fcl.plugin.mobileglues.settings.DepthClearFixMode +import com.fcl.plugin.mobileglues.settings.GlVersion +import com.fcl.plugin.mobileglues.settings.GlslCacheScale +import com.fcl.plugin.mobileglues.settings.MGConfig +import com.fcl.plugin.mobileglues.settings.NoErrorConfig +import com.fcl.plugin.mobileglues.settings.SpinnerOption +import com.fcl.plugin.mobileglues.settings.UiStyle +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.SettingsLoadState +import top.yukonga.miuix.kmp.basic.Button +import top.yukonga.miuix.kmp.basic.ButtonDefaults +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.TabRowWithContour +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.scrollEndHaptic + +/** + * 设置页(Miuix)。 + * + * 分组、权限门、每一处警告与倒计时都与 MD3 皮肤一致——它们都在 [AppController] 里。 + * 不同的只是表达方式:这里的选项用 Miuix 的就地下拉,而不是弹出一个对话框。 + */ +@Composable +fun MiuixSettingsPage(controller: AppController) { + val auth by controller.auth.state.collectAsStateWithLifecycle() + val loadState by controller.loadState.collectAsStateWithLifecycle() + val config by controller.configStore.config.collectAsStateWithLifecycle() + val uiStyle by controller.pluginConfig.uiStyle.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { controller.ensureDeviceInfo() } + + Column( + modifier = Modifier + .fillMaxSize() + // 甩到顶或底时给一下振动——HyperOS 的滚动到此为止就是这个手感。 + .scrollEndHaptic() + .verticalScroll(rememberScrollState()), + ) { + MiuixPageTitle(stringResource(R.string.nav_settings)) + + MiuixGroup(title = stringResource(R.string.settings_group_plugin)) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + text = stringResource(R.string.ui_style), + style = MiuixTheme.textStyles.body1, + color = MiuixTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(10.dp)) + TabRowWithContour( + tabs = listOf( + stringResource(R.string.ui_style_material), + stringResource(R.string.ui_style_miuix), + ), + selectedTabIndex = uiStyle.ordinal, + onTabSelected = { controller.pluginConfig.setUiStyle(UiStyle.entries[it]) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Crossfade( + targetState = auth.granted to (loadState == SettingsLoadState.Ready && config != null), + label = "settings-gate", + ) { (granted, ready) -> + Column(modifier = Modifier.fillMaxWidth()) { + when { + !granted -> PermissionGate(onGrant = controller::requestAccess) + ready -> ConfigSections(controller, config ?: MGConfig.Default) + else -> MiuixLoading(modifier = Modifier.padding(top = 48.dp)) + } + } + } + + MiuixBottomSpacer() + } +} + +@Composable +private fun PermissionGate(onGrant: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MiuixScreenPadding, vertical = 20.dp), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) { + Text( + text = stringResource(R.string.settings_gate_title), + style = MiuixTheme.textStyles.title4, + color = MiuixTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.settings_gate_msg), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(top = 8.dp), + ) + Spacer(Modifier.height(20.dp)) + Button(onClick = onGrant, colors = ButtonDefaults.buttonColorsPrimary()) { + Text( + text = stringResource(R.string.settings_gate_grant), + style = MiuixTheme.textStyles.button, + color = MiuixTheme.colorScheme.onPrimary, + ) + } + } + } +} + +@Composable +private fun ConfigSections(controller: AppController, config: MGConfig) { + val context = LocalContext.current + val deviceInfo by controller.deviceInfo.collectAsStateWithLifecycle() + val cacheBytes by controller.configStore.glslCacheBytes.collectAsStateWithLifecycle() + var multidrawExpanded by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxWidth()) { + MiuixGroup(title = stringResource(R.string.settings_group_render)) { + OptionRow( + title = stringResource(R.string.option_angle), + options = AngleConfig.entries, + selected = config.angle, + onSelect = controller::selectAngle, + ) + OptionRow( + title = stringResource(R.string.option_no_error), + options = NoErrorConfig.entries, + selected = config.noError, + onSelect = controller::selectNoError, + ) + OptionRow( + title = stringResource(R.string.option_angle_clear_workaround), + options = DepthClearFixMode.entries, + selected = config.depthClearFix, + onSelect = controller::selectDepthClearFix, + ) + MiuixSwitchRow( + title = stringResource(R.string.option_enable_fsr1), + checked = config.fsr1Enabled, + onCheckedChange = controller::setFsr1, + ) + } + + MiuixGroup(title = stringResource(R.string.settings_group_cache)) { + GlslCacheSlider(controller, config, deviceInfo?.totalRamBytes) + // 没有缓存文件时不摆一个删不了东西的按钮:它按需浮现,删完收回。 + AnimatedVisibility( + visible = cacheBytes != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + MiuixArrowRow( + title = stringResource( + R.string.option_glsl_cache_delete, + controller.formatCacheSize(cacheBytes ?: 0L), + ), + titleColor = MiuixTheme.colorScheme.error, + onClick = controller::deleteGlslCache, + ) + } + } + + MiuixGroup(title = stringResource(R.string.settings_group_ext)) { + MiuixSwitchRow( + title = stringResource(R.string.option_ext_cs), + checked = config.extComputeShader, + onCheckedChange = controller::setExtComputeShader, + ) + MiuixSwitchRow( + // 磁盘上记的是「启用」,界面上问的是「禁用」,取反只发生在这一行。 + title = stringResource(R.string.option_ext_timer_query), + checked = !config.extTimerQuery, + onCheckedChange = controller::setExtTimerQueryDisabled, + ) + MiuixSwitchRow( + title = stringResource(R.string.option_ext_direct_state_access), + checked = config.extDirectStateAccess, + onCheckedChange = controller::setExtDirectStateAccess, + ) + } + + MiuixGroup(title = stringResource(R.string.settings_group_advanced)) { + OptionRow( + title = stringResource(R.string.option_custom_gl_version), + options = GlVersion.entries, + selected = config.glVersion, + onSelect = controller::selectGlVersion, + ) + + MiuixExpandableSection( + title = stringResource(R.string.option_multidraw), + summary = miuixMultidrawSummary(config.multidraw), + expanded = multidrawExpanded, + onToggle = { multidrawExpanded = !multidrawExpanded }, + ) { + MiuixMultidrawOrderContent(controller, config) + } + } + } + +} + +/** + * 缓存上限滑块。 + * + * 拖动期间用本地档位,松手才交还给配置:档位 → MiB → 档位 的换算有取整, + * 直接跟着配置画的话手指底下的滑块会自己抖。 + */ +@Composable +private fun GlslCacheSlider(controller: AppController, config: MGConfig, totalRamBytes: Long?) { + val mebibytes = config.glslCache.mebibytesOrZero + val base = totalRamBytes?.let { GlslCacheScale.baseCeiling(it) } + ?: GlslCacheScale.MIN_UPPER_BOUND_MIB.toInt() + val ceiling = maxOf(base, mebibytes) + var dragPosition by remember { mutableStateOf(null) } + + MiuixSliderRow( + title = stringResource(R.string.option_glsl_cache), + valueLabel = if (mebibytes > 0) { + stringResource(R.string.option_glsl_cache_value, mebibytes) + } else { + stringResource(R.string.option_glsl_cache_off) + }, + position = dragPosition ?: GlslCacheScale.positionFor(mebibytes, ceiling), + steps = GlslCacheScale.STEPS, + onPositionChange = { position -> + dragPosition = position + controller.setGlslCacheSliderPosition(position, ceiling) + }, + onDragFinished = { dragPosition = null }, + ) +} + +/** 枚举 → 下拉行。选项顺序就是枚举的声明顺序,不会出现「选项与取值对不上」。 */ +@Composable +private fun OptionRow( + title: String, + options: List, + selected: T, + onSelect: (T) -> Unit, +) { + val context = LocalContext.current + MiuixDropdownRow( + title = title, + options = options.map { it.label(context).toString() }, + selectedIndex = options.indexOf(selected), + onSelect = { onSelect(options[it]) }, + ) +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixSubPages.kt b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixSubPages.kt new file mode 100644 index 00000000..e23d3293 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/ui/miuix/MiuixSubPages.kt @@ -0,0 +1,252 @@ +package com.fcl.plugin.mobileglues.ui.miuix + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fcl.plugin.mobileglues.R +import com.fcl.plugin.mobileglues.ui.AppController +import com.fcl.plugin.mobileglues.ui.PrivacySections +import com.fcl.plugin.mobileglues.ui.ThirdPartyGroups +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.scrollEndHaptic + +/** GL 信息页(Miuix)。 */ +@Composable +fun MiuixGlInfoPage(controller: AppController) { + val context = LocalContext.current + val info by controller.glInfo.collectAsStateWithLifecycle() + val loading by controller.glInfoLoading.collectAsStateWithLifecycle() + val needsAngle by controller.glInfoNeedsAngle.collectAsStateWithLifecycle() + val angleState by controller.glInfoAngle.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { controller.loadGlInfo() } + + MiuixSubPage( + title = stringResource(R.string.dialog_mg_gl_info_title), + onBack = { controller.navigateBack() }, + actions = { + AnimatedVisibility( + visible = !info.isNullOrBlank(), + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut(targetScale = 0.8f), + ) { + IconButton(onClick = { copyGlInfo(context, controller, info.orEmpty()) }) { + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.copy), + tint = MiuixTheme.colorScheme.onSurfaceVariantActions, + modifier = Modifier.size(22.dp), + ) + } + } + }, + ) { + Crossfade(targetState = loading, label = "gl-info") { busy -> + if (busy) { + MiuixLoading( + text = stringResource(R.string.gl_info_loading), + modifier = Modifier.padding(top = 48.dp), + ) + } else { + Column { + // ANGLE 随启动器走,本 App 里没有;不借的话这一页讲的是系统驱动, + // 不是游戏里那个。借不借由用户点——不能因为他只想看一眼就自作主张 + // 把别人的原生代码载进来。 + if (needsAngle) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MiuixScreenPadding, vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(18.dp)) { + Text( + text = stringResource(R.string.md_glinfo_needs_angle), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.primary, + ) + TextButton( + text = stringResource(R.string.md_glinfo_borrow), + onClick = { controller.reloadGlInfoWithAngle() }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } + } else if (angleState == AppController.GlInfoAngle.Borrowed) { + Text( + text = stringResource(R.string.md_glinfo_borrowed), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = MiuixScreenPadding, vertical = 4.dp), + ) + } else if (angleState == AppController.GlInfoAngle.BorrowIneffective) { + Text( + text = stringResource(R.string.md_glinfo_borrow_ineffective), + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = MiuixScreenPadding, vertical = 4.dp), + ) + } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MiuixScreenPadding), + ) { + MiuixSelectableBody( + text = info.orEmpty(), + modifier = Modifier.padding(18.dp), + ) + } + } + } + } + MiuixBottomSpacer() + } +} + +private fun copyGlInfo(context: Context, controller: AppController, text: String) { + val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return + clipboard.setPrimaryClip(ClipData.newPlainText(GL_INFO_CLIP_LABEL, text)) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + controller.snackbar(context.getString(R.string.copied)) + } +} + +/** 隐私政策页(Miuix)。 */ +@Composable +fun MiuixPrivacyPage(controller: AppController) { + MiuixSubPage( + title = stringResource(R.string.info_privacy), + onBack = { controller.navigateBack() }, + ) { + Text( + text = stringResource(R.string.privacy_intro), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onBackgroundVariant, + modifier = Modifier.padding(horizontal = MiuixScreenPadding + 16.dp, vertical = 8.dp), + ) + // 标题在卡片外、正文在卡片内——和设置页的分组是同一套语法。 + PrivacySections.forEach { (title, body) -> + MiuixGroup( + title = stringResource(title), + titleColor = MiuixTheme.colorScheme.onSurface, + ) { + Text( + text = stringResource(body), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + } + MiuixBottomSpacer() + } +} + +/** + * 第三方开源项目(Miuix)。 + * + * 分「渲染器」和「插件」两组:用户看到 SPIRV-Cross 的时候,应该同时知道它是被游戏里 + * 那个 .so 用的,而不是被这个设置界面用的。每一项都能点开自己的主页去看许可证原文—— + * 在这里抄一份许可证全文,既没人读,也保证不了和上游一致。 + */ +@Composable +fun MiuixThirdPartyPage(controller: AppController) { + MiuixSubPage( + title = stringResource(R.string.third_party_title), + onBack = { controller.navigateBack() }, + ) { + Text( + text = stringResource(R.string.third_party_intro), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onBackgroundVariant, + modifier = Modifier.padding(horizontal = MiuixScreenPadding + 16.dp, vertical = 8.dp), + ) + ThirdPartyGroups.forEach { group -> + MiuixGroup(title = stringResource(group.title)) { + group.components.forEach { component -> + MiuixArrowRow( + title = component.name, + summary = "${component.author} · ${component.license}", + onClick = { controller.openThirdPartyComponent(component) }, + ) + } + } + } + MiuixBottomSpacer() + } +} + +/** 子页面骨架:返回 + 标题 + 操作,下面是可滚动内容。 */ +@Composable +private fun MiuixSubPage( + title: String, + onBack: () -> Unit, + actions: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + ) { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back), + tint = MiuixTheme.colorScheme.onBackground, + modifier = Modifier.size(22.dp), + ) + } + Text( + text = title, + style = MiuixTheme.textStyles.title4, + color = MiuixTheme.colorScheme.onBackground, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + actions() + } + Column( + modifier = Modifier + .fillMaxSize() + // 甩到顶或底时给一下振动——HyperOS 的滚动到此为止就是这个手感。 + .scrollEndHaptic() + .verticalScroll(rememberScrollState()), + ) { + content() + } + } +} + +private const val GL_INFO_CLIP_LABEL = "MobileGlues GL info" diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/utils/Constants.kt b/app/src/main/java/com/fcl/plugin/mobileglues/utils/Constants.kt index e7be42e6..e6047c66 100644 --- a/app/src/main/java/com/fcl/plugin/mobileglues/utils/Constants.kt +++ b/app/src/main/java/com/fcl/plugin/mobileglues/utils/Constants.kt @@ -3,10 +3,11 @@ package com.fcl.plugin.mobileglues.utils import android.os.Environment object Constants { + const val CONFIG_FILE_NAME: String = "config.json" + val MG_DIRECTORY: String = "${Environment.getExternalStorageDirectory().absolutePath}/MG" - val CONFIG_FILE_PATH: String = "$MG_DIRECTORY/config.json" + val CONFIG_FILE_PATH: String = "$MG_DIRECTORY/$CONFIG_FILE_NAME" val GLSL_CACHE_FILE_PATH: String = "$MG_DIRECTORY/glsl_cache.tmp" } - diff --git a/app/src/main/res/drawable-nodpi/avatar_ahmet53535353.webp b/app/src/main/res/drawable-nodpi/avatar_ahmet53535353.webp new file mode 100644 index 00000000..c33b2d0e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_ahmet53535353.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_alexytomi.webp b/app/src/main/res/drawable-nodpi/avatar_alexytomi.webp new file mode 100644 index 00000000..dee43a72 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_alexytomi.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_aparttusitu.webp b/app/src/main/res/drawable-nodpi/avatar_aparttusitu.webp new file mode 100644 index 00000000..cdfc356d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_aparttusitu.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_bzlzhh.webp b/app/src/main/res/drawable-nodpi/avatar_bzlzhh.webp new file mode 100644 index 00000000..f37eb43b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_bzlzhh.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_chy2240.webp b/app/src/main/res/drawable-nodpi/avatar_chy2240.webp new file mode 100644 index 00000000..ac8e7dbf Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_chy2240.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_confectionaryqwq.webp b/app/src/main/res/drawable-nodpi/avatar_confectionaryqwq.webp new file mode 100644 index 00000000..5b919d09 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_confectionaryqwq.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_crystall1nedev.webp b/app/src/main/res/drawable-nodpi/avatar_crystall1nedev.webp new file mode 100644 index 00000000..4776dad1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_crystall1nedev.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_dhjcomical.webp b/app/src/main/res/drawable-nodpi/avatar_dhjcomical.webp new file mode 100644 index 00000000..e270c74c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_dhjcomical.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_dontknowhy.webp b/app/src/main/res/drawable-nodpi/avatar_dontknowhy.webp new file mode 100644 index 00000000..81485bf4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_dontknowhy.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_eldenthrone588.webp b/app/src/main/res/drawable-nodpi/avatar_eldenthrone588.webp new file mode 100644 index 00000000..c8f38dc1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_eldenthrone588.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_evsdrg.webp b/app/src/main/res/drawable-nodpi/avatar_evsdrg.webp new file mode 100644 index 00000000..6be4c2f6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_evsdrg.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_happydiy.webp b/app/src/main/res/drawable-nodpi/avatar_happydiy.webp new file mode 100644 index 00000000..2339017d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_happydiy.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_laoliu114514.webp b/app/src/main/res/drawable-nodpi/avatar_laoliu114514.webp new file mode 100644 index 00000000..86fb4e17 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_laoliu114514.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_leemwood.webp b/app/src/main/res/drawable-nodpi/avatar_leemwood.webp new file mode 100644 index 00000000..a404bf75 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_leemwood.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_lgc2333.webp b/app/src/main/res/drawable-nodpi/avatar_lgc2333.webp new file mode 100644 index 00000000..47f4765e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_lgc2333.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_liufeng258.webp b/app/src/main/res/drawable-nodpi/avatar_liufeng258.webp new file mode 100644 index 00000000..76581f20 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_liufeng258.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_milk13995.webp b/app/src/main/res/drawable-nodpi/avatar_milk13995.webp new file mode 100644 index 00000000..b8f61129 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_milk13995.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_mo_j8x.webp b/app/src/main/res/drawable-nodpi/avatar_mo_j8x.webp new file mode 100644 index 00000000..7b70eba1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_mo_j8x.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_movtery.webp b/app/src/main/res/drawable-nodpi/avatar_movtery.webp new file mode 100644 index 00000000..75cc9011 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_movtery.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_nnkshv.webp b/app/src/main/res/drawable-nodpi/avatar_nnkshv.webp new file mode 100644 index 00000000..0be11871 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_nnkshv.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_qisumei.webp b/app/src/main/res/drawable-nodpi/avatar_qisumei.webp new file mode 100644 index 00000000..1aa6eeca Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_qisumei.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_shirosakimio.webp b/app/src/main/res/drawable-nodpi/avatar_shirosakimio.webp new file mode 100644 index 00000000..5668f7cc Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_shirosakimio.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_ssbtt.webp b/app/src/main/res/drawable-nodpi/avatar_ssbtt.webp new file mode 100644 index 00000000..62237e54 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_ssbtt.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_swung0x48.webp b/app/src/main/res/drawable-nodpi/avatar_swung0x48.webp new file mode 100644 index 00000000..9aa0f8da Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_swung0x48.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_tntyep520.webp b/app/src/main/res/drawable-nodpi/avatar_tntyep520.webp new file mode 100644 index 00000000..605a97b1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_tntyep520.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_tungstend.webp b/app/src/main/res/drawable-nodpi/avatar_tungstend.webp new file mode 100644 index 00000000..0b1e51f4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_tungstend.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_usernotfound999.webp b/app/src/main/res/drawable-nodpi/avatar_usernotfound999.webp new file mode 100644 index 00000000..8ec23b2a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_usernotfound999.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_wyj5211.webp b/app/src/main/res/drawable-nodpi/avatar_wyj5211.webp new file mode 100644 index 00000000..1eb16d8f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_wyj5211.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_xiaoliyuanpp.webp b/app/src/main/res/drawable-nodpi/avatar_xiaoliyuanpp.webp new file mode 100644 index 00000000..6af89dc0 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_xiaoliyuanpp.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_youfeng11.webp b/app/src/main/res/drawable-nodpi/avatar_youfeng11.webp new file mode 100644 index 00000000..32b48930 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_youfeng11.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_yzcthinking.webp b/app/src/main/res/drawable-nodpi/avatar_yzcthinking.webp new file mode 100644 index 00000000..cb640e5f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_yzcthinking.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_zheshiqunzhu.webp b/app/src/main/res/drawable-nodpi/avatar_zheshiqunzhu.webp new file mode 100644 index 00000000..98218274 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_zheshiqunzhu.webp differ diff --git a/app/src/main/res/drawable-nodpi/avatar_zhongjiening.webp b/app/src/main/res/drawable-nodpi/avatar_zhongjiening.webp new file mode 100644 index 00000000..6ea4e5c7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_zhongjiening.webp differ diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..8b3e9ead --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/outline_info_24.xml b/app/src/main/res/drawable/ic_copy.xml similarity index 51% rename from app/src/main/res/drawable/outline_info_24.xml rename to app/src/main/res/drawable/ic_copy.xml index b7f7bc3f..fc4e24a1 100644 --- a/app/src/main/res/drawable/outline_info_24.xml +++ b/app/src/main/res/drawable/ic_copy.xml @@ -1,12 +1,9 @@ - - + android:pathData="M16,1H4C2.9,1 2,1.9 2,3v14h2V3h12V1zM19,5H8C6.9,5 6,5.9 6,7v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2V7c0,-1.1 -0.9,-2 -2,-2zM19,21H8V7h11v14z" /> diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 00000000..977cec3e --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 00000000..56d7ddac --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 00000000..a3d12f61 --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 00000000..d3ff6959 --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_open_in_new.xml b/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 00000000..10f351a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 00000000..ea7e236d --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 4ab73f59..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -