作者: z7ui

  • 幕布 Curtain:把屏幕调暗到只剩一层黑,设备照常运行

    模仿三星「幕布」和游戏助推器的触控保护:用一层可调透明度的黑盖住屏幕,适合 OLED 省电、挂机,以及防止游戏更新时锁屏。

    项目地址:https://github.com/z7ui/Curtain

    为什么要做这个东西

    三星 One UI 里有个不太起眼但很好用的功能:幕布。一按,整个屏幕变黑,只留时间和电量,手机照常在后台跑。挂机、下载、更新游戏的时候特别好用 —— 屏幕不亮,OLED 省电,也不会因为长时间不碰而锁屏。

    问题是这个功能只在三星上有,而且依赖系统签名权限,别的机器拿不到。

    所以有了这个项目。它不追求复刻三星的全部细节,核心就一件事:盖一层够黑、但不影响后台运行的幕布。

    三种模式,按需选

    实现上最大的分歧是「状态栏要不要一起盖住」。这件事没有免费午餐,我做了三条路,主界面里单选框直接选:

    模式 盖住状态栏 打扰后台应用 需要无障碍
    普通悬浮窗 · 抢焦点(默认) ✅ ⚠️ 后台应用会失去窗口焦点 不需要
    普通悬浮窗 · 不抢焦点 ❌ ✅ 不需要
    无障碍覆盖层 ✅ ✅ 需要开一次

    默认那条走的是前台服务 + TYPE_APPLICATION_OVERLAY,不带 FLAG_NOT_FOCUSABLE。选无障碍但系统里没开的话会自动降级成抢焦点模式,主界面底部那行显示的是实际生效的模式,不是你想用的那个。

    幕布运行中也能切模式,会自动重建窗口,不用先关再开。

    一个被推翻的结论

    这里值得单独说,因为上一版的文档写错了,而且错得理直气壮。

    我上一版断言:普通悬浮窗一定盖不住状态栏,系统会忽略它的 setSystemUiVisibility。这个结论是照抄另一个项目(FakeStandby)的参数得出的 —— 而那个项目用的是无障碍覆盖层,天生被系统信任,带不带 FLAG_NOT_FOCUSABLE 都行。照搬到普通悬浮窗上,前提就不成立了。

    这次重新拆了 base.apk。Black Screen(io.japp.blackscreen v2.0.7)没有任何无障碍服务,权限只有 SYSTEM_ALERT_WINDOW 加前台服务那一套,没有 root,没有系统签名。它的全屏黑层就是普通悬浮窗:

    layoutParams2.type    = SDK_INT >= 26 ? 2038 : 2002;  // TYPE_APPLICATION_OVERLAY
    layoutParams2.gravity = 17;                           // CENTER
    layoutParams2.format  = 1;                            // RGBA_8888,不透明
    layoutParams2.flags   = 16777984;                     // LAYOUT_IN_SCREEN | LAYOUT_NO_LIMITS
                                                          // 注意:没有 FLAG_NOT_FOCUSABLE
    

    它唯一「藏状态栏」的动作,是给这个视图设了一句:

    o21.setSystemUiVisibility(4102);
    // 4102 = IMMERSIVE_STICKY(4096) | HIDE_NAVIGATION(2) | FULLSCREEN(4)
    

    所以真正的机制是这样:悬浮窗层级低于状态栏,这没错,我们确实画不到它上面。但状态栏可以被藏掉,这是沉浸模式,任何窗口都能请求。系统采纳谁的请求,看的是这个窗口有没有资格代表屏幕 —— 而资格来自三个条件:全屏(MATCH_PARENT + LAYOUT_NO_LIMITS)、不透明(RGBA_8888,不是 TRANSLUCENT)、能拿到焦点(不带 FLAG_NOT_FOCUSABLE)。

    我们原来卡在第三条上。这一版只改了这一条:普通悬浮窗不再带 FLAG_NOT_FOCUSABLE,无障碍那条保持原样。代价是幕布期间后台 app 会失去窗口焦点,个别视频和游戏会因此自动暂停。

    顺带一提,上一版用的 root 方案(su -c "settings put global policy_control immersive.full=*")实测同样盖不住状态栏,整条 root 路径已经全部删掉了。方案对比我放在 README 里,七种方案都试过并标注了实测结果。

    修掉的几个问题

    这一版主要修 bug,其中两个挺有意思:

    幕布开启时改等级没反应。 原因是我上一版重写 CurtainOverlayView 时把 SharedPreferences 监听器删了。这里有个容易踩的点:幕布是悬浮窗,不是 Activity,它盖住屏幕时主界面仍处于 resumed 状态,改偏好不会触发任何回调。所以必须显式注册 OnSharedPreferenceChangeListener,同进程主线程调用下 apply() 会同步回调,幕布上的按钮和主界面的滑杆才能两边都实时生效。

    点「停止」反而启动。 同一个根因。用幕布上的手势关掉之后,主界面不会走 onResume,按钮文字还停在「停止幕布」;但点击时读的是 isRunning(已经是 false),于是执行了启动。修法是主界面也监听 running 键,让按钮的显示和执行来自同一个来源。

    还有一个纯粹是我自己引入的:为了让幕布盖住圆角和挖孔,视图画得比屏幕各大 48dp,导致布局里的 paddingTop="40dp" 换算到屏幕坐标其实是 -8dp,内容跑到屏幕外面去了。第一版只加了上下方向,结果时间被推到左边、电量 pill 跑到右边 —— 同样的 bug 换了个方向。现在四个方向都加回来了,可见留白是顶部 64dp、底部 56dp、左右 40dp,数值都在 dimens.xml 里,觉得不舒服直接改。

    滑动关闭的手感也调了:解锁线从屏幕高度的 28% 降到 25%,图标最多跟到解锁线就停住 —— 停住那一刻就是「够了」,不会再诱导你继续拖。顺带修掉了「必须刚好按在圆钮上」的问题,现在整块屏幕都能起手。

    界面

    主界面是 Material 3 卡片式,权限、模式、显示三块加一个启动按钮。幕布本体上有时间、日期、电量,中央一个关闭钮,底部两个 pill 分别是亮度级别和自动纯黑倒计时,点击循环切换,和主界面实时同步。

    有「使用情况访问」权限的话,中央会显示当前前台应用的图标。

    装起来用

    Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
    & .android-envbuild-apk.ps1 .curtain -Task assembleRelease
    

    第一次使用:授权悬浮窗 → 选模式 → 想要无障碍覆盖层就去开一下(不开也能用,自动降级)→ 授权通知(Android 13+)→ 点启动,看底部那行确认实际生效的模式。

    最新版本 V1.6,在 Releases 里下。

    作者:Deepseek · B站 @z7ui

  • PIN 滑动输入:让 HyperOS 的锁屏支持 Flyme 那种「划过即输入」

    Flyme 的锁屏数字密码可以按住不放、划过即输入。HyperOS 没有这个能力,而且它不是配置项 —— 只能挂代码。

    项目地址:https://github.com/z7ui/PinSwipe | 三星版:https://github.com/z7ui/PinSwipeForGalaxy

    一句话说清它做什么

    锁屏输数字密码时,手指按在第一位数字上不抬起来,一路划过去,按划过的顺序依次输入。就是 Flyme 那个「滑动输入密码」。

    不需要改系统 APK:一个普通 LSPosed 模块,作用域只有 SystemUI。小米 17 Pro / HyperOS 4(Android 17 / SDK 37)实测可用。

    实现思路

    反编译 /system_ext/priv-app/MiuiSystemUI/MiuiSystemUI.apk(OS4.0.0.41.XBLCNXM)后,键盘结构很清晰:

    com.android.keyguard.KeyguardPINView            ← 根,extends KeyguardPinBasedInputView
    ├─ AlphaOptimizedRelativeLayout
    │  ├─ PasswordTextViewForPIN    id 0x7f0b0912(那排圆点)
    │  └─ NumPadButton              id 0x7f0b05d0(确定键)
    ├─ 4 × LinearLayout
    │  └─ NumPadKey × 10            id 0x7f0b05c6..0x7f0b05cf
    └─ FrameLayout                 id 0x7f0b0369(⌫/返回)
    

    挂点选在 com.android.keyguard.NumPadKey#onTouchEvent(MotionEvent)。原因是:数字键一旦收到 ACTION_DOWN 就成为这次手势的 touch target,之后每一个 MOVE 都会送到它手里,哪怕手指早就跑到别的键上去了。从这里旁观能拿到整段手势的完整轨迹,而且不改参数、不吞事件。

    输入动作直接调按键自己的 performClick()。这不是我发明的 —— ROM 自己处理外接键盘时就是这么干的(KeyguardPinBasedInputView#onKeyDown):

    if (i >= 7 && i <= 16) { buttons[i - 7].performClick(); return true; }
    

    点击最终落到 NumPadKey$mListener$1#onClick:把数字 append() 到 PasswordTextViewForPIN,并调一次 PowerManager.userActivity()。所以不碰任何私有输入逻辑,也不绕过 isEnabled() 检查。

    为什么不会把「点按」搞坏

    这是整个模块最关键的设计。原生 View.onTouchEvent 的规则是:按下后只要手指没离开该键的 touch slop,抬手时才会 performClick();一旦离开 slop,按下状态就被取消,抬手什么都不会输入。

    也就是说「划过去」这个动作,系统那一路是一声不吭的 —— 正好是我们要补的。补的规则只有四条:

    时刻 动作
    手指离开起点键的 slop 补上起点键(这时系统那一路已彻底失效,补了不会重复)
    手指进入一个新的键 补上一个键;新键挂起
    抬手 如果系统还会自己点起点键(手指没离开过 slop),什么都不做,交给系统
    抬手 否则补上最后挂起的键

    于是单点一下仍然只输入一位,划过 1→2→3 会依次输入 1、2、3。实测:三次独立点按 = 正好 3 位,不翻倍。

    快速划动时相邻两次事件可能跨过好几个键,所以坐标之间做了插值采样,避免漏键。

    长按 ⌫ 清空:手指停在 ⌫ 上超过系统长按时间(默认 500ms),会另外回一次 onKeyHeld,并且这一下不再补短按。ROM 自己在 KeyguardPinBasedInputViewController#onViewAttached 里就绑了短按删一位、长按 resetPasswordText(true, true) 清空,模块这边只是调它自己的 performLongClick(),语义完全一致。

    两个从真机数据里抠出来的坑

    一、斜着划会多输数字

    真机(Xiaomi 17 Pro / HyperOS 4)量出来的键盘几何:

    键 321 x 192
    横向 128 / 449 / 770  —— 正好等于键宽,横向完全无缝
    纵向 941 / 1150 / 1359 —— 行间只有 17px
    

    横向无缝,所以斜划时手指只要有一点弧度就会蹭进隔壁键 —— 而人划不出直线。拿真机坐标做模拟(弧高 5%):

    路径 整键判定(旧) 中央 56%(现在)
    1→5 145 / 415 15
    5→9 589 59
    7→5 785 75
    一笔 1-5-9-7-5-3 14589878563 1598753

    所以命中判定只认键中央 56% 那一块:真的「划进去了」才算,蹭边角不算。起点键仍然用整个键判定 —— 人可能按在数字边上,那一按系统会认。

    二、同一个键被上报两次

    手指停在中央那块的边界上会来回穿越,同一个键就会被上报两次。真机日志里抓到过间隔 5ms 的重复上报,人不可能是这个速度:

    手势  1  4(+75ms)  5(+149ms)  5(+5ms)       ← 多了一个 5
    

    规则是:连续两次上报同一个键就吞掉。中间隔了别的键(真的划回来)照报,所以 1→5→1 依然是 151。

    顺带一个诚实的说明:有些密码必须分两笔

    159753 这种图案,9→7 是横穿,必然经过 8。而 5 在 1→5→9 里同样是「一条直线穿过的中间那个」—— 两种情况几何上完全一样,任何算法都分不出来,Flyme 也一样,它没有读心术。

    所以实际是两笔:

    1 → 5 → 9      (一条笔直的斜线)
    抬手
    7 → 5 → 3      (另一条平行的斜线)
    

    实测日志,两笔合起来正好 159753,而且比一笔更顺手:

    23:33:39   1  5(+75ms)  9(+243ms)   [抬手 705ms]   7  5(+33ms)  3(+239ms)   -> 159753
    

    想「一笔划完、中间穿过的不算」也能做 —— 但那会让直线划 1→2→3 只出 1、3,两者互斥,只能选一个。

    装起来,先验证再上锁屏

    1. 装 APK(普通安装即可,它同时是个可打开的演示页)
    2. LSPosed 里启用 PIN 滑动输入,作用域勾 SystemUI(APK 里已用 xposedscope 声明)
    3. 重启 SystemUI 才会注入:su -c 'killall com.android.systemui'

    先在演示页试手感。 APK 里带一个和锁屏同样布局的假键盘,用的是同一个 SwipePadController,挂点也一样。在这个页面上试:按住一位数字别松手一路划过去;再单点几下确认不会翻倍。手感对了再去锁屏。

    怎么确认生效:模块只在 hook / 接管 / 出错时写 LSPosed 日志:

    su -c 'grep PinSwipe /data/adb/lspd/log/modules_*.log | tail -20'
    
    PinSwipe: hooked com.android.keyguard.NumPadKey#onTouchEvent
    PinSwipe: 接管 PIN 键盘,数字键 10 个,附加键 1 个
    

    逐位日志默认关闭(LOG_TRIGGERS = false),要核对手感就改成 true 重新装。

    出事了怎么退

    模块只做加法、绝不吞事件,最坏情况是「滑动没反应」或「多输一位」,不会让你点不动。

    • KernelSU Next 的 safe mode(开机时按住音量减)
    • 进 recovery 删掉 /data/adb/modules/zygisk_lsposed
    • 或者电脑上 adb shell su -c 'killall com.android.systemui' 后在 LSPosed 里关掉模块

    ⚠️ 输错密码会计入失败次数,测试时别拿一整排数字去划,划两位就够了。

    没做的

    • 只覆盖 PIN(数字密码)。图形密码 / 混合密码没做 —— 那两种的「划过输入」语义不清晰。
    • 停留在 ⌫ 上清空一次,不会连续重复清空。
    • 一笔划完「中间穿过别的数字」的图案,做不到,那是几何问题不是 bug。
    • 长按数字键的原有行为不受影响(只补 MOVE,不碰 DOWN/UP)。

    最新版本 V1.2(演示页加了「清除模块日志」和「关于」),在 Releases 里下。

    作者:Deepseek · B站 @z7ui

  • PIN 滑动输入 for Galaxy:三星 One UI 也支持按住不松手、划过即输入

    三星 One UI 的锁屏数字密码,支持按住不松手、划过即输入,长按 ⌫ 清空。

    项目地址:https://github.com/z7ui/PinSwipeForGalaxy | HyperOS 版:https://github.com/z7ui/PinSwipe

    实测机型:Galaxy S25 Ultra(SM-S9380)· One UI 8.5 · Android 16(SDK 36)

    先说清楚:这是独立的一个软件。跟 HyperOS 那个版本是两个项目、两个包名、分开发布。两边都 hook SystemUI,所以同一台机器上只能装一个,否则一次划动会输入两遍。

    它靠什么工作

    普通 LSPosed 模块,作用域只有 SystemUI。不写系统分区、不需要重启 zygote。

    挂点是拿真机的 SystemUI.apk 反编译出来的:

    com.android.keyguard.NumPadKey#onTouchEvent(MotionEvent)   ← 数字键
    com.android.keyguard.SecNumPadKey extends NumPadKey         ← 三星的键,没覆写 onTouchEvent
    

    为什么挂这里:数字键一旦收到 ACTION_DOWN 就成了这次手势的 touch target,之后每一个 MOVE 都会送到它手里(哪怕手指早跑到别的键上)。从这里旁观能拿到整段手势的完整轨迹,而且不改参数、不吞事件,原生逻辑一点不受影响。

    输入动作用的是 ROM 自己的 performClick() —— 和它处理外接键盘时走的是同一条路(KeyguardPinBasedInputView#onKeyDown → performNumberClick() → mButtons[i].performClick())。

    三星这边和 HyperOS 的差异值得列一张表:

    项 三星上是什么
    数字键类 SecNumPadKey extends NumPadKey(未覆写 onTouchEvent)
    键数组字段 mButtons(没有 getButtons(),HyperOS 才有)
    ⌫ 字段 mDeleteButton(NumPadButton,可见可点)
    ⌫ 短按 ROM 绑定为删一位
    ⌫ 长按 ROM 绑定为清空(resetPasswordText(true, true))
    图片 PIN 用的是按键的 mDigit,所以图片 PIN 模式下一样能划

    注意三星的 ⌫ 在左下角,HyperOS 在右下角 —— 别按错了。

    两个从真机数据里抠出来的规则

    一、数字键只认中央 56%,但 ⌫ 用整个键

    真机量出来的三星键盘几何:

    键尺寸 240 x 240
    1(365,1111)  2(720,1111)  3(1075,1111)
    4(365,1413)  5(720,1413)  6(1075,1413)
    7(365,1715)  8(720,1715)  9(1075,1715)
    ⌫(365,2017)              0(720,2017)
    

    斜划时手指只要有一点弧度就会蹭到隔壁键,所以数字键的判定缩到中间那一块。但 ⌫ 是「按」的目标,不是「划过」的目标,缩到中央会让手指够不着 —— 这条踩过坑:缩到 56% 之后 ⌫ 只剩 134×134,短按和长按都摸不到。现在 ⌫ 用整个键判定。

    二、相邻两位不会重复上报

    手指停在中央区域的边界上会来回穿越,同一个键会被上报两次(HyperOS 上抓到过间隔 5ms 的重复,人不可能是这个速度)。规则:连续两次上报同一个键就吞掉;中间隔了别的键(真的划回来)照报,所以 1→5→1 依然是 151。

    有些密码必须分两笔

    如果两个要输的数字之间的直线上正好压着另一个数字,那一段就不能一笔划 —— 手指确实从那个数字上过去了,任何算法都分不出你是想输它还是路过它。

    1→2→3   一笔可以(中间那个 2 就是你想要的)
    1→5→9   一笔可以
    9→7     一笔会多出一个 8(8 在 9 和 7 中间)→ 在这里抬一下手指
    

    装起来,先验证再上锁屏

    1. 装 APK
    2. LSPosed 里启用「PIN 滑动输入 for Galaxy」,作用域勾 SystemUI(APK 里已用 xposedscope 声明,通常会自动勾上)
    3. 重启 SystemUI 让它注入,不用重启手机:su -c 'killall com.android.systemui'

    先在演示页试手感。 桌面图标「滑动输入 · 演示」是个和锁屏同样布局的假键盘,用的是同一个 SwipePadController,挂点也一样。先在这里试:按住一位数字不松手划过去、单点确认不会翻倍、按住 ⌫ 清空。手感对了再去真锁屏。

    怎么确认生效:模块只在 hook / 接管 / 出错时写 LSPosed 日志(逐位日志默认关闭,把 LOG_TRIGGERS 改成 true 重新装就会每一位都留一条):

    su -c 'grep PinSwipe /data/adb/lspd/log/modules_*.log | tail -20'
    

    正常的开头是:

    PinSwipe: hooked com.android.keyguard.NumPadKey#onTouchEvent
    PinSwipe: 接管 PIN 键盘,数字键 10 个,附加键 1 个
    

    出事了怎么退

    模块只做加法、绝不吞事件,最坏情况是「滑动没反应」或「多输一位」,不会让你点不动。

    • 进 LSPosed 关掉这个模块,再 su -c 'killall com.android.systemui'
    • 或者直接卸载这个 APK,它是普通应用,跟系统分区没关系
    • 输错密码会计入失败次数,测试时别拿一整排数字去划

    没做的

    • 只覆盖 PIN(数字密码)。图形密码 / 混合密码没做。
    • 滑过 ⌫ 只删一位;按住不放才清空(不会连续重复清空)。
    • 「直线穿过中间数字」的图案做不到一笔,那是几何问题不是 bug。

    最新版本 V1.1(演示页加了「清除模块日志」和「关于」),在 Releases 里下。

    作者:Deepseek · B站 @z7ui