From 76eeff28e60437316463b5e24186b3c36ddc30ec Mon Sep 17 00:00:00 2001 From: daixu0502 Date: Wed, 30 Sep 2026 10:28:08 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat=EF=BC=9A=20SystmUIHook=20can=20run=20c?= =?UTF-8?q?orrectly=20on=20hyperOS4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parallelc/mixflipmod/hook/SystemUIHook.kt | 88 +++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/SystemUIHook.kt b/app/src/main/java/com/parallelc/mixflipmod/hook/SystemUIHook.kt index 662bc33..cc05eee 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/SystemUIHook.kt +++ b/app/src/main/java/com/parallelc/mixflipmod/hook/SystemUIHook.kt @@ -8,8 +8,10 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import com.parallelc.mixflipmod.Prefs +import com.parallelc.mixflipmod.hook.util.HookScope import com.parallelc.mixflipmod.hook.util.after import com.parallelc.mixflipmod.hook.util.callMethod +import com.parallelc.mixflipmod.hook.util.createDexKitBridge import com.parallelc.mixflipmod.hook.util.field import com.parallelc.mixflipmod.hook.util.findClass import com.parallelc.mixflipmod.hook.util.getField @@ -26,6 +28,7 @@ import io.github.libxposed.api.XposedInterface.Hooker import io.github.libxposed.api.XposedInterface.Invoker.Type import io.github.libxposed.api.XposedInterface.PRIORITY_LOWEST import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam +import java.lang.reflect.Method object SystemUIHook : BaseHook() { override val targetPackages = listOf("com.android.systemui") @@ -46,8 +49,78 @@ object SystemUIHook : BaseHook() { private fun hookNotification(param: PackageReadyParam, miuiConfigs: Class<*>) { val clazz = param.classLoader.findClass("com.android.systemui.statusbar.notification.row.MiuiNotificationMenuRow") - val fakeTinyScreen = hookScope(miuiConfigs.method("isTinyScreen", Context::class.java)) { false } - hook(clazz.method("createMenuViews", Boolean::class.java)) { chain -> fakeTinyScreen.run { chain.proceed() } } + val legacyTinyScreenScopes = listOf("isTinyScreen", "isFlipTinyScreen") + .mapNotNull { name -> + runCatching { miuiConfigs.method(name, Context::class.java) } + .getOrNull() + } + .distinct() + .map { method -> hookScope(method) { false } } + + val newTinyScreenPath = findNewNotificationTinyScreenPath(param) + val fakeAppMiniWindowTinyScreens = newTinyScreenPath + ?.first + .orEmpty() + .map { method -> hookScope(method) { false } } + + hook(clazz.method("createMenuViews", Boolean::class.java)) { chain -> + proceedWithScopes(chain, legacyTinyScreenScopes + fakeAppMiniWindowTinyScreens) + } + + val (appMiniWindowIsTiny, miniWindowBarUpdates) = newTinyScreenPath ?: return + log( + "new notification tiny-screen paths hooked: " + + "${appMiniWindowIsTiny.joinToString { "${it.declaringClass.name}#${it.name}" }}, " + + "updateMiniWindowBar=${miniWindowBarUpdates.size}" + ) + + miniWindowBarUpdates.forEach { updateMethod -> + runCatching { module!!.deoptimize(updateMethod) } + hook(updateMethod) { chain -> proceedWithScopes(chain, fakeAppMiniWindowTinyScreens) } + } + + runCatching { + param.classLoader + .findClass("com.android.systemui.statusbar.notification.modal.ModalController") + .declaredMethods + .filter { it.name == "enterModal" } + .onEach { it.isAccessible = true } + }.getOrDefault(emptyList()).forEach { enterModal -> + runCatching { module!!.deoptimize(enterModal) } + hook(enterModal) { chain -> proceedWithScopes(chain, fakeAppMiniWindowTinyScreens) } + } + } + + private fun findNewNotificationTinyScreenPath(param: PackageReadyParam): Pair, List>? = + runCatching { + createDexKitBridge(param.classLoader).use { bridge -> + val isTinyScreen = bridge.findMethod { + matcher { + name = "isTinyScreen" + returnType = "boolean" + } + }.asSequence() + .mapNotNull { data -> runCatching { data.getMethodInstance(param.classLoader) }.getOrNull() } + .distinct() + .toList() + .takeIf { it.isNotEmpty() } + ?: return@use null + + val updateMiniWindowBar = bridge.findMethod { + matcher { name = "updateMiniWindowBar" } + }.asSequence() + .mapNotNull { data -> runCatching { data.getMethodInstance(param.classLoader) }.getOrNull() } + .distinct() + .toList() + + isTinyScreen to updateMiniWindowBar + } + }.onFailure { log("new notification tiny-screen path lookup failed", it) } + .getOrNull() + + private fun proceedWithScopes(chain: Chain, scopes: List, index: Int = 0): Any? { + if (index >= scopes.size) return chain.proceed() + return scopes[index].run { proceedWithScopes(chain, scopes, index + 1) } } private fun hookControlCenter(param: PackageReadyParam, miuiConfigs: Class<*>) { @@ -58,15 +131,20 @@ object SystemUIHook : BaseHook() { override fun intercept(chain: Chain): Any? { val result = chain.proceed() - val mComponentName = chain.thisObject?.getField("mComponentName") as? ComponentName ?: return result if (isHooked) return result - if (mComponentName !in controlCenterComponents) return result val pluginLoader = (result as? ContextWrapper)?.classLoader ?: return result + val componentName = runCatching { + chain.thisObject?.getField("mComponentName") as? ComponentName + }.getOrNull() + if (componentName != null && componentName !in controlCenterComponents) return result + + val styleClass = runCatching { + pluginLoader.loadClass($$"miui.systemui.controlcenter.panel.main.MainPanelController$Style") + }.getOrNull() ?: return result isHooked = true runCatching { - val styleClass = pluginLoader.loadClass($$"miui.systemui.controlcenter.panel.main.MainPanelController$Style") val compactStyle = styleClass.field("COMPACT").get(null) val verticalStyle = styleClass.field("VERTICAL").get(null) From 3d6a66f6e47fcf86ee716427ce94ac6e2578d77d Mon Sep 17 00:00:00 2001 From: daixu0502 Date: Fri, 23 Oct 2026 14:15:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E5=B0=9D=E8=AF=95?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E6=9C=AA=E5=90=AF=E7=94=A8R8?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E9=83=A8=E5=88=86=E5=BD=B1=E5=93=8D=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=80=9F=E5=BA=A6=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++ app/build.gradle.kts | 13 ++- .../chener}/mixflipmod/Main.kt | 14 +-- .../chener}/mixflipmod/Prefs.kt | 2 +- .../chener}/mixflipmod/hook/BaseHook.kt | 14 +-- .../chener}/mixflipmod/hook/FlipHomeHook.kt | 24 ++-- .../chener}/mixflipmod/hook/HideOuterHook.kt | 14 +-- .../mixflipmod/hook/PersonalAssistantHook.kt | 16 +-- .../chener}/mixflipmod/hook/SogouHook.kt | 20 ++-- .../chener}/mixflipmod/hook/SystemHook.kt | 28 ++--- .../chener}/mixflipmod/hook/SystemUIHook.kt | 34 +++--- .../chener}/mixflipmod/hook/util/HookUtils.kt | 6 +- .../mixflipmod/hook/util/ReflectUtils.kt | 2 +- .../chener}/mixflipmod/model/AppConfig.kt | 6 +- .../chener}/mixflipmod/ui/MainActivity.kt | 7 +- .../chener}/mixflipmod/ui/MainScreen.kt | 107 ++++++++---------- .../mixflipmod/ui/component/AppIcon.kt | 40 ++++--- .../mixflipmod/ui/component/ConfigEntry.kt | 19 ++-- .../mixflipmod/ui/component/PrefSpecItem.kt | 12 +- .../mixflipmod/ui/component/RestartDialog.kt | 8 +- .../ui/component/ServiceStatusCard.kt | 10 +- .../mixflipmod/ui/icons/CheckCircleOutline.kt | 59 ++++++++++ .../mixflipmod/ui/icons/ErrorOutline.kt | 62 ++++++++++ .../mixflipmod/ui/icons/VisibilityOff.kt | 74 ++++++++++++ .../mixflipmod/ui/model/InstalledApp.kt | 2 +- .../ui/screen/FlipScreenModeScreen.kt | 20 ++-- .../mixflipmod/ui/screen/GroupSwitchScreen.kt | 34 +++--- .../chener}/mixflipmod/ui/screen/SubScreen.kt | 6 +- .../chener}/mixflipmod/ui/util/AppUtils.kt | 2 +- .../mixflipmod/ui/util/ConfiguredApps.kt | 46 ++++++++ .../mixflipmod/ui/util/InstalledApps.kt | 4 +- .../chener}/mixflipmod/ui/util/Restart.kt | 2 +- .../mixflipmod/ui/util/XposedServiceUtils.kt | 10 +- .../resources/META-INF/xposed/java_init.list | 2 +- gradle/libs.versions.toml | 2 +- 35 files changed, 496 insertions(+), 242 deletions(-) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/Main.kt (72%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/Prefs.kt (98%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/BaseHook.kt (83%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/FlipHomeHook.kt (98%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/HideOuterHook.kt (77%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/PersonalAssistantHook.kt (96%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/SogouHook.kt (93%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/SystemHook.kt (97%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/SystemUIHook.kt (95%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/util/HookUtils.kt (95%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/hook/util/ReflectUtils.kt (98%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/model/AppConfig.kt (98%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/MainActivity.kt (88%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/MainScreen.kt (76%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/component/AppIcon.kt (50%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/component/ConfigEntry.kt (84%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/component/PrefSpecItem.kt (97%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/component/RestartDialog.kt (93%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/component/ServiceStatusCard.kt (93%) create mode 100644 app/src/main/java/vip/chener/mixflipmod/ui/icons/CheckCircleOutline.kt create mode 100644 app/src/main/java/vip/chener/mixflipmod/ui/icons/ErrorOutline.kt create mode 100644 app/src/main/java/vip/chener/mixflipmod/ui/icons/VisibilityOff.kt rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/model/InstalledApp.kt (86%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/screen/FlipScreenModeScreen.kt (96%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/screen/GroupSwitchScreen.kt (80%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/screen/SubScreen.kt (95%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/util/AppUtils.kt (86%) create mode 100644 app/src/main/java/vip/chener/mixflipmod/ui/util/ConfiguredApps.kt rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/util/InstalledApps.kt (98%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/util/Restart.kt (97%) rename app/src/main/java/{com/parallelc => vip/chener}/mixflipmod/ui/util/XposedServiceUtils.kt (92%) diff --git a/README.md b/README.md index ca4ea70..ed94921 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,22 @@ 支持 HyperOS 3.0 及以上版本,需要配合支持 libxposed API 101 的 Xposed 框架使用 +应用包名为 `vip.chener.mixflipmod`。与原包名版本属于不同应用,安装后需要在 LSPosed 中重新启用并设置作用域,原版本的设置不会自动迁移;请避免同时启用两个版本。 + +## 构建与性能验证 + +使用 JDK 17 和 Android SDK 37 构建。在 Windows 上执行: + +```powershell +.\gradlew.bat :app:assembleDebug +.\gradlew.bat :app:assembleUnminified +.\gradlew.bat :app:assembleRelease +``` + +`debug` 为可调试版本,未启用 R8;`unminified` 为不可调试且未启用 R8 的性能验证版本,使用调试签名;`release` 保持启用 R8。产物分别位于 `app/build/outputs/apk/` 下的同名目录。比较 R8 对流畅度的影响时,应使用 `unminified` 与 `release`,避免混入 Compose 调试开销。 + +主界面的应用信息在后台加载并复用,列表入口独立懒加载,图标使用有容量上限的内存缓存。仅保留实际使用的三个 Material 扩展图标源码,避免未启用 R8 时打包完整扩展图标库。 + ## 功能 隐藏外屏状态: @@ -61,6 +77,7 @@ - [KernelSU](https://github.com/tiann/KernelSU) - [HyperCeiler](https://github.com/ReChronoRain/HyperCeiler) +- [AndroidX Compose Material Icons](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/material/material-icons-extended/):三个本地图标保留原始 Apache-2.0 许可证声明。 ## 许可 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7b98a4..6f28e2e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,11 +19,11 @@ val commitCount = repo?.commitCount("refs/remotes/origin/main") ?: 1 val latestTag = repo?.latestTag?.removePrefix("v") ?: "3.0" android { - namespace = "com.parallelc.mixflipmod" + namespace = "vip.chener.mixflipmod" compileSdk = 37 defaultConfig { - applicationId = "com.parallelc.mixflipmod" + applicationId = "vip.chener.mixflipmod" minSdk = 35 targetSdk = 36 versionCode = commitCount @@ -40,6 +40,13 @@ android { "proguard-rules.pro" ) } + create("unminified") { + initWith(getByName("release")) + isMinifyEnabled = false + isDebuggable = false + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += "release" + } } buildFeatures { compose = true @@ -67,11 +74,11 @@ androidComponents { dependencies { implementation(libs.androidx.activity.ktx) implementation(libs.lifecycle.runtime.ktx) + implementation(libs.lifecycle.runtime.compose) implementation(libs.activity.compose) implementation(platform(libs.compose.bom)) implementation(libs.androidx.foundation) implementation(libs.androidx.material.icons.core) - implementation(libs.androidx.material.icons.extended) implementation(libs.miuix.ui) implementation(libs.miuix.preference) implementation(libs.miuix.icons) diff --git a/app/src/main/java/com/parallelc/mixflipmod/Main.kt b/app/src/main/java/vip/chener/mixflipmod/Main.kt similarity index 72% rename from app/src/main/java/com/parallelc/mixflipmod/Main.kt rename to app/src/main/java/vip/chener/mixflipmod/Main.kt index 42aaba8..e93576e 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/Main.kt +++ b/app/src/main/java/vip/chener/mixflipmod/Main.kt @@ -1,11 +1,11 @@ -package com.parallelc.mixflipmod +package vip.chener.mixflipmod -import com.parallelc.mixflipmod.hook.FlipHomeHook -import com.parallelc.mixflipmod.hook.HideOuterHook -import com.parallelc.mixflipmod.hook.PersonalAssistantHook -import com.parallelc.mixflipmod.hook.SogouHook -import com.parallelc.mixflipmod.hook.SystemHook -import com.parallelc.mixflipmod.hook.SystemUIHook +import vip.chener.mixflipmod.hook.FlipHomeHook +import vip.chener.mixflipmod.hook.HideOuterHook +import vip.chener.mixflipmod.hook.PersonalAssistantHook +import vip.chener.mixflipmod.hook.SogouHook +import vip.chener.mixflipmod.hook.SystemHook +import vip.chener.mixflipmod.hook.SystemUIHook import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam diff --git a/app/src/main/java/com/parallelc/mixflipmod/Prefs.kt b/app/src/main/java/vip/chener/mixflipmod/Prefs.kt similarity index 98% rename from app/src/main/java/com/parallelc/mixflipmod/Prefs.kt rename to app/src/main/java/vip/chener/mixflipmod/Prefs.kt index e2e29c9..2b7ac4a 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/Prefs.kt +++ b/app/src/main/java/vip/chener/mixflipmod/Prefs.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod +package vip.chener.mixflipmod object Prefs { const val NAME = "mixflipmod" diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/BaseHook.kt b/app/src/main/java/vip/chener/mixflipmod/hook/BaseHook.kt similarity index 83% rename from app/src/main/java/com/parallelc/mixflipmod/hook/BaseHook.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/BaseHook.kt index 0dd854a..1bd1cee 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/BaseHook.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/BaseHook.kt @@ -1,11 +1,11 @@ -package com.parallelc.mixflipmod.hook +package vip.chener.mixflipmod.hook -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.hook.util.safeHook -import com.parallelc.mixflipmod.model.OptionValue -import com.parallelc.mixflipmod.model.PrefSpec -import com.parallelc.mixflipmod.model.appConfigs -import com.parallelc.mixflipmod.module +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.hook.util.safeHook +import vip.chener.mixflipmod.model.OptionValue +import vip.chener.mixflipmod.model.PrefSpec +import vip.chener.mixflipmod.model.appConfigs +import vip.chener.mixflipmod.module import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam abstract class BaseHook { diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/FlipHomeHook.kt b/app/src/main/java/vip/chener/mixflipmod/hook/FlipHomeHook.kt similarity index 98% rename from app/src/main/java/com/parallelc/mixflipmod/hook/FlipHomeHook.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/FlipHomeHook.kt index d42dac5..0e5b0bd 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/FlipHomeHook.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/FlipHomeHook.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.hook +package vip.chener.mixflipmod.hook import android.app.Activity import android.content.ComponentName @@ -27,17 +27,17 @@ import android.widget.PopupWindow import android.widget.TextView import android.widget.Toast import androidx.core.net.toUri -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.hook.util.after -import com.parallelc.mixflipmod.hook.util.callMethod -import com.parallelc.mixflipmod.hook.util.findClass -import com.parallelc.mixflipmod.hook.util.getField -import com.parallelc.mixflipmod.hook.util.hook -import com.parallelc.mixflipmod.hook.util.log -import com.parallelc.mixflipmod.hook.util.method -import com.parallelc.mixflipmod.hook.util.prefInt -import com.parallelc.mixflipmod.hook.util.replaceResult -import com.parallelc.mixflipmod.hook.util.setField +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.hook.util.after +import vip.chener.mixflipmod.hook.util.callMethod +import vip.chener.mixflipmod.hook.util.findClass +import vip.chener.mixflipmod.hook.util.getField +import vip.chener.mixflipmod.hook.util.hook +import vip.chener.mixflipmod.hook.util.log +import vip.chener.mixflipmod.hook.util.method +import vip.chener.mixflipmod.hook.util.prefInt +import vip.chener.mixflipmod.hook.util.replaceResult +import vip.chener.mixflipmod.hook.util.setField import io.github.libxposed.api.XposedInterface.Hooker import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam import java.io.File diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/HideOuterHook.kt b/app/src/main/java/vip/chener/mixflipmod/hook/HideOuterHook.kt similarity index 77% rename from app/src/main/java/com/parallelc/mixflipmod/hook/HideOuterHook.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/HideOuterHook.kt index 80f8804..aa687ab 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/HideOuterHook.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/HideOuterHook.kt @@ -1,13 +1,13 @@ -package com.parallelc.mixflipmod.hook +package vip.chener.mixflipmod.hook import android.content.ContentResolver import android.provider.Settings -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.hook.util.after -import com.parallelc.mixflipmod.hook.util.findClass -import com.parallelc.mixflipmod.hook.util.hook -import com.parallelc.mixflipmod.hook.util.method -import com.parallelc.mixflipmod.model.hideOuterPackages +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.hook.util.after +import vip.chener.mixflipmod.hook.util.findClass +import vip.chener.mixflipmod.hook.util.hook +import vip.chener.mixflipmod.hook.util.method +import vip.chener.mixflipmod.model.hideOuterPackages import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam object HideOuterHook : BaseHook() { diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/PersonalAssistantHook.kt b/app/src/main/java/vip/chener/mixflipmod/hook/PersonalAssistantHook.kt similarity index 96% rename from app/src/main/java/com/parallelc/mixflipmod/hook/PersonalAssistantHook.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/PersonalAssistantHook.kt index 2a65e3c..943c662 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/PersonalAssistantHook.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/PersonalAssistantHook.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.hook +package vip.chener.mixflipmod.hook import android.app.Activity import android.content.Context @@ -6,13 +6,13 @@ import android.content.Intent import android.net.Uri import android.widget.Toast import androidx.core.net.toUri -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.hook.util.callMethod -import com.parallelc.mixflipmod.hook.util.findClass -import com.parallelc.mixflipmod.hook.util.getField -import com.parallelc.mixflipmod.hook.util.hook -import com.parallelc.mixflipmod.hook.util.log -import com.parallelc.mixflipmod.hook.util.method +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.hook.util.callMethod +import vip.chener.mixflipmod.hook.util.findClass +import vip.chener.mixflipmod.hook.util.getField +import vip.chener.mixflipmod.hook.util.hook +import vip.chener.mixflipmod.hook.util.log +import vip.chener.mixflipmod.hook.util.method import io.github.libxposed.api.XposedInterface.Hooker import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam import java.io.File diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/SogouHook.kt b/app/src/main/java/vip/chener/mixflipmod/hook/SogouHook.kt similarity index 93% rename from app/src/main/java/com/parallelc/mixflipmod/hook/SogouHook.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/SogouHook.kt index 35aafef..a8845b2 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/SogouHook.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/SogouHook.kt @@ -1,13 +1,13 @@ -package com.parallelc.mixflipmod.hook - -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.hook.util.HookScope -import com.parallelc.mixflipmod.hook.util.createDexKitBridge -import com.parallelc.mixflipmod.hook.util.hookScope -import com.parallelc.mixflipmod.hook.util.findClass -import com.parallelc.mixflipmod.hook.util.hook -import com.parallelc.mixflipmod.hook.util.log -import com.parallelc.mixflipmod.hook.util.method +package vip.chener.mixflipmod.hook + +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.hook.util.HookScope +import vip.chener.mixflipmod.hook.util.createDexKitBridge +import vip.chener.mixflipmod.hook.util.hookScope +import vip.chener.mixflipmod.hook.util.findClass +import vip.chener.mixflipmod.hook.util.hook +import vip.chener.mixflipmod.hook.util.log +import vip.chener.mixflipmod.hook.util.method import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam import org.luckypray.dexkit.DexKitBridge import java.lang.reflect.Method diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/SystemHook.kt b/app/src/main/java/vip/chener/mixflipmod/hook/SystemHook.kt similarity index 97% rename from app/src/main/java/com/parallelc/mixflipmod/hook/SystemHook.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/SystemHook.kt index 200915a..3b28d3c 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/SystemHook.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/SystemHook.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.hook +package vip.chener.mixflipmod.hook import android.content.ComponentName import android.graphics.Rect @@ -10,19 +10,19 @@ import android.content.res.Configuration import android.os.Bundle import android.provider.Settings import android.view.WindowManager -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.Prefs.FlipScreenMode -import com.parallelc.mixflipmod.hook.util.after -import com.parallelc.mixflipmod.hook.util.callMethod -import com.parallelc.mixflipmod.hook.util.createDexKitBridge -import com.parallelc.mixflipmod.hook.util.findClass -import com.parallelc.mixflipmod.hook.util.getField -import com.parallelc.mixflipmod.hook.util.hook -import com.parallelc.mixflipmod.hook.util.log -import com.parallelc.mixflipmod.hook.util.method -import com.parallelc.mixflipmod.hook.util.runWithCleanup -import com.parallelc.mixflipmod.hook.util.safeHook -import com.parallelc.mixflipmod.module +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.Prefs.FlipScreenMode +import vip.chener.mixflipmod.hook.util.after +import vip.chener.mixflipmod.hook.util.callMethod +import vip.chener.mixflipmod.hook.util.createDexKitBridge +import vip.chener.mixflipmod.hook.util.findClass +import vip.chener.mixflipmod.hook.util.getField +import vip.chener.mixflipmod.hook.util.hook +import vip.chener.mixflipmod.hook.util.log +import vip.chener.mixflipmod.hook.util.method +import vip.chener.mixflipmod.hook.util.runWithCleanup +import vip.chener.mixflipmod.hook.util.safeHook +import vip.chener.mixflipmod.module import io.github.libxposed.api.XposedInterface.Hooker import io.github.libxposed.api.XposedModuleInterface.SystemServerStartingParam diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/SystemUIHook.kt b/app/src/main/java/vip/chener/mixflipmod/hook/SystemUIHook.kt similarity index 95% rename from app/src/main/java/com/parallelc/mixflipmod/hook/SystemUIHook.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/SystemUIHook.kt index cc05eee..9c2a3a8 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/SystemUIHook.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/SystemUIHook.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.hook +package vip.chener.mixflipmod.hook import android.content.ComponentName import android.content.Context @@ -7,22 +7,22 @@ import android.content.res.Resources import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.hook.util.HookScope -import com.parallelc.mixflipmod.hook.util.after -import com.parallelc.mixflipmod.hook.util.callMethod -import com.parallelc.mixflipmod.hook.util.createDexKitBridge -import com.parallelc.mixflipmod.hook.util.field -import com.parallelc.mixflipmod.hook.util.findClass -import com.parallelc.mixflipmod.hook.util.getField -import com.parallelc.mixflipmod.hook.util.hook -import com.parallelc.mixflipmod.hook.util.hookScope -import com.parallelc.mixflipmod.hook.util.log -import com.parallelc.mixflipmod.hook.util.method -import com.parallelc.mixflipmod.hook.util.prefInt -import com.parallelc.mixflipmod.hook.util.runWithCleanup -import com.parallelc.mixflipmod.hook.util.setField -import com.parallelc.mixflipmod.module +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.hook.util.HookScope +import vip.chener.mixflipmod.hook.util.after +import vip.chener.mixflipmod.hook.util.callMethod +import vip.chener.mixflipmod.hook.util.createDexKitBridge +import vip.chener.mixflipmod.hook.util.field +import vip.chener.mixflipmod.hook.util.findClass +import vip.chener.mixflipmod.hook.util.getField +import vip.chener.mixflipmod.hook.util.hook +import vip.chener.mixflipmod.hook.util.hookScope +import vip.chener.mixflipmod.hook.util.log +import vip.chener.mixflipmod.hook.util.method +import vip.chener.mixflipmod.hook.util.prefInt +import vip.chener.mixflipmod.hook.util.runWithCleanup +import vip.chener.mixflipmod.hook.util.setField +import vip.chener.mixflipmod.module import io.github.libxposed.api.XposedInterface.Chain import io.github.libxposed.api.XposedInterface.Hooker import io.github.libxposed.api.XposedInterface.Invoker.Type diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/util/HookUtils.kt b/app/src/main/java/vip/chener/mixflipmod/hook/util/HookUtils.kt similarity index 95% rename from app/src/main/java/com/parallelc/mixflipmod/hook/util/HookUtils.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/util/HookUtils.kt index 2783c2d..e6b2c0a 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/util/HookUtils.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/util/HookUtils.kt @@ -1,8 +1,8 @@ -package com.parallelc.mixflipmod.hook.util +package vip.chener.mixflipmod.hook.util import android.util.Log -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.module +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.module import io.github.libxposed.api.XposedInterface.Chain import io.github.libxposed.api.XposedInterface.HookHandle import io.github.libxposed.api.XposedInterface.Hooker diff --git a/app/src/main/java/com/parallelc/mixflipmod/hook/util/ReflectUtils.kt b/app/src/main/java/vip/chener/mixflipmod/hook/util/ReflectUtils.kt similarity index 98% rename from app/src/main/java/com/parallelc/mixflipmod/hook/util/ReflectUtils.kt rename to app/src/main/java/vip/chener/mixflipmod/hook/util/ReflectUtils.kt index 94ad96b..c404d3e 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/hook/util/ReflectUtils.kt +++ b/app/src/main/java/vip/chener/mixflipmod/hook/util/ReflectUtils.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.hook.util +package vip.chener.mixflipmod.hook.util import java.lang.reflect.Field import java.lang.reflect.Method diff --git a/app/src/main/java/com/parallelc/mixflipmod/model/AppConfig.kt b/app/src/main/java/vip/chener/mixflipmod/model/AppConfig.kt similarity index 98% rename from app/src/main/java/com/parallelc/mixflipmod/model/AppConfig.kt rename to app/src/main/java/vip/chener/mixflipmod/model/AppConfig.kt index 6d61d77..738b54f 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/model/AppConfig.kt +++ b/app/src/main/java/vip/chener/mixflipmod/model/AppConfig.kt @@ -1,8 +1,8 @@ -package com.parallelc.mixflipmod.model +package vip.chener.mixflipmod.model import androidx.annotation.StringRes -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.R +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.R data class AppConfig( val packageName: String, diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/MainActivity.kt b/app/src/main/java/vip/chener/mixflipmod/ui/MainActivity.kt similarity index 88% rename from app/src/main/java/com/parallelc/mixflipmod/ui/MainActivity.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/MainActivity.kt index f5a1553..def108e 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/MainActivity.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/MainActivity.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui +package vip.chener.mixflipmod.ui import android.graphics.Color import android.os.Bundle @@ -8,7 +8,7 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.ThemeController @@ -20,11 +20,12 @@ class MainActivity : ComponentActivity() { setContent { val controller = remember { ThemeController() } val dark = isSystemInDarkTheme() - SideEffect { + DisposableEffect(dark) { enableEdgeToEdge( statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark }, navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark }, ) + onDispose { } } MiuixTheme(controller = controller) { MainScreen() } } diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/MainScreen.kt b/app/src/main/java/vip/chener/mixflipmod/ui/MainScreen.kt similarity index 76% rename from app/src/main/java/com/parallelc/mixflipmod/ui/MainScreen.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/MainScreen.kt index 086af24..89b5632 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/MainScreen.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/MainScreen.kt @@ -1,11 +1,10 @@ -package com.parallelc.mixflipmod.ui +package vip.chener.mixflipmod.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -16,12 +15,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.VisibilityOff +import vip.chener.mixflipmod.ui.icons.VisibilityOff import androidx.compose.material.icons.filled.MoreVert import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,21 +36,23 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import com.parallelc.mixflipmod.BuildConfig -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.R -import com.parallelc.mixflipmod.model.AppConfig -import com.parallelc.mixflipmod.model.ConfigNode -import com.parallelc.mixflipmod.model.configNodes -import com.parallelc.mixflipmod.ui.component.AppIcon -import com.parallelc.mixflipmod.ui.component.ConfigEntry -import com.parallelc.mixflipmod.ui.component.PrefSpecItem -import com.parallelc.mixflipmod.ui.component.ServiceStatusCard -import com.parallelc.mixflipmod.ui.screen.FlipScreenModeScreen -import com.parallelc.mixflipmod.ui.screen.GroupSwitchScreen -import com.parallelc.mixflipmod.ui.screen.SubScreen -import com.parallelc.mixflipmod.ui.util.appLabel -import com.parallelc.mixflipmod.ui.util.XposedServiceState +import vip.chener.mixflipmod.BuildConfig +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.R +import vip.chener.mixflipmod.model.AppConfig +import vip.chener.mixflipmod.model.ConfigNode +import vip.chener.mixflipmod.model.configNodes +import vip.chener.mixflipmod.ui.component.AppIcon +import vip.chener.mixflipmod.ui.component.ConfigEntry +import vip.chener.mixflipmod.ui.component.PrefSpecItem +import vip.chener.mixflipmod.ui.component.ServiceStatusCard +import vip.chener.mixflipmod.ui.screen.FlipScreenModeScreen +import vip.chener.mixflipmod.ui.screen.GroupSwitchScreen +import vip.chener.mixflipmod.ui.screen.SubScreen +import vip.chener.mixflipmod.ui.util.rememberConfiguredApps +import vip.chener.mixflipmod.ui.util.XposedServiceState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.DropdownImpl import top.yukonga.miuix.kmp.basic.Icon @@ -88,13 +89,12 @@ fun MainScreen() { var showAboutDialog by remember { mutableStateOf(false) } val mainSB = MiuixScrollBehavior() val mainLS = rememberLazyListState() - val pm = ctx.packageManager val appInfo = remember(ctx) { ctx.applicationInfo } + val configuredApps by rememberConfiguredApps() - DisposableEffect(Unit) { - ctx.deleteSharedPreferences(Prefs.NAME) + LaunchedEffect(ctx) { + withContext(Dispatchers.IO) { ctx.deleteSharedPreferences(Prefs.NAME) } XposedServiceState.ensureRegistered() - onDispose { } } LaunchedEffect(serviceConnected) { @@ -103,16 +103,9 @@ fun MainScreen() { } } - val isConfigVisible: (AppConfig) -> Boolean = { config -> - config.packageName == "android" || runCatching { - pm.getApplicationInfo(config.packageName, 0) - }.isSuccess - } - - val visibleNodes = configNodes.filter { node -> - when (node) { - is ConfigNode.Package -> isConfigVisible(node.config) - is ConfigNode.Group -> node.packages.any(isConfigVisible) + val visibleNodes = remember(configuredApps) { + configNodes.filter { node -> + node.packages.any { it.packageName in configuredApps } } } @@ -136,6 +129,7 @@ fun MainScreen() { prefs = remotePrefs, titleRes = s.node.titleRes, configs = s.node.packages, + apps = configuredApps, onBack = { screen = Screen.Main }, ) } @@ -180,31 +174,28 @@ fun MainScreen() { .nestedScroll(mainSB.nestedScrollConnection), contentPadding = it, ) { - item { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + item(key = "service_status", contentType = "status") { + Box(modifier = Modifier.fillMaxWidth().padding(12.dp)) { ServiceStatusCard(serviceConnected = serviceConnected) - prefs?.takeIf { serviceConnected }?.let { - Card(modifier = Modifier.fillMaxWidth()) { - visibleNodes.forEach { node -> - when (node) { - is ConfigNode.Package -> { - ConfigEntry( - packageName = node.config.packageName, - onClick = { screen = Screen.PackageConfig(node.config) }, - ) - } - is ConfigNode.Group -> { - ConfigEntry( - title = stringResource(node.titleRes), - summary = node.summaryRes?.let { stringResource(it) }, - icon = Icons.Filled.VisibilityOff, - onClick = { screen = Screen.Group(node) }, - ) - } - } + } + } + if (prefs != null && serviceConnected) { + items(visibleNodes, key = { it.id }, contentType = { "config" }) { node -> + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 8.dp)) { + when (node) { + is ConfigNode.Package -> { + ConfigEntry( + app = configuredApps.getValue(node.config.packageName), + onClick = { screen = Screen.PackageConfig(node.config) }, + ) + } + is ConfigNode.Group -> { + ConfigEntry( + title = stringResource(node.titleRes), + summary = node.summaryRes?.let { stringResource(it) }, + icon = Icons.Filled.VisibilityOff, + onClick = { screen = Screen.Group(node) }, + ) } } } @@ -222,7 +213,7 @@ fun MainScreen() { val cfg = s.config val remotePrefs = prefs ?: return@AnimatedContent val title = if (cfg.packageName == "android") stringResource(R.string.system_framework) - else remember(cfg.packageName) { pm.appLabel(cfg.packageName, cfg.packageName) } + else configuredApps[cfg.packageName]?.label ?: cfg.packageName BackHandler { screen = Screen.Main } SubScreen( title = title, diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/component/AppIcon.kt b/app/src/main/java/vip/chener/mixflipmod/ui/component/AppIcon.kt similarity index 50% rename from app/src/main/java/com/parallelc/mixflipmod/ui/component/AppIcon.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/component/AppIcon.kt index 73c41e1..d4c3cc8 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/component/AppIcon.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/component/AppIcon.kt @@ -1,9 +1,9 @@ -package com.parallelc.mixflipmod.ui.component +package vip.chener.mixflipmod.ui.component import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.Canvas -import android.graphics.drawable.BitmapDrawable +import android.util.LruCache import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -17,6 +17,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.core.graphics.createBitmap import kotlinx.coroutines.Dispatchers @@ -24,25 +26,27 @@ import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.miuixShape +private val iconCache = object : LruCache(4 * 1024 * 1024) { + override fun sizeOf(key: String, value: ImageBitmap): Int = value.width * value.height * 4 +} + @Composable fun AppIcon(icon: ApplicationInfo, pm: PackageManager, modifier: Modifier) { - var bitmap by remember { mutableStateOf(null) } - LaunchedEffect(icon.packageName) { - val bm = withContext(Dispatchers.IO) { - runCatching { - val d = pm.getApplicationIcon(icon) - if (d is BitmapDrawable) d.bitmap - else { - val w = if (d.intrinsicWidth > 0) d.intrinsicWidth else 48 - val h = if (d.intrinsicHeight > 0) d.intrinsicHeight else 48 - val b = createBitmap(w, h) - d.setBounds(0, 0, w, h) - d.draw(Canvas(b)) - b - } - }.getOrNull()?.asImageBitmap() + val size = with(LocalDensity.current) { 50.dp.roundToPx().coerceAtLeast(1) } + val configuration = LocalConfiguration.current + val cacheKey = "${icon.uid}:${icon.packageName}:${icon.sourceDir}:$size:${configuration.uiMode}:${configuration.locales.toLanguageTags()}" + var bitmap by remember(cacheKey) { mutableStateOf(iconCache.get(cacheKey)) } + LaunchedEffect(cacheKey) { + if (bitmap != null) return@LaunchedEffect + bitmap = withContext(Dispatchers.IO) { + iconCache.get(cacheKey) ?: runCatching { + val drawable = pm.getApplicationIcon(icon) + val result = createBitmap(size, size) + drawable.setBounds(0, 0, size, size) + drawable.draw(Canvas(result)) + result.asImageBitmap().also { iconCache.put(cacheKey, it) } + }.getOrNull() } - bitmap = bm } bitmap?.let { Image(it, "", modifier = modifier.clip(miuixShape(12.dp))) diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/component/ConfigEntry.kt b/app/src/main/java/vip/chener/mixflipmod/ui/component/ConfigEntry.kt similarity index 84% rename from app/src/main/java/com/parallelc/mixflipmod/ui/component/ConfigEntry.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/component/ConfigEntry.kt index 3387961..5d4db1e 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/component/ConfigEntry.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/component/ConfigEntry.kt @@ -1,39 +1,36 @@ -package com.parallelc.mixflipmod.ui.component +package vip.chener.mixflipmod.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.parallelc.mixflipmod.R +import vip.chener.mixflipmod.R +import vip.chener.mixflipmod.ui.util.ConfiguredApp import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.preference.ArrowPreference import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.miuixShape @Composable -fun ConfigEntry( - packageName: String, +internal fun ConfigEntry( + app: ConfiguredApp, onClick: () -> Unit, ) { val ctx = LocalContext.current val pm = ctx.packageManager - val appInfo = remember(packageName) { - runCatching { pm.getApplicationInfo(packageName, 0) }.getOrNull() - } + val packageName = app.packageName + val appInfo = app.appInfo val appLabel = if (packageName == "android") { stringResource(R.string.system_framework) } else { - remember(appInfo) { - appInfo?.let { pm.getApplicationLabel(it).toString() } ?: packageName - } + app.label } val summary = if (packageName == "android") stringResource(R.string.system_framework_summary) else packageName ArrowPreference( diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/component/PrefSpecItem.kt b/app/src/main/java/vip/chener/mixflipmod/ui/component/PrefSpecItem.kt similarity index 97% rename from app/src/main/java/com/parallelc/mixflipmod/ui/component/PrefSpecItem.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/component/PrefSpecItem.kt index 3388576..f564bf9 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/component/PrefSpecItem.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/component/PrefSpecItem.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.component +package vip.chener.mixflipmod.ui.component import android.content.Context import android.content.SharedPreferences @@ -27,11 +27,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.edit -import com.parallelc.mixflipmod.model.OptionValue -import com.parallelc.mixflipmod.model.PrefSpec -import com.parallelc.mixflipmod.model.appConfigs -import com.parallelc.mixflipmod.ui.util.checkScopes -import com.parallelc.mixflipmod.ui.util.scopePackage +import vip.chener.mixflipmod.model.OptionValue +import vip.chener.mixflipmod.model.PrefSpec +import vip.chener.mixflipmod.model.appConfigs +import vip.chener.mixflipmod.ui.util.checkScopes +import vip.chener.mixflipmod.ui.util.scopePackage import io.github.libxposed.service.XposedService import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.DropdownArrowEndAction diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/component/RestartDialog.kt b/app/src/main/java/vip/chener/mixflipmod/ui/component/RestartDialog.kt similarity index 93% rename from app/src/main/java/com/parallelc/mixflipmod/ui/component/RestartDialog.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/component/RestartDialog.kt index 228e62b..a75df45 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/component/RestartDialog.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/component/RestartDialog.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.component +package vip.chener.mixflipmod.ui.component import android.widget.Toast import androidx.compose.foundation.layout.Row @@ -12,9 +12,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.parallelc.mixflipmod.R -import com.parallelc.mixflipmod.ui.util.RestartResult -import com.parallelc.mixflipmod.ui.util.restartApp +import vip.chener.mixflipmod.R +import vip.chener.mixflipmod.ui.util.RestartResult +import vip.chener.mixflipmod.ui.util.restartApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/component/ServiceStatusCard.kt b/app/src/main/java/vip/chener/mixflipmod/ui/component/ServiceStatusCard.kt similarity index 93% rename from app/src/main/java/com/parallelc/mixflipmod/ui/component/ServiceStatusCard.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/component/ServiceStatusCard.kt index 0079e5a..a50a3d4 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/component/ServiceStatusCard.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/component/ServiceStatusCard.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.component +package vip.chener.mixflipmod.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -12,8 +12,8 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CheckCircleOutline -import androidx.compose.material.icons.rounded.ErrorOutline +import vip.chener.mixflipmod.ui.icons.CheckCircleOutline +import vip.chener.mixflipmod.ui.icons.ErrorOutline import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,8 +22,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.parallelc.mixflipmod.BuildConfig -import com.parallelc.mixflipmod.R +import vip.chener.mixflipmod.BuildConfig +import vip.chener.mixflipmod.R import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Text diff --git a/app/src/main/java/vip/chener/mixflipmod/ui/icons/CheckCircleOutline.kt b/app/src/main/java/vip/chener/mixflipmod/ui/icons/CheckCircleOutline.kt new file mode 100644 index 0000000..1e851a6 --- /dev/null +++ b/app/src/main/java/vip/chener/mixflipmod/ui/icons/CheckCircleOutline.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vip.chener.mixflipmod.ui.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +internal val Icons.Rounded.CheckCircleOutline: ImageVector + get() { + if (_checkCircleOutline != null) { + return _checkCircleOutline!! + } + _checkCircleOutline = materialIcon(name = "Rounded.CheckCircleOutline") { + materialPath { + moveTo(12.0f, 2.0f) + curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f) + reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f) + reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f) + reflectiveCurveTo(17.52f, 2.0f, 12.0f, 2.0f) + close() + moveTo(12.0f, 20.0f) + curveToRelative(-4.41f, 0.0f, -8.0f, -3.59f, -8.0f, -8.0f) + reflectiveCurveToRelative(3.59f, -8.0f, 8.0f, -8.0f) + reflectiveCurveToRelative(8.0f, 3.59f, 8.0f, 8.0f) + reflectiveCurveToRelative(-3.59f, 8.0f, -8.0f, 8.0f) + close() + moveTo(15.88f, 8.29f) + lineTo(10.0f, 14.17f) + lineToRelative(-1.88f, -1.88f) + curveToRelative(-0.39f, -0.39f, -1.02f, -0.39f, -1.41f, 0.0f) + curveToRelative(-0.39f, 0.39f, -0.39f, 1.02f, 0.0f, 1.41f) + lineToRelative(2.59f, 2.59f) + curveToRelative(0.39f, 0.39f, 1.02f, 0.39f, 1.41f, 0.0f) + lineTo(17.3f, 9.7f) + curveToRelative(0.39f, -0.39f, 0.39f, -1.02f, 0.0f, -1.41f) + curveToRelative(-0.39f, -0.39f, -1.03f, -0.39f, -1.42f, 0.0f) + close() + } + } + return _checkCircleOutline!! + } + +private var _checkCircleOutline: ImageVector? = null diff --git a/app/src/main/java/vip/chener/mixflipmod/ui/icons/ErrorOutline.kt b/app/src/main/java/vip/chener/mixflipmod/ui/icons/ErrorOutline.kt new file mode 100644 index 0000000..fd8d2c9 --- /dev/null +++ b/app/src/main/java/vip/chener/mixflipmod/ui/icons/ErrorOutline.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vip.chener.mixflipmod.ui.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +internal val Icons.Rounded.ErrorOutline: ImageVector + get() { + if (_errorOutline != null) { + return _errorOutline!! + } + _errorOutline = materialIcon(name = "Rounded.ErrorOutline") { + materialPath { + moveTo(12.0f, 7.0f) + curveToRelative(0.55f, 0.0f, 1.0f, 0.45f, 1.0f, 1.0f) + verticalLineToRelative(4.0f) + curveToRelative(0.0f, 0.55f, -0.45f, 1.0f, -1.0f, 1.0f) + reflectiveCurveToRelative(-1.0f, -0.45f, -1.0f, -1.0f) + lineTo(11.0f, 8.0f) + curveToRelative(0.0f, -0.55f, 0.45f, -1.0f, 1.0f, -1.0f) + close() + moveTo(11.99f, 2.0f) + curveTo(6.47f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f) + reflectiveCurveToRelative(4.47f, 10.0f, 9.99f, 10.0f) + curveTo(17.52f, 22.0f, 22.0f, 17.52f, 22.0f, 12.0f) + reflectiveCurveTo(17.52f, 2.0f, 11.99f, 2.0f) + close() + moveTo(12.0f, 20.0f) + curveToRelative(-4.42f, 0.0f, -8.0f, -3.58f, -8.0f, -8.0f) + reflectiveCurveToRelative(3.58f, -8.0f, 8.0f, -8.0f) + reflectiveCurveToRelative(8.0f, 3.58f, 8.0f, 8.0f) + reflectiveCurveToRelative(-3.58f, 8.0f, -8.0f, 8.0f) + close() + moveTo(13.0f, 17.0f) + horizontalLineToRelative(-2.0f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + close() + } + } + return _errorOutline!! + } + +private var _errorOutline: ImageVector? = null diff --git a/app/src/main/java/vip/chener/mixflipmod/ui/icons/VisibilityOff.kt b/app/src/main/java/vip/chener/mixflipmod/ui/icons/VisibilityOff.kt new file mode 100644 index 0000000..d3cf99e --- /dev/null +++ b/app/src/main/java/vip/chener/mixflipmod/ui/icons/VisibilityOff.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vip.chener.mixflipmod.ui.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +internal val Icons.Filled.VisibilityOff: ImageVector + get() { + if (_visibilityOff != null) { + return _visibilityOff!! + } + _visibilityOff = materialIcon(name = "Filled.VisibilityOff") { + materialPath { + moveTo(12.0f, 7.0f) + curveToRelative(2.76f, 0.0f, 5.0f, 2.24f, 5.0f, 5.0f) + curveToRelative(0.0f, 0.65f, -0.13f, 1.26f, -0.36f, 1.83f) + lineToRelative(2.92f, 2.92f) + curveToRelative(1.51f, -1.26f, 2.7f, -2.89f, 3.43f, -4.75f) + curveToRelative(-1.73f, -4.39f, -6.0f, -7.5f, -11.0f, -7.5f) + curveToRelative(-1.4f, 0.0f, -2.74f, 0.25f, -3.98f, 0.7f) + lineToRelative(2.16f, 2.16f) + curveTo(10.74f, 7.13f, 11.35f, 7.0f, 12.0f, 7.0f) + close() + moveTo(2.0f, 4.27f) + lineToRelative(2.28f, 2.28f) + lineToRelative(0.46f, 0.46f) + curveTo(3.08f, 8.3f, 1.78f, 10.02f, 1.0f, 12.0f) + curveToRelative(1.73f, 4.39f, 6.0f, 7.5f, 11.0f, 7.5f) + curveToRelative(1.55f, 0.0f, 3.03f, -0.3f, 4.38f, -0.84f) + lineToRelative(0.42f, 0.42f) + lineTo(19.73f, 22.0f) + lineTo(21.0f, 20.73f) + lineTo(3.27f, 3.0f) + lineTo(2.0f, 4.27f) + close() + moveTo(7.53f, 9.8f) + lineToRelative(1.55f, 1.55f) + curveToRelative(-0.05f, 0.21f, -0.08f, 0.43f, -0.08f, 0.65f) + curveToRelative(0.0f, 1.66f, 1.34f, 3.0f, 3.0f, 3.0f) + curveToRelative(0.22f, 0.0f, 0.44f, -0.03f, 0.65f, -0.08f) + lineToRelative(1.55f, 1.55f) + curveToRelative(-0.67f, 0.33f, -1.41f, 0.53f, -2.2f, 0.53f) + curveToRelative(-2.76f, 0.0f, -5.0f, -2.24f, -5.0f, -5.0f) + curveToRelative(0.0f, -0.79f, 0.2f, -1.53f, 0.53f, -2.2f) + close() + moveTo(11.84f, 9.02f) + lineToRelative(3.15f, 3.15f) + lineToRelative(0.02f, -0.16f) + curveToRelative(0.0f, -1.66f, -1.34f, -3.0f, -3.0f, -3.0f) + lineToRelative(-0.17f, 0.01f) + close() + } + } + return _visibilityOff!! + } + +private var _visibilityOff: ImageVector? = null diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/model/InstalledApp.kt b/app/src/main/java/vip/chener/mixflipmod/ui/model/InstalledApp.kt similarity index 86% rename from app/src/main/java/com/parallelc/mixflipmod/ui/model/InstalledApp.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/model/InstalledApp.kt index 707eb3d..981b62e 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/model/InstalledApp.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/model/InstalledApp.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.model +package vip.chener.mixflipmod.ui.model import android.content.pm.ApplicationInfo diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/screen/FlipScreenModeScreen.kt b/app/src/main/java/vip/chener/mixflipmod/ui/screen/FlipScreenModeScreen.kt similarity index 96% rename from app/src/main/java/com/parallelc/mixflipmod/ui/screen/FlipScreenModeScreen.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/screen/FlipScreenModeScreen.kt index 6786305..637f732 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/screen/FlipScreenModeScreen.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/screen/FlipScreenModeScreen.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.screen +package vip.chener.mixflipmod.ui.screen import android.content.SharedPreferences import androidx.compose.foundation.basicMarquee @@ -41,15 +41,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.edit import kotlin.math.roundToInt -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.Prefs.FlipScreenMode -import com.parallelc.mixflipmod.R -import com.parallelc.mixflipmod.ui.component.AppIcon -import com.parallelc.mixflipmod.ui.model.InstalledApp -import com.parallelc.mixflipmod.ui.util.XposedServiceState -import com.parallelc.mixflipmod.ui.util.checkScopes -import com.parallelc.mixflipmod.ui.util.filterByQuery -import com.parallelc.mixflipmod.ui.util.rememberInstalledAppsState +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.Prefs.FlipScreenMode +import vip.chener.mixflipmod.R +import vip.chener.mixflipmod.ui.component.AppIcon +import vip.chener.mixflipmod.ui.model.InstalledApp +import vip.chener.mixflipmod.ui.util.XposedServiceState +import vip.chener.mixflipmod.ui.util.checkScopes +import vip.chener.mixflipmod.ui.util.filterByQuery +import vip.chener.mixflipmod.ui.util.rememberInstalledAppsState import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.DropdownArrowEndAction diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/screen/GroupSwitchScreen.kt b/app/src/main/java/vip/chener/mixflipmod/ui/screen/GroupSwitchScreen.kt similarity index 80% rename from app/src/main/java/com/parallelc/mixflipmod/ui/screen/GroupSwitchScreen.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/screen/GroupSwitchScreen.kt index cb0919d..c9aeae4 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/screen/GroupSwitchScreen.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/screen/GroupSwitchScreen.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.screen +package vip.chener.mixflipmod.ui.screen import android.content.SharedPreferences import androidx.annotation.StringRes @@ -19,13 +19,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.edit -import com.parallelc.mixflipmod.R -import com.parallelc.mixflipmod.model.AppConfig -import com.parallelc.mixflipmod.ui.component.AppIcon -import com.parallelc.mixflipmod.ui.util.XposedServiceState -import com.parallelc.mixflipmod.ui.util.appLabel -import com.parallelc.mixflipmod.ui.util.checkScopes -import com.parallelc.mixflipmod.ui.util.scopePackage +import vip.chener.mixflipmod.R +import vip.chener.mixflipmod.model.AppConfig +import vip.chener.mixflipmod.ui.component.AppIcon +import vip.chener.mixflipmod.ui.util.XposedServiceState +import vip.chener.mixflipmod.ui.util.ConfiguredApp +import vip.chener.mixflipmod.ui.util.checkScopes +import vip.chener.mixflipmod.ui.util.scopePackage import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton @@ -37,19 +37,18 @@ import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.scrollEndHaptic @Composable -fun GroupSwitchScreen( +internal fun GroupSwitchScreen( prefs: SharedPreferences, @StringRes titleRes: Int, configs: List, + apps: Map, onBack: () -> Unit, ) { val ctx = LocalContext.current val pm = ctx.packageManager val scrollBehavior = MiuixScrollBehavior() - val visibleConfigs = remember(configs) { - configs.filter { - runCatching { pm.getApplicationInfo(it.packageName, 0) }.isSuccess - } + val visibleConfigs = remember(configs, apps) { + configs.filter { it.packageName in apps } } Scaffold(topBar = { @@ -82,13 +81,10 @@ fun GroupSwitchScreen( val checked = remember(prefs, prefKey) { mutableStateOf(prefs.getBoolean(prefKey, false)) } - val appInfo = remember(config.packageName) { - runCatching { pm.getApplicationInfo(config.packageName, 0) }.getOrNull() - } + val app = apps.getValue(config.packageName) + val appInfo = app.appInfo SwitchPreference( - title = remember(config.packageName) { - pm.appLabel(config.packageName, config.packageName) - }, + title = app.label, summary = config.packageName, checked = checked.value, startAction = { diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/screen/SubScreen.kt b/app/src/main/java/vip/chener/mixflipmod/ui/screen/SubScreen.kt similarity index 95% rename from app/src/main/java/com/parallelc/mixflipmod/ui/screen/SubScreen.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/screen/SubScreen.kt index db57ce9..bf56f73 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/screen/SubScreen.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/screen/SubScreen.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.screen +package vip.chener.mixflipmod.ui.screen import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -19,8 +19,8 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.parallelc.mixflipmod.R -import com.parallelc.mixflipmod.ui.component.RestartDialog +import vip.chener.mixflipmod.R +import vip.chener.mixflipmod.ui.component.RestartDialog import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/util/AppUtils.kt b/app/src/main/java/vip/chener/mixflipmod/ui/util/AppUtils.kt similarity index 86% rename from app/src/main/java/com/parallelc/mixflipmod/ui/util/AppUtils.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/util/AppUtils.kt index 90da1c7..caab33d 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/util/AppUtils.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/util/AppUtils.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.util +package vip.chener.mixflipmod.ui.util import android.content.pm.PackageManager diff --git a/app/src/main/java/vip/chener/mixflipmod/ui/util/ConfiguredApps.kt b/app/src/main/java/vip/chener/mixflipmod/ui/util/ConfiguredApps.kt new file mode 100644 index 0000000..ba12e32 --- /dev/null +++ b/app/src/main/java/vip/chener/mixflipmod/ui/util/ConfiguredApps.kt @@ -0,0 +1,46 @@ +package vip.chener.mixflipmod.ui.util + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import vip.chener.mixflipmod.model.appConfigs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal data class ConfiguredApp( + val packageName: String, + val label: String, + val appInfo: ApplicationInfo?, +) + +@Composable +internal fun rememberConfiguredApps(): State> { + val pm = LocalContext.current.packageManager + val lifecycle = LocalLifecycleOwner.current.lifecycle + val locales = LocalConfiguration.current.locales.toLanguageTags() + return produceState>(emptyMap(), pm, lifecycle, locales) { + // Query once per foreground entry, never during composition or scrolling. + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + value = withContext(Dispatchers.IO) { loadConfiguredApps(pm) } + } + } +} + +private fun loadConfiguredApps(pm: PackageManager): Map = buildMap { + appConfigs.map { it.packageName }.distinct().forEach { packageName -> + val info = runCatching { pm.getApplicationInfo(packageName, 0) }.getOrNull() + if (info != null || packageName == "android") { + val label = info?.let { + runCatching { pm.getApplicationLabel(it).toString() }.getOrNull() + } ?: packageName + put(packageName, ConfiguredApp(packageName, label, info)) + } + } +} diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/util/InstalledApps.kt b/app/src/main/java/vip/chener/mixflipmod/ui/util/InstalledApps.kt similarity index 98% rename from app/src/main/java/com/parallelc/mixflipmod/ui/util/InstalledApps.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/util/InstalledApps.kt index 33835fc..19b74d1 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/util/InstalledApps.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/util/InstalledApps.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.util +package vip.chener.mixflipmod.ui.util import android.content.Context import android.content.pm.ApplicationInfo @@ -15,7 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import com.parallelc.mixflipmod.ui.model.InstalledApp +import vip.chener.mixflipmod.ui.model.InstalledApp import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/util/Restart.kt b/app/src/main/java/vip/chener/mixflipmod/ui/util/Restart.kt similarity index 97% rename from app/src/main/java/com/parallelc/mixflipmod/ui/util/Restart.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/util/Restart.kt index 9d07fbf..55744cc 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/util/Restart.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/util/Restart.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.util +package vip.chener.mixflipmod.ui.util import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/app/src/main/java/com/parallelc/mixflipmod/ui/util/XposedServiceUtils.kt b/app/src/main/java/vip/chener/mixflipmod/ui/util/XposedServiceUtils.kt similarity index 92% rename from app/src/main/java/com/parallelc/mixflipmod/ui/util/XposedServiceUtils.kt rename to app/src/main/java/vip/chener/mixflipmod/ui/util/XposedServiceUtils.kt index d663be0..aac3557 100644 --- a/app/src/main/java/com/parallelc/mixflipmod/ui/util/XposedServiceUtils.kt +++ b/app/src/main/java/vip/chener/mixflipmod/ui/util/XposedServiceUtils.kt @@ -1,4 +1,4 @@ -package com.parallelc.mixflipmod.ui.util +package vip.chener.mixflipmod.ui.util import android.content.SharedPreferences import android.os.Handler @@ -6,10 +6,10 @@ import android.os.Looper import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.parallelc.mixflipmod.Prefs -import com.parallelc.mixflipmod.model.AppConfig -import com.parallelc.mixflipmod.model.PrefSpec -import com.parallelc.mixflipmod.model.appConfigs +import vip.chener.mixflipmod.Prefs +import vip.chener.mixflipmod.model.AppConfig +import vip.chener.mixflipmod.model.PrefSpec +import vip.chener.mixflipmod.model.appConfigs import io.github.libxposed.service.XposedService import io.github.libxposed.service.XposedService.OnScopeEventListener import io.github.libxposed.service.XposedService.ServiceException diff --git a/app/src/main/resources/META-INF/xposed/java_init.list b/app/src/main/resources/META-INF/xposed/java_init.list index 07e5bec..c3f86b9 100644 --- a/app/src/main/resources/META-INF/xposed/java_init.list +++ b/app/src/main/resources/META-INF/xposed/java_init.list @@ -1 +1 @@ -com.parallelc.mixflipmod.Main +vip.chener.mixflipmod.Main diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95da956..47d40dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,6 @@ dexkit = "2.2.0" androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } androidx-foundation = { module = "androidx.compose.foundation:foundation" } androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } -androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } miuix-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-ui-android", version.ref = "miuix" } miuix-preference = { group = "top.yukonga.miuix.kmp", name = "miuix-preference-android", version.ref = "miuix" } miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons-android", version.ref = "miuix" } @@ -23,6 +22,7 @@ libxposed-service = { group = "io.github.libxposed", name = "service", version.r compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-runtime-ktx" } dexkit = { group = "org.luckypray", name = "dexkit", version.ref = "dexkit" } [plugins] From 2ef8ec48bbb062c2e04be5d39cea88df27dc2f9a Mon Sep 17 00:00:00 2001 From: daixu0502 Date: Fri, 23 Oct 2026 14:58:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E9=87=8D=E6=9E=84=20workflow=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8F=91=E5=B8=83=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/release.py | 316 +++++++++++++++++++++++++ .github/scripts/tests/test_pipeline.py | 118 +++++++++ .github/scripts/tests/test_release.py | 187 +++++++++++++++ .github/workflows/ci_build.yml | 67 ------ .github/workflows/release.yml | 106 +++++++++ README.md | 13 + app/build.gradle.kts | 32 ++- 7 files changed, 766 insertions(+), 73 deletions(-) create mode 100644 .github/scripts/release.py create mode 100644 .github/scripts/tests/test_pipeline.py create mode 100644 .github/scripts/tests/test_release.py delete mode 100644 .github/workflows/ci_build.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/scripts/release.py b/.github/scripts/release.py new file mode 100644 index 0000000..7d500db --- /dev/null +++ b/.github/scripts/release.py @@ -0,0 +1,316 @@ +"""Version-aware GitHub publishing. Uses Python's standard library and Git only.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +from urllib.error import HTTPError +from urllib.parse import quote, urlencode, urlparse +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[2] +WORK = ROOT / "build/github-release" +PLAN = WORK / "plan.json" +NOTES = WORK / "notes.md" +DIST = WORK / "dist" +VERSION = re.compile(r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*))?(?:-beta(?:\.?\d+)?)?$", re.I) +MAX_NOTES_BYTES = 100_000 +APPLICATION_ID = "vip.chener.mixflipmod" + + +def classify_version(version): + if not isinstance(version, str) or not VERSION.fullmatch(version): + raise ValueError("versionName must be numeric (2.0 / 2.0.1) or beta (2.1-beta / 2.1-beta1 / 2.1-beta.1)") + return "-beta" in version.lower() + + +def validate_metadata(metadata): + prerelease = classify_version(metadata["versionName"]) + for name in ("versionCode", "compileSdk"): + if type(metadata[name]) is not int or metadata[name] <= 0: + raise ValueError(f"{name} must be a positive integer") + minor = metadata.get("compileSdkMinor", 0) + if type(minor) is not int or minor < 0: + raise ValueError("compileSdkMinor must be a non-negative integer") + if not re.fullmatch(r"\d+\.\d+\.\d+", metadata["buildToolsVersion"]): + raise ValueError("buildToolsVersion must identify a stable Android build-tools package") + return prerelease + + +def sdk_platform_package(metadata): + """SDK 37+ base platforms include .0; older base platforms use the major alone.""" + validate_metadata(metadata) + major = metadata["compileSdk"] + minor = metadata.get("compileSdkMinor", 0) + version = f"{major}.{minor}" if major >= 37 or minor > 0 else str(major) + return f"platforms;android-{version}" + + +def git(*arguments, check=True): + return subprocess.run(["git", *arguments], cwd=ROOT, text=True, encoding="utf-8", + capture_output=True, check=check) + + +def resolve_commit(reference): + result = git("rev-parse", "--verify", "--end-of-options", f"{reference}^{{commit}}", check=False) + if result.returncode: + raise ValueError(f"Cannot resolve Git reference {reference!r}; full history and tags are required") + return result.stdout.strip() + + +class GitHub: + def __init__(self, repository=None, token=None): + self.repository = repository if repository is not None else os.environ["GITHUB_REPOSITORY"] + self.server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + self.api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com") + self.token = token if token is not None else os.environ["GH_TOKEN"] + + def request(self, method, endpoint, payload=None, binary=None): + # GitHub's repository endpoint returns 404 when an empty endpoint adds a trailing slash. + repository_url = f"{self.api_url.rstrip('/')}/repos/{self.repository}" + url = endpoint if endpoint.startswith("https://") else repository_url + ("/" + endpoint if endpoint else "") + if urlparse(url).hostname not in { + urlparse(self.api_url).hostname, urlparse(self.server_url).hostname, "uploads.github.com", + }: + raise ValueError("Refusing an unexpected GitHub upload host") + data = binary if binary is not None else (json.dumps(payload).encode() if payload is not None else None) + headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "MixFlipMod-release-workflow"} + if data is not None: + headers["Content-Type"] = "application/octet-stream" if binary is not None else "application/json" + try: + with urlopen(Request(url, data=data, headers=headers, method=method), timeout=60) as response: + content = response.read() + return json.loads(content) if content else None + except HTTPError as error: + # Do not print request headers or credentials on failure. + raise RuntimeError(f"GitHub API {method} failed with HTTP {error.code}; check Actions permissions and logs") from None + + def pages(self, endpoint): + values = [] + page = 1 + while True: + batch = self.request("GET", f"{endpoint}?per_page=100&page={page}") + values.extend(batch) + if len(batch) < 100: + return values + page += 1 + + +def previous_release(releases, prerelease, current_tag): + candidates = [release for release in releases + if not release["draft"] and release["prerelease"] == prerelease + and release["tag_name"] != current_tag] + return max(candidates, key=lambda release: (release.get("published_at") or "", release["id"]), default=None) + + +def commit_messages(previous_tag, commit): + revision = commit if previous_tag is None else f"{resolve_commit('refs/tags/' + previous_tag)}..{commit}" + output = git("log", "--reverse", "--no-merges", "--format=%H%x00%s", revision, "--").stdout + return [tuple(line.split("\0", 1)) for line in output.splitlines() if line] + + +def markdown_text(text): + text = " ".join(text.split()).replace("&", "&").replace("<", "<").replace(">", ">") + return re.sub(r"([\\`*_\[\]])", r"\\\1", text) + + +def release_notes(repository, server_url, version, prerelease, previous_tag, commits): + channel = "预发布版(Beta)" if prerelease else "正式版" + repository_url = f"{server_url}/{repository}" + tag = "v" + version + lines = [f"## {version} · {channel}", ""] + if previous_tag: + lines += [f"以下为自上一个同类型版本 **{markdown_text(previous_tag)}** 以来的改动。", ""] + else: + lines += [f"这是首个{channel},以下汇总截至本版本的提交。", ""] + lines += ["### 改动记录", ""] + shown = 0 + used_bytes = len("\n".join(lines).encode()) + for sha, subject in commits: + item = f"- {markdown_text(subject)} ([{sha[:7]}]({repository_url}/commit/{sha}))" + if used_bytes + len(item.encode()) > MAX_NOTES_BYTES - 2_000: + break + lines.append(item) + used_bytes += len(item.encode()) + 1 + shown += 1 + if not commits: + lines.append("- 与上一个同类型版本相比,没有新的非合并提交。") + if shown < len(commits): + lines += ["", f"共 {len(commits)} 项提交,此处列出前 {shown} 项;其余请查看完整对比。"] + if previous_tag: + comparison = f"{repository_url}/compare/{quote(previous_tag, safe='')}...{quote(tag, safe='')}" + else: + comparison = f"{repository_url}/commits/{quote(tag, safe='')}" + lines += ["", f"**完整对比/历史**:{comparison}", ""] + return "\n".join(lines) + + +def output(name, value): + if "GITHUB_OUTPUT" in os.environ: + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream: + stream.write(f"{name}={value}\n") + + +def summary(text): + print(text) + if "GITHUB_STEP_SUMMARY" in os.environ: + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as stream: + stream.write(text + "\n\n") + + +def verify_draft_target(release, commit, resolve_target=None): + if (resolve_target or resolve_commit)(release["target_commitish"]) != commit: + raise ValueError("An existing draft for this version targets another commit; resolve the draft before retrying") + + +def prepare(): + metadata = json.loads((ROOT / "app/build/release-metadata.json").read_text(encoding="utf-8")) + prerelease = validate_metadata(metadata) + version = metadata["versionName"] + tag = "v" + version + reference = os.environ.get("GITHUB_REF", "") + if reference.startswith("refs/tags/") and reference != "refs/tags/" + tag: + raise ValueError(f"Tag must match app versionName exactly: expected {tag}") + for name, value in {"version": version, "sdk_platform_package": sdk_platform_package(metadata), + "build_tools": metadata["buildToolsVersion"]}.items(): + output(name, value) + api = GitHub() + releases = api.pages("releases") + existing = next((item for item in releases if item["tag_name"] == tag), None) + if existing and not existing["draft"]: + output("publish", "false") + summary(f"{tag} 已发布,跳过构建和上传。发布新版本请同时提高 versionName 和 versionCode。") + return + commit = resolve_commit("HEAD") + if existing: + verify_draft_target(existing, commit) + tag_result = git("show-ref", "--verify", "--quiet", "refs/tags/" + tag, check=False) + if tag_result.returncode == 0 and resolve_commit("refs/tags/" + tag) != commit: + raise ValueError(f"Existing tag {tag} points to another commit; refusing to replace it") + previous = previous_release(releases, prerelease, tag) + previous_tag = previous["tag_name"] if previous else None + notes = release_notes(api.repository, api.server_url, version, prerelease, previous_tag, + commit_messages(previous_tag, commit)) + plan = {**metadata, "tag": tag, "prerelease": prerelease, "commit": commit, "previous_tag": previous_tag} + WORK.mkdir(parents=True, exist_ok=True) + PLAN.write_text(json.dumps(plan, indent=2), encoding="utf-8") + NOTES.write_text(notes, encoding="utf-8") + output("publish", "true") + summary(f"计划发布 {tag}({'Pre-release' if prerelease else 'Release'});改动基准:{previous_tag or '仓库起点'}。") + + +def signing(): + required = { + "KEY_STORE": "MIXFLIPMOD_KEYSTORE_BASE64", + "KEY_STORE_PASSWORD": "ORG_GRADLE_PROJECT_androidStorePassword", + "ALIAS": "ORG_GRADLE_PROJECT_androidKeyAlias", + } + for secret, variable in required.items(): + if not os.environ.get(variable): + raise ValueError(f"Missing repository Actions secret: {secret}") + encoded = "".join(os.environ["MIXFLIPMOD_KEYSTORE_BASE64"].split()) + try: + key_bytes = base64.b64decode(encoded, validate=True) + except ValueError: + raise ValueError("KEY_STORE is not valid Base64") from None + if not key_bytes: + raise ValueError("The signing keystore is empty") + keystore = Path(os.environ["ORG_GRADLE_PROJECT_androidStoreFile"]) + keystore.write_bytes(key_bytes) + keystore.chmod(0o600) + +def apk_filename(metadata): + return f"MixFlipMod_{metadata['versionName']}_{metadata['versionCode']}.apk" + + +def package(): + plan = json.loads(PLAN.read_text(encoding="utf-8")) + validate_metadata(plan) + apk_directory = ROOT / "app/build/outputs/apk/release" + metadata = json.loads((apk_directory / "output-metadata.json").read_text(encoding="utf-8")) + elements = metadata["elements"] + filename = apk_filename(plan) + if metadata["applicationId"] != APPLICATION_ID: + raise ValueError("Built APK applicationId differs from the release application") + if len(elements) != 1 or elements[0]["outputFile"] != filename: + raise ValueError(f"Expected exactly one signed {filename}") + if elements[0]["versionName"] != plan["versionName"] or elements[0]["versionCode"] != plan["versionCode"]: + raise ValueError("Built APK version differs from the planned release") + source = apk_directory / filename + verify_apk_metadata(source, plan) + DIST.mkdir(parents=True, exist_ok=True) + apk = DIST / filename + shutil.copyfile(source, apk) + digest = hashlib.sha256(apk.read_bytes()).hexdigest() + apk.with_suffix(".apk.sha256").write_text(f"{digest} {apk.name}\n", encoding="utf-8") + +def publish_release(api, plan, notes, assets, resolve_target=None): + existing = next((item for item in api.pages("releases") if item["tag_name"] == plan["tag"]), None) + if existing and not existing["draft"]: + return existing # A rerun must never replace assets/notes of an already public release. + if existing: + verify_draft_target(existing, plan["commit"], resolve_target) + payload = {"tag_name": plan["tag"], "target_commitish": plan["commit"], + "name": plan.get("versionName", plan["tag"]), + "body": notes, "draft": True, "prerelease": plan["prerelease"]} + if existing: + release = api.request("PATCH", f"releases/{existing['id']}", payload) + else: + release = api.request("POST", "releases", payload) + asset_endpoint = f"releases/{release['id']}/assets" + uploaded = api.pages(asset_endpoint) + for asset in assets: + for old in uploaded: + if old["name"] == asset.name: + api.request("DELETE", f"releases/assets/{old['id']}") + upload_url = release["upload_url"].split("{", 1)[0] + "?" + urlencode({"name": asset.name}) + api.request("POST", upload_url, binary=asset.read_bytes()) + # Keep a failed upload private as a recoverable draft; publish only after every asset succeeds. + return api.request("PATCH", f"releases/{release['id']}", { + "draft": False, "prerelease": plan["prerelease"], + "make_latest": "false" if plan["prerelease"] else "true", + }) + + +def publish(): + plan = json.loads(PLAN.read_text(encoding="utf-8")) + apk = DIST / apk_filename(plan) + assets = [apk, apk.with_suffix(".apk.sha256")] + if not all(asset.is_file() for asset in assets): + raise ValueError("Verified APK and SHA256 file must both exist before publishing") + release = publish_release(GitHub(), plan, NOTES.read_text(encoding="utf-8"), assets) + summary(f"发布完成:{release['html_url']}") + +def verify_apk_metadata(apk, metadata): + executable = "aapt2.exe" if os.name == "nt" else "aapt2" + aapt2 = Path(os.environ["ANDROID_HOME"]) / "build-tools" / metadata["buildToolsVersion"] / executable + result = subprocess.run([str(aapt2), "dump", "badging", str(apk)], + text=True, encoding="utf-8", capture_output=True, check=True) + package = re.search(r"^package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'", result.stdout, re.M) + if (package is None or package[1] != APPLICATION_ID or int(package[2]) != metadata["versionCode"] + or package[3] != metadata["versionName"]): + raise ValueError("APK package/version differs from Gradle metadata; refusing publication") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("prepare", "signing", "package", "publish")) + command = parser.parse_args().command + try: + globals()[command]() + except (ValueError, RuntimeError, KeyError, OSError, subprocess.CalledProcessError) as error: + print(f"Release workflow failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/tests/test_pipeline.py b/.github/scripts/tests/test_pipeline.py new file mode 100644 index 0000000..6a843c5 --- /dev/null +++ b/.github/scripts/tests/test_pipeline.py @@ -0,0 +1,118 @@ +import hashlib +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from test_release import FakeGitHub, item, release + + +class PipelineTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.metadata = {"versionName": "3.1.0", "versionCode": 30100, "compileSdk": 37, + "compileSdkMinor": 0, "buildToolsVersion": "36.0.0"} + self.work = self.root / "build/github-release" + for name, value in (("ROOT", self.root), ("WORK", self.work), + ("PLAN", self.work / "plan.json"), ("NOTES", self.work / "notes.md"), + ("DIST", self.work / "dist")): + self.enterContext(patch.object(release, name, value)) + metadata_file = self.root / "app/build/release-metadata.json" + metadata_file.parent.mkdir(parents=True) + metadata_file.write_text(json.dumps(self.metadata), encoding="utf-8") + self.api = FakeGitHub() + self.client = self.enterContext(patch.object(release, "GitHub", return_value=self.api)) + self.outputs = self.enterContext(patch.object(release, "output")) + self.enterContext(patch.object(release, "summary")) + + def test_wrong_tag_fails_before_github_access(self): + with patch.dict(os.environ, {"GITHUB_REF": "refs/tags/v9.9"}): + with self.assertRaisesRegex(ValueError, "Tag must match"): + release.prepare() + self.client.assert_not_called() + + def test_public_version_skips_build_and_plan(self): + self.api.releases = [item(1, "v3.1.0")] + with patch.dict(os.environ, {"GITHUB_REF": "refs/heads/main"}): + release.prepare() + self.outputs.assert_any_call("publish", "false") + self.assertFalse(release.PLAN.exists()) + + def prepare_apk(self): + self.work.mkdir(parents=True) + release.PLAN.write_text(json.dumps(self.metadata), encoding="utf-8") + directory = self.root / "app/build/outputs/apk/release" + directory.mkdir(parents=True) + filename = "MixFlipMod_3.1.0_30100.apk" + source = directory / filename + source.write_bytes(b"test-only APK") + output = {"applicationId": "vip.chener.mixflipmod", "elements": [{ + "outputFile": filename, "versionName": "3.1.0", "versionCode": 30100, + }]} + return directory, source, output + + def test_apk_keeps_generated_filename_and_valid_checksum(self): + directory, source, output = self.prepare_apk() + (directory / "output-metadata.json").write_text(json.dumps(output), encoding="utf-8") + with patch.object(release, "verify_apk_metadata") as verify: + release.package() + verify.assert_called_once_with(source, self.metadata) + apk = release.DIST / source.name + self.assertEqual(source.read_bytes(), apk.read_bytes()) + digest = hashlib.sha256(apk.read_bytes()).hexdigest() + self.assertEqual(f"{digest} {apk.name}\n", + apk.with_suffix(".apk.sha256").read_text(encoding="utf-8")) + + def test_other_app_or_version_is_not_staged(self): + directory, _, output = self.prepare_apk() + invalid = [ + {**output, "applicationId": "vip.chener.musicenhance"}, + {**output, "elements": [{**output["elements"][0], "versionCode": 999}]}, + {**output, "elements": [{**output["elements"][0], "outputFile": "../wrong.apk"}]}, + {**output, "elements": output["elements"] * 2}, + ] + for metadata in invalid: + with self.subTest(metadata=metadata): + (directory / "output-metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + with self.assertRaises(ValueError): + release.package() + self.assertFalse(release.DIST.exists()) + + def test_bad_apk_header_stops_staging(self): + directory, _, output = self.prepare_apk() + (directory / "output-metadata.json").write_text(json.dumps(output), encoding="utf-8") + with patch.object(release, "verify_apk_metadata", side_effect=ValueError("Bad APK header")): + with self.assertRaisesRegex(ValueError, "Bad APK"): + release.package() + self.assertFalse(release.DIST.exists()) + + def test_signing_requires_secrets(self): + with patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(ValueError, "KEY_STORE"): + release.signing() + + def test_signing_rejects_bad_base64(self): + env = {"MIXFLIPMOD_KEYSTORE_BASE64": "@@@", + "ORG_GRADLE_PROJECT_androidStorePassword": "test-password", + "ORG_GRADLE_PROJECT_androidKeyAlias": "test-alias", + "ORG_GRADLE_PROJECT_androidStoreFile": str(self.root / "bad.jks")} + with patch.dict(os.environ, env, clear=True), self.assertRaisesRegex(ValueError, "Base64"): + release.signing() + self.assertFalse((self.root / "bad.jks").exists()) + + def test_signing_accepts_wrapped_base64(self): + keystore = self.root / "test-signing.jks" + env = {"MIXFLIPMOD_KEYSTORE_BASE64": " dGVzdC1rZXk=\n", + "ORG_GRADLE_PROJECT_androidStorePassword": "test-password", + "ORG_GRADLE_PROJECT_androidKeyAlias": "test-alias", + "ORG_GRADLE_PROJECT_androidStoreFile": str(keystore)} + with patch.dict(os.environ, env, clear=True): + release.signing() + self.assertEqual(b"test-key", keystore.read_bytes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_release.py b/.github/scripts/tests/test_release.py new file mode 100644 index 0000000..eab6f5a --- /dev/null +++ b/.github/scripts/tests/test_release.py @@ -0,0 +1,187 @@ +import importlib.util +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + + +SPEC = importlib.util.spec_from_file_location("mixflipmod_release", Path(__file__).parents[1] / "release.py") +release = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release +SPEC.loader.exec_module(release) + + +def item(identifier, tag, prerelease=False, draft=False, published="2026-09-23T00:00:00Z"): + return {"id": identifier, "tag_name": tag, "prerelease": prerelease, "draft": draft, + "published_at": published, "target_commitish": "abc", "upload_url": "https://uploads.github.com/assets{?name}", + "html_url": "https://github.com/owner/repo/releases/tag/" + tag} + + +class FakeGitHub: + repository = "owner/repo" + server_url = "https://github.com" + + def __init__(self, releases=None, fail_upload=False): + self.releases = releases or [] + self.calls = [] + self.fail_upload = fail_upload + + def pages(self, endpoint): + return self.releases if endpoint == "releases" else [{"id": 100, "name": "test.apk"}] + + def request(self, method, endpoint, payload=None, binary=None): + self.calls.append((method, endpoint, payload)) + if binary is not None and self.fail_upload: + raise RuntimeError("upload failed") + if binary is not None or method == "DELETE": + return {} + return item(1, payload.get("tag_name", "v2.0"), payload.get("prerelease", False), payload.get("draft", True)) + + +class VersionAndNotesTests(unittest.TestCase): + def test_platform_packages_keep_required_minor_versions(self): + metadata = {"versionName": "2.0.5", "versionCode": 41, "buildToolsVersion": "36.0.0"} + for major, minor, expected in ( + (35, 0, "platforms;android-35"), + (36, 0, "platforms;android-36"), + (36, 1, "platforms;android-36.1"), + (37, 0, "platforms;android-37.0"), + (37, 2, "platforms;android-37.2"), + ): + with self.subTest(major=major, minor=minor): + self.assertEqual(expected, release.sdk_platform_package({ + **metadata, "compileSdk": major, "compileSdkMinor": minor, + })) + self.assertEqual("platforms;android-37.0", release.sdk_platform_package({**metadata, "compileSdk": 37})) + + def test_invalid_minor_versions_are_rejected(self): + metadata = {"versionName": "2.0.5", "versionCode": 41, "compileSdk": 37, "buildToolsVersion": "36.0.0"} + for minor in (-1, "0", True, "0;tools"): + with self.subTest(minor=minor), self.assertRaises(ValueError): + release.sdk_platform_package({**metadata, "compileSdkMinor": minor}) + + def test_numeric_versions_are_stable_and_beta_variants_are_previews(self): + for value in ("2.0", "2.0.1", "12.31.0"): + self.assertFalse(release.classify_version(value)) + for value in ("2.1-beta", "2.1-beta1", "2.1-beta.2", "2.1.0-BETA.3"): + self.assertTrue(release.classify_version(value)) + + def test_unknown_suffixes_and_path_characters_fail_instead_of_publishing_stable(self): + for value in ("2.1-rc.1", "2.1-alpha", "2.1/dev", "v2.0", "2.0\nfoo", "2.01", "2.0;false"): + with self.assertRaises(ValueError): + release.classify_version(value) + + def test_each_channel_uses_its_own_most_recent_published_release(self): + releases = [item(1, "v1.9", published="2026-09-20T00:00:00Z"), + item(2, "v2.0-beta.1", True, published="2026-09-21T00:00:00Z"), + item(3, "v2.0-beta.2", True, published="2026-09-22T00:00:00Z"), + item(4, "v2.0", draft=True), item(5, "v2.1-beta.1", True, draft=True)] + self.assertEqual("v1.9", release.previous_release(releases, False, "v2.0")["tag_name"]) + self.assertEqual("v2.0-beta.2", release.previous_release(releases, True, "v2.1-beta.1")["tag_name"]) + + def test_first_stable_does_not_use_a_beta_as_its_base(self): + self.assertIsNone(release.previous_release([item(1, "v2.0-beta.1", True)], False, "v2.0")) + self.assertIsNone(release.previous_release([item(1, "v2.0")], True, "v2.1-beta.1")) + + def test_notes_escape_commit_messages_and_link_to_the_exact_base(self): + notes = release.release_notes("owner/repo", "https://github.com", "2.0", False, "v1.9", + [("a" * 40, "fix: [lyrics] *blur*")]) + self.assertIn("compare/v1.9...v2.0", notes) + self.assertIn(r"\[lyrics\] <layout> \*blur\*", notes) + self.assertIn("a" * 40, notes) + + def test_first_release_and_empty_range_have_explicit_notes(self): + first = release.release_notes("owner/repo", "https://github.com", "2.0", False, None, []) + self.assertIn("首个正式版", first) + self.assertIn("/commits/v2.0", first) + + def test_long_history_is_bounded_with_full_comparison_link(self): + notes = release.release_notes("owner/repo", "https://github.com", "2.0", False, "v1.9", + [("a" * 40, "改动" * 100) for _ in range(2000)]) + self.assertLess(len(notes.encode()), release.MAX_NOTES_BYTES) + self.assertIn("共 2000 项提交", notes) + self.assertIn("compare/v1.9...v2.0", notes) + + +class PublishTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.apk = Path(self.directory.name) / "test.apk" + self.apk.write_bytes(b"test-only") + self.plan = {"tag": "v2.0", "commit": "abc", "prerelease": False} + + def test_assets_are_uploaded_while_draft_before_publishing_stable(self): + api = FakeGitHub() + release.publish_release(api, self.plan, "notes", [self.apk]) + self.assertTrue(api.calls[0][2]["draft"]) + self.assertTrue(any(call[1].startswith("https://uploads.") for call in api.calls[:-1])) + self.assertEqual({"draft": False, "prerelease": False, "make_latest": "true"}, api.calls[-1][2]) + + def test_beta_does_not_replace_the_latest_stable_release(self): + api = FakeGitHub() + release.publish_release(api, {**self.plan, "prerelease": True}, "notes", [self.apk]) + self.assertEqual({"draft": False, "prerelease": True, "make_latest": "false"}, api.calls[-1][2]) + + def test_release_title_uses_version_name_instead_of_tag(self): + api = FakeGitHub() + release.publish_release(api, {**self.plan, "versionName": "2.0"}, "notes", [self.apk]) + self.assertEqual("2.0", api.calls[0][2]["name"]) + self.assertEqual("v2.0", api.calls[0][2]["tag_name"]) + + def test_failed_upload_never_publishes_the_draft(self): + api = FakeGitHub(fail_upload=True) + with self.assertRaises(RuntimeError): + release.publish_release(api, self.plan, "notes", [self.apk]) + self.assertFalse(any(call[2] and call[2].get("draft") is False for call in api.calls)) + + def test_public_release_is_never_overwritten_on_rerun(self): + api = FakeGitHub([item(1, "v2.0")]) + release.publish_release(api, self.plan, "new notes", [self.apk]) + self.assertEqual([], api.calls) + + def test_draft_retry_replaces_matching_assets_and_preserves_the_release(self): + api = FakeGitHub([item(1, "v2.0", draft=True)]) + with patch.object(release, "resolve_commit", return_value="abc"): + release.publish_release(api, self.plan, "notes", [self.apk]) + self.assertEqual("PATCH", api.calls[0][0]) + self.assertIn(("DELETE", "releases/assets/100", None), api.calls) + + def test_draft_from_another_commit_is_rejected(self): + api = FakeGitHub([item(1, "v2.0", draft=True)]) + with patch.object(release, "resolve_commit", return_value="different"): + with self.assertRaises(ValueError): + release.publish_release(api, self.plan, "notes", [self.apk]) + self.assertEqual([], api.calls) + + +class GitHistoryTests(unittest.TestCase): + def test_real_git_ranges_include_all_beta_changes_in_the_next_stable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def git(*args): + return subprocess.run(["git", "-c", "user.name=Workflow Test", "-c", "user.email=test@example.invalid", *args], + cwd=root, check=True, capture_output=True, text=True).stdout.strip() + git("init") + git("commit", "--allow-empty", "-m", "initial") + git("tag", "v1.9") + git("commit", "--allow-empty", "-m", "first beta change") + git("tag", "v2.0-beta.1") + git("commit", "--allow-empty", "-m", "second beta change") + head = git("rev-parse", "HEAD") + with patch.object(release, "ROOT", root): + stable = release.commit_messages("v1.9", head) + beta = release.commit_messages("v2.0-beta.1", head) + first = release.commit_messages(None, head) + self.assertEqual(["first beta change", "second beta change"], [subject for _, subject in stable]) + self.assertEqual(["second beta change"], [subject for _, subject in beta]) + self.assertEqual(3, len(first)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci_build.yml b/.github/workflows/ci_build.yml deleted file mode 100644 index ec00142..0000000 --- a/.github/workflows/ci_build.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: CI Build - -on: - workflow_dispatch: - push: - branches: [ main ] - tags: [ v* ] - paths-ignore: - - '*.md' - - '.github/**' - - '!.github/workflows/**' - pull_request: - types: [ opened, synchronize, reopened ] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Write key - run: | - if [ ! -z "${{ secrets.KEY_STORE }}" ]; then - echo androidStorePassword='${{ secrets.KEY_STORE_PASSWORD }}' >> gradle.properties - echo androidKeyAlias='${{ secrets.ALIAS }}' >> gradle.properties - echo androidKeyPassword='${{ secrets.KEY_PASSWORD }}' >> gradle.properties - echo androidStoreFile='key.jks' >> gradle.properties - echo ${{ secrets.KEY_STORE }} | base64 --decode > key.jks - fi - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "17" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - with: - gradle-home-cache-cleanup: true - - - name: Build Release - run: | - echo 'org.gradle.caching=true' >> ~/.gradle/gradle.properties - echo 'org.gradle.parallel=true' >> ~/.gradle/gradle.properties - echo 'org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC' >> ~/.gradle/gradle.properties - - ./gradlew :app:assembleRelease - echo "APK_PATH=$(find app/build/outputs/apk/release -name '*.apk')" >> $GITHUB_ENV - - - name: Set NAME_SUFFIX - run: | - if [ "${{ github.ref_type }}" == "tag" ]; then - echo "NAME_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV - else - echo "NAME_SUFFIX=${{ github.event.head_commit.id }}" >> $GITHUB_ENV - fi - - - name: Upload Artifacts - uses: actions/upload-artifact@v4 - with: - path: ${{ env.APK_PATH }} - name: MixFlipMod_${{ env.NAME_SUFFIX }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8ae16c2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,106 @@ +name: Build and publish release + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: mixflipmod-release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out source and complete tag history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Test publishing rules + run: python3 -B -m unittest discover -s .github/scripts/tests -v + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + with: + # Java 17 compatible; supports SDK 37.0 package names. + cmdline-tools-version: '15641748' + packages: 'platform-tools' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Read resolved app version and SDK requirements + run: | + chmod +x gradlew + ./gradlew --no-daemon --no-configuration-cache :app:writeReleaseMetadata + + - name: Select release channel and previous version + id: plan + env: + GH_TOKEN: ${{ github.token }} + run: python3 -B .github/scripts/release.py prepare + + - name: Install required Android packages + if: steps.plan.outputs.publish == 'true' + env: + SDK_PLATFORM_PACKAGE: ${{ steps.plan.outputs.sdk_platform_package }} + BUILD_TOOLS: ${{ steps.plan.outputs.build_tools }} + run: sdkmanager "$SDK_PLATFORM_PACKAGE" "build-tools;$BUILD_TOOLS" + + - name: Check and build signed release + if: steps.plan.outputs.publish == 'true' + env: + MIXFLIPMOD_KEYSTORE_BASE64: ${{ secrets.KEY_STORE }} + ORG_GRADLE_PROJECT_androidStoreFile: ${{ runner.temp }}/mixflipmod-release.jks + ORG_GRADLE_PROJECT_androidStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} + ORG_GRADLE_PROJECT_androidKeyAlias: ${{ secrets.ALIAS }} + ORG_GRADLE_PROJECT_androidKeyPassword: ${{ secrets.KEY_PASSWORD || secrets.KEY_STORE_PASSWORD }} + run: | + python3 -B .github/scripts/release.py signing + unset MIXFLIPMOD_KEYSTORE_BASE64 + ./gradlew --no-daemon --no-configuration-cache :app:lintRelease :app:assembleRelease + + - name: Verify APK and prepare release files + if: steps.plan.outputs.publish == 'true' + env: + BUILD_TOOLS: ${{ steps.plan.outputs.build_tools }} + run: | + for apk in app/build/outputs/apk/release/*.apk; do + "$ANDROID_HOME/build-tools/$BUILD_TOOLS/apksigner" verify --verbose "$apk" + done + python3 -B .github/scripts/release.py package + + - name: Save build artifacts + if: steps.plan.outputs.publish == 'true' + uses: actions/upload-artifact@v4 + with: + name: MixFlipMod-${{ steps.plan.outputs.version }} + if-no-files-found: error + retention-days: 14 + path: | + build/github-release/dist/* + build/github-release/notes.md + + - name: Upload assets and publish + if: steps.plan.outputs.publish == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: python3 -B .github/scripts/release.py publish + + - name: Remove temporary signing key + if: always() + run: rm -f -- "$RUNNER_TEMP/mixflipmod-release.jks" diff --git a/README.md b/README.md index ed94921..7cbdc4e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,19 @@ 主界面的应用信息在后台加载并复用,列表入口独立懒加载,图标使用有容量上限的内存缓存。仅保留实际使用的三个 Material 扩展图标源码,避免未启用 R8 时打包完整扩展图标库。 +## 自动构建与发布 + +`.github/workflows/release.yml` 沿用 MusicEnhance 的发布规则:推送到 `main`、推送 `v*` 标签或手动运行 Actions 时,根据 `app/build.gradle.kts` 的版本构建并上传 GitHub Release。标签须匹配应用版本,例如 `3.1.0` 对应 `v3.1.0`。 + +- 数字版本发布为正式 Release;带 `-beta` 后缀的版本发布为 Pre-release。 +- 从上一个同类型 Release 汇总提交,生成更新日志。 +- 上传原始命名的 Release APK 和 `.apk.sha256` 文件,Actions 产物保留 14 天。 +- 已公开的版本不覆盖;发布新版本需同时递增 `versionName` 和 `versionCode`。上传失败保留草稿,允许重试。 + +Actions Secrets 继续使用 `KEY_STORE`(JKS 文件的 Base64)、`KEY_STORE_PASSWORD` 和 `ALIAS`;可选的 `KEY_PASSWORD` 未设置时使用密钥库密码。自动发布必须配置签名,不会回退到调试签名;发布使用当前仓库的 `GITHUB_TOKEN`。 + +检查发布规则:`python -B -m unittest discover -s .github/scripts/tests -v`。构建前可执行 `./gradlew --no-configuration-cache :app:writeReleaseMetadata :app:lintRelease :app:assembleRelease`(Windows 使用 `gradlew.bat`)。 + ## 功能 隐藏外屏状态: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6f28e2e..0221e3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,9 +14,6 @@ apksign { keyPasswordProperty = "androidKeyPassword" } -val repo = jgit.repo() -val commitCount = repo?.commitCount("refs/remotes/origin/main") ?: 1 -val latestTag = repo?.latestTag?.removePrefix("v") ?: "3.0" android { namespace = "vip.chener.mixflipmod" @@ -26,15 +23,15 @@ android { applicationId = "vip.chener.mixflipmod" minSdk = 35 targetSdk = 36 - versionCode = commitCount - versionName = latestTag - + versionCode = 30100 + versionName = "3.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -43,6 +40,7 @@ android { create("unminified") { initWith(getByName("release")) isMinifyEnabled = false + isShrinkResources = false isDebuggable = false signingConfig = signingConfigs.getByName("debug") matchingFallbacks += "release" @@ -58,6 +56,28 @@ kotlin { jvmToolchain(17) } +// CI reads resolved values so the workflow follows app versions and SDK changes. +val releaseMetadata = mapOf( + "versionName" to requireNotNull(android.defaultConfig.versionName), + "versionCode" to requireNotNull(android.defaultConfig.versionCode), + "compileSdk" to requireNotNull(android.compileSdk), + "compileSdkMinor" to (android.compileSdkMinor ?: 0), + "buildToolsVersion" to android.buildToolsVersion, +) +val releaseMetadataFile = layout.buildDirectory.file("release-metadata.json") +tasks.register("writeReleaseMetadata") { + group = "publishing" + description = "Exports the app version and SDK requirements for GitHub Releases." + inputs.properties(releaseMetadata) + outputs.file(releaseMetadataFile) + doLast { + releaseMetadataFile.get().asFile.apply { + parentFile.mkdirs() + writeText(groovy.json.JsonOutput.toJson(releaseMetadata)) + } + } +} + androidComponents { onVariants { variant -> variant.outputs.forEach { output ->