diff --git a/.github/workflows/fork-apk-build.yml b/.github/workflows/fork-apk-build.yml new file mode 100644 index 000000000..adc8e1d7b --- /dev/null +++ b/.github/workflows/fork-apk-build.yml @@ -0,0 +1,110 @@ +name: Fork APK Build + +on: + push: + branches: + - main + - 'feature/**' + pull_request: + workflow_dispatch: + inputs: + publish_release: + description: Publish the signed APK as a GitHub Release + required: false + type: boolean + default: false + release_tag: + description: Release tag used when publish_release is enabled + required: false + type: string + default: lbe-clipboard-toast-v1 + +permissions: + contents: write + +concurrency: + group: fork-apk-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build installable APK + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Set up Java 21 + uses: actions/setup-java@v4.3.0 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4.1.0 + with: + gradle-version: wrapper + + - name: Prepare fork signing key + shell: bash + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + run: | + if [[ -n "$KEYSTORE_BASE64" ]]; then + printf '%s' "$KEYSTORE_BASE64" | base64 -d > android/app/fork-release.jks + fi + + - name: Build release-fast APK + env: + KEYSTORE_PATH: fork-release.jks + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + chmod +x android/gradlew + bash android/gradlew -p android :app:assembleReleaseFast \ + -PbuildTime="$(date -u '+%Y-%m-%d %H:%M')" + + - name: Stage APK + shell: bash + run: | + APK_PATH="$(find build/app/outputs/apk -type f -name '*.apk' | head -n 1)" + test -n "$APK_PATH" + mkdir -p artifacts + cp "$APK_PATH" artifacts/HyperIsland-lbe-clipboard-toast.apk + sha256sum artifacts/HyperIsland-lbe-clipboard-toast.apk \ + > artifacts/HyperIsland-lbe-clipboard-toast.apk.sha256 + + - name: Upload APK artifact + uses: actions/upload-artifact@v4.6.0 + with: + name: HyperIsland-lbe-clipboard-toast-${{ github.run_number }} + path: artifacts/* + if-no-files-found: error + retention-days: 30 + compression-level: 0 + + - name: Publish signed fork release + if: github.event_name == 'workflow_dispatch' && inputs.publish_release + uses: softprops/action-gh-release@v2.1.0 + with: + tag_name: ${{ inputs.release_tag }} + target_commitish: ${{ github.sha }} + name: HyperIsland LBE Clipboard Toast v1 + body: | + Custom HyperIsland build based on upstream main. + + Adds HyperOS clipboard-read prompt interception for + `com.lbe.security.utility.ToastUtil.initToastView(String, int)` when `type == 1`, + forwards it through HyperIsland's existing Toast/IslandDispatcher path, and + suppresses the original touch-blocking LBE view. + + Enable **Settings → Hook Extensions → Xiaomi clipboard prompt island** and + restart both System UI and Permission Manager scopes. + files: | + artifacts/HyperIsland-lbe-clipboard-toast.apk + artifacts/HyperIsland-lbe-clipboard-toast.apk.sha256 + draft: false + prerelease: true diff --git a/android/app/src/main/kotlin/io/github/hyperisland/XposedPrefsSyncApp.kt b/android/app/src/main/kotlin/io/github/hyperisland/XposedPrefsSyncApp.kt index b6a5acafe..cf4ccd22a 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/XposedPrefsSyncApp.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/XposedPrefsSyncApp.kt @@ -326,6 +326,7 @@ class XposedPrefsSyncApp : Application(), XposedServiceHelper.OnServiceListener "pref_settings_home_entry", "pref_settings_home_entry_position", "pref_settings_home_entry_icon_style", + "pref_lbe_clipboard_toast_island", "pref_bluetooth_island", "pref_bluetooth_island_show_device_name", "pref_bluetooth_island_display_duration_seconds", diff --git a/android/app/src/main/kotlin/io/github/hyperisland/compose/page/settings/extensions/HookExtensionConfig.kt b/android/app/src/main/kotlin/io/github/hyperisland/compose/page/settings/extensions/HookExtensionConfig.kt index 4c3bb250b..5043b7c0b 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/compose/page/settings/extensions/HookExtensionConfig.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/compose/page/settings/extensions/HookExtensionConfig.kt @@ -7,6 +7,7 @@ internal const val KEY_SCREEN_RECORDER_ICON_STYLE = "pref_screen_recorder_icon_s internal const val KEY_SETTINGS_HOME_ENTRY = "pref_settings_home_entry" internal const val KEY_SETTINGS_HOME_ENTRY_POSITION = "pref_settings_home_entry_position" internal const val KEY_SETTINGS_HOME_ENTRY_ICON_STYLE = "pref_settings_home_entry_icon_style" +internal const val KEY_LBE_CLIPBOARD_TOAST_ISLAND = "pref_lbe_clipboard_toast_island" internal const val KEY_BLUETOOTH_ISLAND = "pref_bluetooth_island" internal const val KEY_BLUETOOTH_SHOW_DEVICE_NAME = "pref_bluetooth_island_show_device_name" internal const val KEY_BLUETOOTH_DURATION = "pref_bluetooth_island_display_duration_seconds" diff --git a/android/app/src/main/kotlin/io/github/hyperisland/compose/page/settings/extensions/HookExtensionPage.kt b/android/app/src/main/kotlin/io/github/hyperisland/compose/page/settings/extensions/HookExtensionPage.kt index 5d1c15e8e..918162c92 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/compose/page/settings/extensions/HookExtensionPage.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/compose/page/settings/extensions/HookExtensionPage.kt @@ -57,6 +57,7 @@ internal fun HookExtensionPage( SETTINGS_POSITION_TOP, ) val settingsIcon = rememberStringPreference(prefs, KEY_SETTINGS_HOME_ENTRY_ICON_STYLE, MODE_DEFAULT) + val lbeClipboardToast = rememberBooleanPreference(prefs, KEY_LBE_CLIPBOARD_TOAST_ISLAND, false) val smooth = rememberBooleanPreference(prefs, KEY_SMOOTH_ISLAND, false) val smoothingState = remember(KEY_SMOOTHING) { mutableFloatStateOf(prefs.getDouble(KEY_SMOOTHING, DEFAULT_SMOOTHING).toFloat()) } val unlockAll = rememberBooleanPreference(prefs, KEY_UNLOCK_ALL_FOCUS, false) @@ -166,6 +167,22 @@ internal fun HookExtensionPage( item { SectionTitle(stringResource(R.string.ext_system_ui)) Card(modifier = Modifier.fillMaxWidth()) { + PreferenceSwitch( + title = stringResource(R.string.ext_lbe_clipboard_toast), + summary = stringResource(R.string.ext_lbe_clipboard_toast_summary), + icon = null, + checked = lbeClipboardToast.value, + ) { value -> + if (request( + value, + listOf("com.android.systemui", "com.lbe.security.miui"), + ) + ) { + lbeClipboardToast.value = value + prefs.putBoolean(KEY_LBE_CLIPBOARD_TOAST_ISLAND, value) + restart() + } + } PreferenceSwitch( title = stringResource(R.string.smooth_island), summary = stringResource(R.string.smooth_island_summary), diff --git a/android/app/src/main/kotlin/io/github/hyperisland/xposed/ConfigManager.kt b/android/app/src/main/kotlin/io/github/hyperisland/xposed/ConfigManager.kt index f44ee551b..9ffa9a765 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/xposed/ConfigManager.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/xposed/ConfigManager.kt @@ -357,6 +357,7 @@ object ConfigManager { "pref_settings_home_entry", "pref_settings_home_entry_position", "pref_settings_home_entry_icon_style", + "pref_lbe_clipboard_toast_island", "pref_bluetooth_island", "pref_bluetooth_island_show_device_name", "pref_bluetooth_island_display_duration_seconds", diff --git a/android/app/src/main/kotlin/io/github/hyperisland/xposed/HyperIslandModule.kt b/android/app/src/main/kotlin/io/github/hyperisland/xposed/HyperIslandModule.kt index ba3cd339d..b24f655c7 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/xposed/HyperIslandModule.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/xposed/HyperIslandModule.kt @@ -36,6 +36,7 @@ import io.github.hyperisland.xposed.hook.TimerTextColorHook import io.github.hyperisland.xposed.hook.StatusBarTextColorHook import io.github.hyperisland.xposed.hook.ScreenRecorder.ScreenRecorderHook import io.github.hyperisland.xposed.hook.ToastUiInterceptHook +import io.github.hyperisland.xposed.hook.LbeClipboardToastHook import io.github.hyperisland.xposed.hook.SystemUI.extensions.UnlockAllFocusHook import io.github.hyperisland.xposed.hook.UnlockFocusAuthHook import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam @@ -122,6 +123,11 @@ class HyperIslandModule : XposedModule() { ScreenRecorderHook.init(this, param) } + "com.lbe.security.miui" -> + if (ConfigManager.getBoolean("pref_lbe_clipboard_toast_island", false)) { + LbeClipboardToastHook.init(this, param) + } + } } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/xposed/hook/LbeClipboardToastHook.kt b/android/app/src/main/kotlin/io/github/hyperisland/xposed/hook/LbeClipboardToastHook.kt new file mode 100644 index 000000000..dc5aa1710 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/xposed/hook/LbeClipboardToastHook.kt @@ -0,0 +1,149 @@ +package io.github.hyperisland.xposed.hook + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import io.github.hyperisland.xposed.ConfigManager +import io.github.hyperisland.xposed.utils.HookUtils +import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam + +/** + * Bridges HyperOS' custom clipboard-read prompt into the standard Toast pipeline. + * + * Xiaomi creates this prompt as a touchable custom View in the LBE permission process, + * so SystemUI's normal Toast hook cannot observe it. The bridge emits an ordinary Toast + * with an invisible marker; [ToastUiInterceptHook] consumes that marker in SystemUI, + * forwards the text through the existing IslandDispatcher path, and blocks the bridge Toast. + */ +object LbeClipboardToastHook : BaseHook() { + + private const val TAG = "HyperIsland[LbeClipboardToast]" + private const val TARGET_CLASS = "com.lbe.security.utility.ToastUtil" + private const val TARGET_METHOD = "initToastView" + private const val PREF_ENABLED = "pref_lbe_clipboard_toast_island" + + override fun getTag() = TAG + + override fun onInit(module: XposedModule, param: PackageLoadedParam) { + if (!ConfigManager.getBoolean(PREF_ENABLED, false)) return + + val clazz = try { + param.defaultClassLoader.loadClass(TARGET_CLASS) + } catch (e: Throwable) { + logWarn(module, "$TARGET_CLASS not found: ${e.message}") + return + } + + val methods = clazz.declaredMethods.filter { method -> + method.name == TARGET_METHOD && + method.parameterCount == 2 && + method.parameterTypes[0] == String::class.java && + method.parameterTypes[1] == Int::class.javaPrimitiveType + } + if (methods.isEmpty()) { + logWarn(module, "$TARGET_CLASS#$TARGET_METHOD(String, int) not found") + return + } + + methods.forEach { method -> + try { + module.hook(method).intercept { chain -> + val original = chain.proceed() + val type = chain.args.getOrNull(1) as? Int ?: return@intercept original + if (type != LbeClipboardToastBridge.CLIPBOARD_TYPE) { + return@intercept original + } + + val originalView = original as? View + val context = originalView?.context + ?: HookUtils.getContext(param.defaultClassLoader) + ?: return@intercept original + val text = resolvePromptText( + rawText = chain.args.getOrNull(0) as? String, + root = originalView, + ) + if (text.isBlank()) return@intercept original + + postBridgeToast(context, text, module) + suppressOriginalView(originalView) + createZeroSizedReplacement(context) + } + log(module, "hooked $TARGET_CLASS#$TARGET_METHOD") + } catch (e: Throwable) { + logError(module, "hook $TARGET_METHOD failed: ${e.message}") + } + } + } + + private fun postBridgeToast(context: Context, text: String, module: XposedModule) { + val appContext = context.applicationContext ?: context + Handler(Looper.getMainLooper()).post { + runCatching { + Toast.makeText( + appContext, + LbeClipboardToastBridge.MARKER + text, + Toast.LENGTH_LONG, + ).show() + }.onFailure { error -> + logError(module, "emit bridge Toast failed: ${error.message}") + } + } + } + + private fun resolvePromptText(rawText: String?, root: View?): String { + val closeButtonId = root?.context?.resources + ?.getIdentifier("closeButton", "id", "android") + ?: View.NO_ID + val candidates = buildList { + collectText(root, closeButtonId, this) + rawText?.trim()?.takeIf { it.isNotEmpty() }?.let(::add) + } + return candidates + .distinct() + .maxByOrNull { it.length } + .orEmpty() + } + + private fun collectText(view: View?, closeButtonId: Int, output: MutableList) { + when (view) { + is TextView -> if (view.id != closeButtonId) { + view.text?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.let(output::add) + } + is ViewGroup -> for (index in 0 until view.childCount) { + collectText(view.getChildAt(index), closeButtonId, output) + } + } + } + + private fun suppressOriginalView(view: View?) { + view ?: return + view.alpha = 0f + view.visibility = View.GONE + view.isClickable = false + view.isFocusable = false + view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + + private fun createZeroSizedReplacement(context: Context): View = View(context).apply { + alpha = 0f + visibility = View.GONE + isClickable = false + isFocusable = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + layoutParams = ViewGroup.LayoutParams(0, 0) + } +} + +internal object LbeClipboardToastBridge { + const val LBE_PACKAGE = "com.lbe.security.miui" + const val CLIPBOARD_TYPE = 1 + + // Unicode formatting characters: invisible when the SystemUI scope is missing, + // but stable through Toast's Binder transport and unlikely to collide with app text. + const val MARKER = "\u2060\u2061\u2062\u2063" +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/xposed/hook/SystemUI/ToastUiInterceptHook.kt b/android/app/src/main/kotlin/io/github/hyperisland/xposed/hook/SystemUI/ToastUiInterceptHook.kt index e9bdf0b5b..6116bd96a 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/xposed/hook/SystemUI/ToastUiInterceptHook.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/xposed/hook/SystemUI/ToastUiInterceptHook.kt @@ -134,10 +134,23 @@ object ToastUiInterceptHook : BaseHook() { pkg: String, text: CharSequence?, ): Boolean { - val normalizedText = text?.toString()?.trim().orEmpty() + val rawText = text?.toString().orEmpty() + val isLbeClipboardBridge = pkg == LbeClipboardToastBridge.LBE_PACKAGE && + rawText.startsWith(LbeClipboardToastBridge.MARKER) + val normalizedText = if (isLbeClipboardBridge) { + rawText.removePrefix(LbeClipboardToastBridge.MARKER).trim() + } else { + rawText.trim() + } if (pkg.isBlank() || normalizedText.isEmpty() || pkg == SELF_PKG) return false - val rule = loadRule(pkg) + val rule = loadRule(pkg).let { configured -> + if (isLbeClipboardBridge) { + configured.copy(forwardEnabled = true, blockOriginal = true) + } else { + configured + } + } // 过滤逻辑:拦截和转发共用,不在黑名单/不在白名单的不处理 val hasKeywords = rule.whitelistKeywords.isNotEmpty() || rule.blacklistKeywords.isNotEmpty() diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 063f85210..ac7322758 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -111,6 +111,8 @@ 将此应用的标准 Toast 文本转为 HyperIsland 焦点通知与超级岛代发 拦截原始 Toast 转发后同时拦截此应用原始标准 Toast 弹窗 + 小米剪贴板提示上岛 + 拦截权限管理服务的非标准剪贴板读取提示,隐藏原悬浮 View 并转为超级岛;需同时启用系统界面和权限管理服务作用域 显示为通知 开启后此转发内容会在通知中心保留为可见通知 搜索应用名或包名 diff --git a/android/app/src/main/resources/META-INF/xposed/scope.list b/android/app/src/main/resources/META-INF/xposed/scope.list index 3add7e718..f591e7937 100644 --- a/android/app/src/main/resources/META-INF/xposed/scope.list +++ b/android/app/src/main/resources/META-INF/xposed/scope.list @@ -4,3 +4,4 @@ com.android.providers.downloads com.android.systemui com.xiaomi.xmsf com.miui.screenrecorder +com.lbe.security.miui diff --git a/docs/en/getting-started.md b/docs/en/getting-started.md index 6c6ea0054..174cda7c6 100644 --- a/docs/en/getting-started.md +++ b/docs/en/getting-started.md @@ -25,6 +25,7 @@ - **Download Manager Extension**: check "Download Manager" - **Super Island**: check "System UI" - **Focus Notification Crack**: check "Xiaomi Services Framework" + - **Xiaomi clipboard prompt island**: check "System UI" and "Permission Manager" (`com.lbe.security.miui`) 4. Save and tap the **restart button** in the top-right corner (or restart your phone) ![LSPosed](https://img.shields.io/badge/Framework-LSPosed-blueviolet?style=flat-square) @@ -54,6 +55,7 @@ Download Island is disabled by default. Go to the app and enable **"Show System - **Notification Super Island**: Convert any notification to Focus Notification + Super Island display - **Download**: Auto-detect download status and convert to Focus Notification + Super Island - **AI Notification Super Island**: AI simplifies left and right sides of the Super Island +- **Xiaomi clipboard prompt island**: enable it under **Settings → Hook Extensions** to replace the permission service's custom clipboard-read prompt with a Super Island ### AI Summary Configuration diff --git a/docs/getting-started.md b/docs/getting-started.md index 4d0e18503..8cc6ee1fc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -25,6 +25,7 @@ - **下载管理器拓展**:勾选「下载管理器」 - **超级岛**:勾选「系统界面」 - **焦点通知破解**:「小米服务框架」 + - **小米剪贴板提示上岛**:勾选「系统界面」和「权限管理服务」(`com.lbe.security.miui`) 4. 确认保存后,点击**软件右上角重启按钮**,重启对应作用域内的应用(或直接重启手机)使 Hook 生效 ![LSPosed](https://img.shields.io/badge/Framework-LSPosed-blueviolet?style=flat-square) @@ -54,6 +55,7 @@ - **通知超级岛**:支持任意通知转为焦点通知 + 超级岛显示 - **下载**:自动识别下载状态并转为焦点通知 + 超级岛 - **AI 通知超级岛**:超级岛左右交给 AI 精简 +- **小米剪贴板提示上岛**:在「设置 → Hook 拓展」开启,将权限管理服务的非标准剪贴板读取提示隐藏并转为超级岛 ### AI 总结配置