Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/fork-apk-build.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>) {
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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
<string name="toast_forward_summary">将此应用的标准 Toast 文本转为 HyperIsland 焦点通知与超级岛代发</string>
<string name="toast_block_original">拦截原始 Toast</string>
<string name="toast_block_original_summary">转发后同时拦截此应用原始标准 Toast 弹窗</string>
<string name="ext_lbe_clipboard_toast">小米剪贴板提示上岛</string>
<string name="ext_lbe_clipboard_toast_summary">拦截权限管理服务的非标准剪贴板读取提示,隐藏原悬浮 View 并转为超级岛;需同时启用系统界面和权限管理服务作用域</string>
<string name="toast_show_notification">显示为通知</string>
<string name="toast_show_notification_summary">开启后此转发内容会在通知中心保留为可见通知</string>
<string name="search_apps">搜索应用名或包名</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/resources/META-INF/xposed/scope.list
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ com.android.providers.downloads
com.android.systemui
com.xiaomi.xmsf
com.miui.screenrecorder
com.lbe.security.miui
2 changes: 2 additions & 0 deletions docs/en/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading