LOADING

加载过慢请开启缓存 浏览器默认开启

晨曦拾光

思绪万千

记录技术与生活

从 88 次构建失败到两套可刷包:小米 17 OrangeFox Recovery 移植全记录(含 R12.0 UI 移植)

技术 2026/9/12 0

设备:小米 17(pudding / sm8750 / Android 17 / HyperOS 4.0.26),1220×2656 屏幕,屏下挖孔
目标:移植 OrangeFox Recovery,并让它在不依赖任何 DE 分区副本的前提下解出全部文件名
结果:DE/CE 全层明文、真实 spblob 直读、SELinux 拒绝日志降 87%;随后完成 R12.0 UI 整体移植,产出 R11.3 / R12.0 两套可刷包;CI 真实根因定位并修复
代价:113+ 次构建、20+ 次刷机、40+ 次重启

下载(解压即用,内含 fastboot 镜像)

刷入:fastboot flash recovery_a <img>fastboot flash recovery_b <img>;R11.3 / R12.0 形态不同,不可混用


一、现象:三个”看起来像玄学”的问题

接手时的情况很典型:recovery 能启动、能解密 metadata、能建 dm 设备、用户 PIN 也能被接受(twrp.user.0.decrypt=1),但文件名字全是 AlBUYQAAAAAPK-y2PupsHUKcvt6LIq0d 这样的密文

更麻烦的是三个互相纠缠的现象:

  1. /data/system_de/0(DE 层)文件名密文
  2. /data/media/0/sdcard(CE 层)文件名密文
  3. 解锁后重启,连已经明文的目录都会退回密文

正常 fstab、正常密钥、正常解锁,但名字就是不解密——这类问题最容易被归为”玄学”,而玄学的解法只有一个:把每一个假设都做成可复现的实验,然后逐个杀掉

二、先做减法:逐项排除的九个假设

排查的第一阶段是”逐个证伪”,每一项都有实测证据:

# 假设 验证方式 结果
1 fstab 的 fileencryption 串错误 对比 A16/A17 的 ParseOptions 已修正,非根因
2 inlinecrypt / gc_merge 挂载选项 /proc/mounts 实测 已修正,非根因
3 metadata_encryption= 丢失 dm 设备建立成功 已修正,非根因
4 spblob 取自陈旧副本 自加 [SP] 诊断判定 已修正,非根因
5 CE key 取自副本 descriptor 逐一对比 已改,非根因
6 /data 未挂载时装键 加挂载检查补丁 /data 已挂载,非根因
7 hw_wrapped 未生效 [POL] hw_wrapped=1 诊断 已生效,非根因
8 fs_mgr 覆盖 use_hw_wrapped_key 移植 A17 语义 已修正,非根因
9 fstab 与参考树不一致 逐字采用参考树配置 仍然失败

第 9 项特别有价值:本地有一份同机型参考树(README 声称”data decryption 稳定”),把它的 fstab 逐字照搬后依然全密文——这条实测直接告诉我们:参考树的声明不可信,fstab 不是根因

三、三个真正的根因

根因 1:缺失的编译期开关 —— TW_USE_FSCRYPT_POLICY := 2

转折点来自一份公开可用的同平台设备树YuKongA/twrp_device_xiaomi_sm8750_thales,109★)。它的 BoardConfig 里有三个我们缺的开关:

TW_INCLUDE_CRYPTO_FBE := true
TW_INCLUDE_FBE_METADATA_DECRYPT := true
TW_USE_FSCRYPT_POLICY := 2

其中最关键的是 TW_USE_FSCRYPT_POLICY := 2

原理:设备上所有目录的策略实测都是 v2:

[POL] /data/system_de/0 -> v2 desc=0a1d1454047afe13 contents=1 filenames=4 flags=0xa

fscrypt_policy 结构体在 v1 与 v2 下长度和字段都不同。缺这个开关时编译出的是 v1 结构体,于是所有策略读写都在用错误的格式解释内存——密钥装了、描述符看着也对,但内核按错误结构理解,结果永远对不上。

// system/vold/fscrypt_policy.cpp 里的开关
#ifdef USE_FSCRYPT_POLICY_V1
    return fscrypt_policy_v1_to_bytes(policy);   // ← 我们一直在用这个
#else
    return fscrypt_policy_v2_to_bytes(policy);
#endif

改完之后,DE 层立刻明文

顺带一提:TW_INCLUDE_FBE_METADATA_DECRYPT 缺失会让 partitionmanager.cpp 里整段 metadata 解密逻辑被 #ifdef 掉;BUILD_BROKEN_DUP_RULES 则是为了解决新开关引入的 se_omapi.rc 重复拷贝。

根因 2:内核密钥绑在”挂载实例”上

DE 层通了、/data/misc 也明文了,但 /data/media/0 依旧密文。这次的关键线索来自用户的一句话:

“我记得有一次也是正常解密的,只是需要手动挂载 storage。”

这句话把方向从”密钥错”转到了”时机错“。

顺着查下去,问题在解锁流程的顺序:

解锁: 解出密钥 → 安装到内核 ✓
      → Setup_Data_Media() 会 **卸载 + 重挂 /data** ✗
      → 内核密钥绑定在 superblock 上, 随旧挂载实例一起失效
      → 文件名退回密文

Android 系统与官方 recovery 不踩这个坑,是因为它们在正确时机安装、之后只挂载一次

修复方式很直接:每次重挂之后把密钥重新装回去

[0121] fscrypt_reinit_de_keys_after_remount()   // 重装 DE 密钥
[0125] fscrypt_reinstall_ce_key()              // 重装 CE 密钥 ← /data/media/0 的关键

两者都在 partitionmanager.cpp 的 CE 解锁段落里、Setup_Data_Media() 与重挂之后调用。改完实测:

[0125] CE key saved for post-remount reinstall (user=0 size=263)
[DE] [0121] reinit result: systemwide=1 de_keys=1
[0125] reinstall CE key after remount: OK
/data/media/0 → AIEdgeGallery-中文版-1.0.19-release.apk / Android / DCIM / …  (28 项明文)

根因 3:keystore2 启动竞态(以及我自己埋的雷)

有一次开机 /data 挂不上,日志里是:

[CE] step: Keystore() ctor FAILED (keystore2 不可用)
I:Failed to mount '/data' (Invalid argument)

原因是 metadata 解密跑在了 keystore2 注册之前。我加了等待逻辑 [0126],但第一版写错了

// ❌ 错误版本
for (int i = 0; i < timeout_sec * 5; i++) {
    Keystore probe;        // 构造函数内部有 300×100ms = 30 秒的有界轮询!
    if (probe) return true;
    usleep(200000);
}
// timeout_sec=20 ⇒ 100 次迭代 × 30.2s ≈ 50 分钟/调用点

而且服务名也写错了(android.system.keystore2 并不存在,正确的是 android.system.keystore2.IKeystoreService/default),导致探测永远失败。

正确版本:先用 checkService() 做廉价预判(服务确实存在才构造),再用 steady_clock 做墙钟兜底:

static const char* const kKeystoreService =
        "android.system.keystore2.IKeystoreService/default";
auto start = std::chrono::steady_clock::now();
for (;;) {
    bool present = false;
    sp<IServiceManager> sm = defaultServiceManager();
    if (sm != nullptr)
        present = (sm->checkService(String16(kKeystoreService)) != nullptr);
    if (present) {
        Keystore probe;
        if (probe) return true;
    }
    if (std::chrono::steady_clock::now() - start >=
        std::chrono::seconds(timeout_sec)) break;
    usleep(200000);
}

实测 [0126] keystore ready after 2600 ms —— 也证明这 2.6 秒等待是真实需要的。

四、性能:把 1759 条 SELinux 拒绝降到 225

启动页停留很久,用 dmesg 带时间戳一量,抓到了真凶:

SELinux 拒绝: t=66.40s → t=78.52s  共 12.1 秒, 1759 条
avc: denied { getattr } for comm="recovery"
    path="/data/user_de/0/cn.wps.moffice_eng.xiaomi.lite/cache/..."

原因有两层:

第一层TWExclude::Get_Folder_Size() 统计 /data 已用空间时,先 lstat 再判排除。而被排除的路径每次 lstat 都被 SELinux 拒绝并产生一条 audit 日志。把判定前移即可(check_skip_dirs() 是纯字符串比较,不依赖 stat):

FullPath = Path + "/" + de->d_name;
if (check_skip_dirs(FullPath))   // ← 提前到这里
    continue;
if (lstat(FullPath.c_str(), &st)) { ... }

第二层:更多拒绝来自”访问本来就被禁止的路径”。这里的正确做法不是跳过(跳过会少算真实数据),而是 **dontaudit**——保留拒绝、只免除审计开销:

/* 用基类属性而非逐个枚举类型:
   逐一枚举会撞上 recovery 精简策略集里不存在的类型(实测 checkin_data_file 即如此) */
dontaudit recovery data_file_type:dir  { search getattr read open };
dontaudit recovery data_file_type:file { getattr read open map };
dontaudit recovery property_type:file  { getattr read open map };

踩坑记录:我一度往跳过列表里加了 /data/user/,结果”备份大小”从 154354MB 掉到 64335MB——少算了 85.5GB 真实用户数据。后来查清 /data/user/0 是独立挂载点(/dev/block/dm-18),属于备份主体,绝不能跳。这个错误如果没被发现,会让备份前的空间检查误判。

结果

项目 优化前 优化后
SELinux 拒绝日志 1759 条 225 条 (-87%)
IHealth 服务轮询 68 次 2 次 (-97%)
keystore 等待上限 最坏 50 分钟 10 秒

其中 IHealth 轮询来自 TWRP 的电池后台线程(twrp.cpp:555-613while(true) + sleep(1s)),改用 TW_USE_LEGACY_BATTERY_SERVICES := true 走 sysfs 读取即可。

五、UI:1220×2656 挖孔屏的适配

5.1 非等比缩放的真相

主题是按 1080×1920(16:9)设计的,而屏幕是 1220×2656(约 1:2.18)。日志里给出了精确数值:

I:Scaling theme width 1.129630x and height 1.383333x
        scale_w = 1220/1080 = 1.1296   (横向 +13%)
        scale_h = 2656/1920 = 1.3833   (纵向 +38%)

两边不等 ⇒ 严重变形。翻 pages.cpp:924-950 才看明白坑在哪:

if (resize_attr) {                                    // 只有存在 resizing 属性时
    height = atoi(height_res_attr->value());          // 才用 XML 声明的 height
} else {
    DataManager::GetValue("screen_original_h", height);   // 否则用真实屏幕高度!
}

主题的 <resolution width="1080" height="1920" /> 没有 resizing,于是宽度用声明值、高度用真实屏幕值——两边各算各的。修正后:

<resolution width="1080" height="2351" resizing="1" />
<!-- 1080 * 2656 / 1220 = 2351 ⇒ scale_w == scale_h == 1.1297 ⇒ 不再变形 -->

5.2 分列时钟:一个 atoi 引发的”完全消失”

挖孔在屏幕正中,居中时钟必然被遮。方案是把时间拆成两半、贴在孔左右:

<text style="text_status">
    <placement x="%center_x%-78" y="%status_info_y%" placement="5"/>
    <text>%tw_time_hh%</text>
</text>

结果两个字符完全看不见。原因堪称经典:

x = "540-78"        // gui_parse_text 把 %center_x% 替换成 540
atoi("540-78") = 540    // atoi 遇到 '-' 就停止解析
⇒ hh 和 mm 都渲染在正中心 540 ⇒ 叠在一起 + 正好压在挖孔下

placement 属性不支持算术式,必须用预计算的纯数字变量:

<variable name="clock_hh_x" value="462"/>   <!-- 540-78 -->
<variable name="clock_mm_x" value="618"/>   <!-- 540+78 -->

同时新增两个动态变量(data.cppGetMagicValue):

else if (varName == "tw_time_hh") {
    /* 小时制规则与 tw_time 完全一致(遵循 tw_military_time 设置) */
    sprintf(tmp, "%d", current->tm_hour % 12 == 0 ? 12 : current->tm_hour % 12);
}

5.3 其他 UI 修复

问题 根因 修复
解密界面导航栏偏上 data.cpp:810 用编译期宏 OF_SCREEN_H(默认1920) 覆盖主题 screen_h OF_SCREEN_H := 2351
电量/温度被 R 角裁切 OF_STATUS_INDENT_* 默认仅 20px := 64
封面图底部露出橙色条 图片 1080×1920 铺不满屏幕,露出填充色 生成 1220×2656 适配版

六、顺带修好的”启动图定制定不了”

用户改了启动图颜色、点了”应用”、提示更新成功,重启后纹丝不动。日志给出了现场:

I:operation_start: 'WLFW'
- Unpacking boot/recovery image - block device=          ← 空的!
cp: /tmp/orangefox/ramdisk/twres/splash.xml: No such file or directory
I:operation_start: 'WLFX'
- Repacking boot/recovery image ...
- Flashing repacked image ...                            ← 看起来"成功"

twrp-functions.cpp:3203 的分支选择是:

#if (defined(AB_OTA_UPDATER) || defined(FOX_AB_DEVICE)) && !defined(OF_AB_DEVICE_WITH_RECOVERY_PARTITION)
    tmpstr = Boot->Actual_Block_Device;
#else
    TWPartition *Recovery = PartitionManager.Find_Partition_By_Path("/recovery");
    tmpstr = Recovery->Actual_Block_Device;
#endif

两个问题叠加:

  1. 我先前从参考树抄了 AB_OTA_UPDATER := true,让代码误判为”无 recovery 分区”,去找 /boot——而 本机 fstab 里连 /boot/recovery 都没声明Find_Partition_By_Path() 双双返回 NULL。
  2. 结果 tmpstr 为空,解包必然失败,后续全在操作不存在的目录,定制被静默丢弃。

修复:给三份 fstab 补上 /recovery/boot 条目,并加 OF_AB_DEVICE_WITH_RECOVERY_PARTITION := 1 走正确分支。实测解包成功:

magiskboot unpack /dev/block/bootdevice/by-name/recovery_a
→ RAMDISK_FMT [lz4_legacy]
→ ramdisk.cpio 142 MB 解出 ✓(内含 twres/splash.xml 与 Splash/*.png)

七、把工程放进 CI

最后把这套东西做成了可复现的工程,推到 Gogs 与 GitHub,并写了 Actions 工作流。

关键认知修正:我一开始认为”GitHub Actions 跑不了 AOSP 构建”,这是错的。别人能跑,靠的是三点:

  1. 最小 manifest:OrangeFox 官方就有 twrp-default.xml(仅 36 个项目,而完整 AOSP 的 default.xml989 个)
  2. 浅克隆repo init --depth=1
  3. 清理 runner 预装工具链:删掉 .NET/SDK/Haskell 等,腾出约 30GB

于是工作流设计为:多设备矩阵 → 清磁盘 → 浅克隆最小 manifest → 从上游拉设备树 → 叠加本仓库的改动 → 应用 5 个补丁 → lunch → m recoveryimage → 上传产物。

仓库本身只有 1.9MB(只存改动,不存 97GB 源码树),并做了防上游删除处理:把 manifest 与设备树都 fork 到自己账号

首次运行的教训libncurses5 / lib32ncurses5-dev 在 Ubuntu 24.04 已被移除,改用 libncurses-dev 等等价包,并加了失败回退到最小依赖集合。

7.1 修正:CI 之前”看起来搭好了”,其实一次都没成功

发文时的说法需要更正——工作流能在 runner 上跑起来,但构建阶段全部失败。失败长这样:

build (pudding, ...)   UNKNOWN STEP   2026-09-12T05:20:52Z FAILED:
  out/soong/.intermediates/frameworks/av/media/libmedia/...
frameworks/av/media/libmedia/include/media/AudioCapabilities.h:24:10:
  fatal error: 'system/audio.h' file not found
ninja: build stopped: subcommand failed.

单次跑了 86 分钟才失败,前面 11 个步骤(清磁盘、配 swap、装依赖、缓存 ccache、浅克隆源码树)全部 ✓,所以乍看像”就快好了”。

7.2 根因:瘦身 manifest 删掉了依赖链需要的一半项目

system/audio.h 来自 system/media 项目。查 manifest 才发现问题:

<!-- default.xml:916 -->
<project path="system/media" name="platform/system/media" groups="pdk" />

<!-- remove-minimal.xml:563 -->
<remove-project path="system/media" />

OrangeFox 的 manifest 有两套:完整的 default.xml(989 个项目)和瘦身的 remove-minimal.xml——后者删掉了 603 个项目system/media 就在其中。而我们这台设备的依赖链需要它:

vendor.display.config → libmedia / libstagefright / codecs + libhwui 图形栈
                       → 需要 system/media/audio/include/system/audio.h

关键点:本地构建树能过,是因为本地早就手工补过。

# 本地 .repo/local_manifests/restore-system-media.xml —— 早就存在, CI 里却漏了
<remote name="tuna" fetch="https://mirrors.tuna.tsinghua.edu.cn/git/AOSP" />
<project path="system/media" name="platform/system/media"
         remote="tuna" revision="refs/tags/android-16.0.0_r1" groups="pdk" />
... 共 36 个项目

于是修复就是把这份本地清单**原样写进工作流的 local_manifests**(和已有那份改 bootable/recovery 指向的 orangefox.xml 并列):

cat > .repo/local_manifests/restore-system-media.xml <<'XML'
<manifest>
  <remote name="tuna" fetch="https://mirrors.tuna.tsinghua.edu.cn/git/AOSP" />
  <project path="system/media" name="platform/system/media"
           remote="tuna" revision="refs/tags/android-16.0.0_r1" groups="pdk" />
  ... 36 条
</manifest>
XML

教训:**”本地能构建”不等于”CI 能构建”。本地树被手工修补过的地方,正是 CI 必踩的坑——这次的 36 个项目、之前那次 bootable/recovery 被指到 TeamWin 仓库,都是同一类错误:工作流复现的是 manifest 的原始状态,不是我们工作树的状态。**

八、复盘:这次真正学到的东西

1. “参考”必须实测,不能信声明。 同机型参考树 README 写着”data decryption 稳定”,逐字照搬后依然全密文。它的价值在于排除了 fstab 这个方向,而不在于提供答案。

2. 编译期开关的破坏力被严重低估。 fscrypt_policy 的 v1/v2 结构体差异不会报任何错——编译通过、密钥装上、描述符匹配,但就是解不开。这类问题只能靠”对照可用参考的差异”来定位。

3. 用户的模糊记忆可能是最强线索。 “有一次能用,只是要手动挂载 storage”这句话,直接把方向从密钥正确性转到了挂载时机,而后者才是真正的根因。

4. 不要用”跳过”来优化。 我为了让日志安静,把 /data/user/ 加进跳过列表,结果少算 85.5GB。性能优化的前提是语义正确,跳过与 dontaudit 的区别就在这里:前者改变行为,后者只改变可观测性。

5. 属性里不能写算术式(TWRP 主题)。 atoi("540-78") == 540,这个坑让我多花了两轮构建,也贡献了本次最”隐蔽”的一个 bug。

6. 自己写的等待逻辑要审两遍。 我加的那个”最多等 20 秒”实际上能等 50 分钟——因为被等待对象的构造函数内部还有一层 30 秒轮询。当你要给某个调用加超时,先确认那个调用本身耗时多少。

九、R12.0:把整套 UI 换掉

R11.3 落地后,用户要求把 UI 完全对齐 OrangeFox R12.0。这不是换套皮肤,而是主题系统换了代。

9.1 两个版本的形态差异(不可混用)

R11.3 R12.0
twres 文件数 1567 1041
SVG 图标 无(代码里根本没有 SVG 引擎) 164 个
基础层 background #1e1f22 #100E0D
皮肤集 Black / Gray / Light / Night Black / Cream / Dark / Gray / Light
keyback 资源 Night/Keyboard/key_butts(PNG) SVG/Keyboard/key_butts_dark.svg

移植量:267 个文件、+47555 / -6930 行。R12 带进了一整套 nanosvg 渲染器(这就是为什么它的图标是矢量格式)。

9.2 最麻烦的一件事:让解密页也跟随主题

需求是”换皮肤时,解密密码那一页也得跟着变”。

难点在于解密页的渲染时机早于用户设置被读取

启动 → gui_init → 解密页渲染 ← 此时 /persist/.foxs 还没被 DataManager 读进来

试过存进 /persist/.foxs——存不进去。因为 theme_style 根本不是 .foxs 里的变量,它是主题 XML 自己的东西。

最终方案:/persist 双向镜像。

换皮肤时:customization.xml 除了写 .foxs,
          同时把 style.xml 复制到 /persist/Fox/.theme/style.xml
解密时:  gui.cpp 在渲染解密页前读回该文件,覆盖 /twres/themes/style.xml
          (rootfs 可写,直接覆盖生效)
兜底:    gui_startPage 里加 SYNC 自动补齐(兼容从旧版本升上来的用户)

实测日志(这三行是机制生效的铁证):

DECRYPT_THEME_PROBE persist_mirror=1
I:Decrypt theme: restored user theme from /persist/Fox/.theme/style.xml
I:DECRYPT_THEME_APPLIED=/persist/Fox/.theme/style.xml

9.3 差点翻车的那次:跨版本主题污染

镜像方案上线后,界面突然爆红,日志里刷了六次:

A render request has failed.

原因:用户的 /persist 镜像里存着 R11.3 的皮肤(含 Night/Keyboard/key_butts),而 R12 的 twres 里没有 Night 这套皮肤。于是一整屏资源找不到,全部渲染失败。

修复是加资源存在性校验——镜像文件里的每个资源路径,都要在当前 twres 里真实存在才应用:

prefixes = {"/twres/images/", "/twres/", "/twres/images/SVG/"};
suffixes = {".png", ".svg", ""};
// 三种前缀 × 三种后缀全试一遍, 任一命中才认为资源可用

教训持久化用户数据时,必须假设它来自”另一个版本的软件”。 那份镜像在写入时完全合法,是下一个版本让它们失效的——这类 bug 只会在跨版本升级的用户身上出现,本地重复刷同版本永远复现不了。

9.4 三处”移植漏配”——系统性核对才挖出来的

R12 移植完后 UI 基本正常,但有两个功能静默失效。为定位它们,做了一次三层系统性核对:

  1. 上游 R12 的 data.cpp所有 SetValue vs 我们这边的
  2. R12 twres 独占的变量(49 个)vs C++/资源层的引用
  3. 上游 variables.h vs 我们缺失的宏

结果抓到三处:

缺失项 症状 说明
options_list_num_group ADB & Sideload 页的配置菜单整体不可见 列表高度算出 0 ⇒ 整块渲染不出来(见下)
fox_media_rw 主题文件的 chcon/chown 参数为空
fox_media_rw_data_file 同上 实际值是 u:object_r:media_rw_data_file:s0,不是 :v0

第一个尤其阴险——它之所以看不见,是因为高度被算成 0:

int lnum_group = 512;
lnum_group = (cv * 144 + (144 * 2));
mConst.SetValue("options_list_num_group", lnum_group);

教训:**移植漏配的特征是”不报错、不崩溃、只是某项功能消失”**。逐个功能点试是查不完的,只能靠”上游有什么、我们缺什么”的系统性 diff。

十、两个版本的工程化:让人脑不再记状态

同时维护 R11.3 / R12.0 两套形态,最大的风险是切分支时忘了切主题——代码是 R12 的、twres 是 R11.3 的,构建能过、刷进去界面就坏。

于是做了两层防护:

① 主题版本化 + 切换脚本

./switch_twres.sh r113          # 切到 R11.3 形态
./switch_twres.sh r12           # 切到 R12 形态
./switch_twres.sh r113 --check  # 只校验不切换

② 构建前 8 项核对(构建前必跑,本轮抓出两次错配)

分支 · twres 文件数 · 皮肤是否含 Night · background 色值 · keyback 资源
· SVG 目录是否存在 · 版本号 · 修复是否在场

③ 构建后语义门槛校验——不能只看 build completed successfully,还要验:

background / keyback / 皮肤集 / 设备名 / variant-script 的 sha256 / AromaFM 是否已移除

为什么需要这个build completed successfully 只说明编译通过。有一次 twres 是错的、版本号是错的,构建依然”成功”。判定标准必须是语义的,不是返回码的。

十一、归档纪律:设备树差点只存在于一块盘上

打完整理产物时发现的问题,值得单独记一笔。

11.1 四个仓库有未提交改动,还有 4676 个文件根本不在任何 git 里

✗ system/vold          7 个文件已修改(971 行 FBE 解密适配) + Decrypt.cpp.orig
                       而且当前是 detached HEAD —— 切分支会直接丢
✗ vendor/recovery      OrangeFox_A16.sh(版本号 R11.3→R12.0) + .bak 文件
✗ bootable/recovery    libpixelflinger/Android.mk.orig 被误提交进仓库
✗★★ 设备树            4676 个文件 / 208MB —— 不在任何 git 仓库内

最后一条最要命:这台机器的硬盘挂掉,整个设备树就没了,而它是构建 R12 的必要条件。

11.2 修复:一个仓库,用分支区分四类内容

分支                              内容
ofox-fox_16.0 / fox_16.0          R11.3 recovery 源码
ofox-r12-ui-port / r12-ui-port    R12.0 recovery 源码
r11.3-device-tree                 设备树 R11.3 形态(1567 文件 / 无 SVG / #1e1f22)
r12.0-device-tree                 设备树 R12 形态(1041 文件 / 164 SVG / #100E0D)
vold-ofox-a16-pudding             vold 适配(两版本共用)
vendor-recovery-ofox              打包脚本(两版本共用)
ci-pudding-stage                  CI 仓库
tag r11.3-sm8850-20260912         里程碑
tag r12.0-sm8850-20260912         里程碑
tag build-r11.3-81033922          产物 → 提交 溯源
tag build-r12.0-8edff23b          产物 → 提交 溯源

设备树与 recovery 是不同根的历史,git 允许它们共存于一个仓库——这样”一个远端备份全部内容”,不必维护多个 repo。

11.3 附带查到的一个隐蔽配置错误

CI 仓库的 origin 指向 ssh://.../Serein-213/xiaomi17-ofox-pudding——和 recovery 仓库同名。也就是说,往 CI 仓库推代码会污染 recovery 仓库。已改为独立分支。

教训:**”产物归档”与”源码归档”是两件事。** 我一开始只存了 zip/img/sha256 和 git bundle,看起来齐了;但 bundle 是从 git 仓库导出的——而设备树压根不在 git 里,bundle 里自然也没有它。归档时要问的不是”我存了什么”,而是”如果这台机器现在挂掉,重建需要的东西都在哪”。

十二、recovery 为什么会烫(用户反馈带出的问题)

有用户反馈:在 OrangeFox 里待十几分钟,手机非常烫。

排查结论——这是 TWRP 系的共性问题,不是移植引入的:

① 屏幕是最大单一热源

BoardConfig.mk:187  TW_DEFAULT_BRIGHTNESS := 820
BoardConfig.mk:188  TW_MAX_BRIGHTNESS     := 1024     # 默认 80% 亮度

1220×2656 的 AMOLED 全亮是数瓦级。而任何 governor 设置都管不到屏幕

② recovery 里没有任何 userspace 温控

init.recovery.service.rc 只启动了 recovery 本体
高通的 thermal-engine / Android 的 thermald 都是 userspace 服务 —— 不在 recovery 镜像里

内核侧其实框架(thermal_zone 96 处 / cpufreq_cooling 16 处 / devfreq_cooling 25 处 / power_allocator 35 处),也不缺温控参数。但中间档的降频策略通常由 userspace 下发,recovery 里没人执行。

③ 菜单里的”性能配置”有效,但只覆盖一半

设置 → 性能配置ext_gov 页)三档,默认均衡

档位 动作
高性能 9 个 CPU 全写 performance
均衡(默认) 9 个 CPU 全写 schedutil
省电 9 个 CPU 全写 powersave(锁最低频)
✓ 省电模式对"SoC 发热"有效 —— 待机时明显更凉
✗ 对屏幕无效 —— 而屏幕才是主热源
⚠ 刷机时反而可能更糟:锁最低频让解压/校验慢 2~4 倍,
  而总发热 ≈ 功率 × 时间,慢下来的时间会吃掉降频省下的热量

还有个时序细节:governor 只在 TWFunc::OrangeFox_Startup() 里应用一次(twrp.cpp:285),改完设置要重启 recovery 才生效

④ 已排除:不是我们引入的

✓ governor 默认 schedutil(balance=1),不是 performance
✓ 屏幕超时生效(TW_NO_SCREEN_TIMEOUT 未定义 ⇒ 60s)
✓ 移植改动里没有任何常驻循环/忙等

要真正解决,得动亮度(屏幕热源)和加温控守护脚本(recovery 版 thermald),只调 governor 不够。

十三、复盘(续):后半天新增的四条

7. “本地能过”和”CI 能过”之间隔着整个 manifest。 本地树是被人手反复修补过的,CI 从零同步的是原始 manifest。把你手工做过的每一件事都当成 CI 的必填项。

8. 持久化数据必须按”来自其他版本”来防御。 /persist 里的主题镜像在写入时完全合法,是下一个版本让它失效的。加存在性校验——校验的不是格式,而是”当前版本是否真的有这些资源”。

9. 移植漏配的典型症状是”功能静默消失”。 不报错、不崩溃,只是某块 UI 不再渲染。只能靠系统性 diff(上游有 / 我们没有),不能靠逐个功能点试。

10. 归档要问”机器挂了会丢什么”,而不是”我存了多少东西”。 我存了 4 个 git bundle,却没发现设备树根本不在 git 里——bundle 只覆盖它导出的那个仓库。同一次核对还查出 971 行 vold 改动躺在 detached HEAD 上、CI 仓库 origin 与 recovery 仓库同名。


附:最终状态(更新)

产物  R11.3 卡刷包  78,583,848 字节  sha256 45a9d236…d2176c   (设备实测通过)
      R12.0 卡刷包  81,033,922 字节  sha256 8edff23b…99b298b   (解密页跟随主题已验证)
      镜像 ×2       各 104,857,600 字节
DE   /data/system_de/0 · /data/misc                → 明文
CE   /data/media/0 · /sdcard · /data/system_ce/0   → 明文
     真实 spblob(原始位置, 不依赖 DE 副本)          → [SP] src=REAL
性能 SELinux 拒绝 -87% / IHealth 轮询 -97% / keystore 等待上限 50min→10s
UI   等比缩放 · R角避让 · 分列时钟避开挖孔 · 封面图适配 · 中文默认
     R12.0 完整主题(nanosvg/164 SVG) · 解密页跟随主题(/persist 镜像 + 存在性校验)
工程 twres 版本化 + 切换脚本 · 构建前 8 项核对 · 构建后语义门槛校验
归档 4 个 git 仓库全部零未提交/零未跟踪 · 单一 Gogs 仓库 10 分支 + 4 标签
CI   ★ 找到并修复真实根因(remove-minimal 删掉 36 个依赖项目)

工程已开源(只含改动集):
https://github.com/serein-213/xiaomi17-ofox-pudding

代价:从 88 次失败到发布 R11.3/R12.0 两套可刷包,中间是更多次的构建、两轮完整移植、一次 CI 根因追查,以及一次差点让设备树只留在单盘上的归档疏漏。最大的体会没有变——这类”玄学”问题从来不是玄学,只是证据还不够细;而工程上的坑,往往出在**”我以为它已经存好了”**的地方。

阅读全文

在 KDE Wayland 上折腾本地语音输入(voxtype):底噪诊断、RNNoise 翻车与 DeepSeek 后处理

技术 2026/9/11 0

环境:Arch Linux + KDE Plasma(Wayland)+ PipeWire 1.6.8 | Ryzen 5 7500F | RTX 4070 SUPER | ALC897 模拟麦
目标:按住热键说话 → 本地识别 → 文本直接落到光标处(复刻 macOS 上 VoxCode 的工作流)
结果:voxtype 1.0.1(Whisper large-v3-turbo + Vulkan)+ PipeWire 110Hz 高通降噪 + DeepSeek 后处理,中文可用,口述到上屏 1 秒内

阅读全文

HyperOS 4 增量 OTA 卡死排查:update_engine 源哈希校验 vs KernelSU

技术 2026/9/5 0

设备:小米平板(pudding,HyperOS 4 / Android 17),KernelSU + Zygisk Next + LSPosed
现象:0.24 → 0.25 小版本增量更新,下载完成、解压到 1% 即报「系统更新失败」
结果:根因定位到 update_engine 原生层的源哈希校验,绕过方案验证通过;顺带修复了 HyperCeiler「移除 OTA 校验」在 HyperOS 4 上的元数据消失问题

一、现象与第一现场

「移除 OTA 校验」模块开着,增量元数据正常出现、增量包下载完成,然后解压刚到 1% 就弹窗失败。logcat 里 Updater2: onInstallFailed -20-20 对应 update_engine 的 kDownloadStateInitializationError

往上翻 update_engine 自己的日志,真正的错误在这里:

E/update_engine: [ERROR:partition_writer.cc(427)] The hash of the source data
  on disk for this operation doesn't match the expected value.
E/update_engine: Expected:   sha256|hex = EB15A507F867…874936
E/update_engine: Calculated: sha256|hex = 3A3FF0F07258…96A773
E/update_engine: Operation source (offset:size) in blocks: 0:512
W/update_engine: Source hash mismatch on partition init_boot @ /dev/block/by-name/init_boot_a
E/update_engine: Failed to perform SOURCE_COPY operation 245, partition "init_boot"

verified_source_fd.cc 里还有一条「Unable to open ECC source partition」是噪音——ECC 是 ChromiumOS 的机制,Android 上永远打不开,fallback 后照常读源,不是死因。)

二、第一个坑:blocks 的单位

0:512 的 512 是 payload 的块数,块大小是 4096 字节,不是 512。所以被校验的区域是 512 × 4096 = 2 MB,不是 256 KB。一开始按 256 KB 校验设备分区和镜像,怎么都对不上号;换成 2 MB 后全部命中:

# 设备 a 槽(被 KSU 改过)
head -c 2097152 /dev/block/by-name/init_boot_a | sha256sum
# 3a3ff0f0…  ← 与 update_engine 的 Calculated 完全一致

# 官方 0.24 完整包 payload 提取的 init_boot.img
head -c 2097152 init_boot.img | sha256sum
# eb15a507…  ← 与 Expected 完全一致

三、死结的完整链条

三层原因叠加:

1. A/B 增量包对「没变的分区」用 SOURCE_COPY 空复制,且强制验源

增量包(delta payload)对每个分区逐块决策:变了 → 带补丁数据;没变 → SOURCE_COPY 空复制(从旧分区原样复制,省体积)。关键是空复制同样要校验源哈希——update_engine 必须确认「源就是打包时的基线」才肯复制,这是写镜像的正确性保证,native 层硬编码,没有开关。

2. KernelSU 的补丁正好在 init_boot 的前 2 MB

KSU 的内核补丁打在 init_boot,被校验区域(前 2 MB)恰好是补丁区。源哈希 = 官方基线的假设被打破,EB15A5…(官方)vs 3A3FF0…(KSU 版),校验失败,整个 payload 处理终止。

3. HyperOS 4 早期版本每次小增量都带着 init_boot

逐块对比官方 0.24 与 0.25 的 init_boot:

block0 (0-2MB):   相同
block1 (2-4MB):   不同   ← 唯一变化(ramdisk 内某文件改了)
block2 (4-6MB):   相同
block3 (6-8MB):   相同

只要分区有一丁点变化,整分区进包;没变的块就用空复制表达。大版本刚出的时候 init/ramdisk 还在频繁改动,所以这段时间的每个小增量都会带上 init_boot,每次都会触发这个死结。

这同时解释了「以前为什么行」:HyperOS 3 末期 init 已经稳定,小增量只动 system/vendor,根本不碰 init_boot——增量包里没有这个分区的操作,就没有源哈希校验,KSU 改着也无所谓。以前「只用模块关掉 OTA 校验就行」成立;现在不是 hook 变弱了,是包开始带 init_boot 了。

四、为什么 Java hook 救不了

「拦截校验、伪造哈希、把 SOURCE_COPY 改成 REPLACE」三条路全部走不通:

  1. 校验在 update_engine 进程里——init 直接拉起的独立 native 服务,不是 zygote 派生,LSPosed/Zygisk 连注入通道都没有,更谈不上 hook;
  2. **改 payload 里的 src_sha256**——payload 有整包签名(内置公钥验证),改一个字节签名就翻车;
  3. 手改操作类型——REPLACE 需要嵌入官方数据并重算整包哈希链,等于重打包增量包,还得过签名,工程上不现实。

五、解法:把「源」变回官方

既然死结是「源 ≠ 官方基线」,就把源恢复成官方。全程两分钟:

# 1. 官方 init_boot 来源:完整包 payload 提取(Payload Dumper),或 KernelSU Manager 的「还原原厂镜像」
dd if=/data/local/tmp/init_boot_official_024.img of=/dev/block/by-name/init_boot_a

# 2. 校验是否命中基线
head -c 2097152 /dev/block/by-name/init_boot_a | sha256sum   # 应为 eb15a507…

# 3. 系统更新走增量 → 全程通过 → 装 KSU 到未激活槽(KernelSU Manager 一键)→ 重启

关键时机:update_engine 是边下载边应用的(流式处理,失败发生在下载开始后 3 秒),所以「还原原厂镜像」必须在点击下载之前完成,不能下载到一半再还原。

更新完成后新槽是官方系统,root 随 init_boot 一起没了,用 KernelSU Manager「安装到另一个槽位」把 KSU patch 到新槽,重启后 root 与 LSPosed 模块自动恢复。

六、顺带修了 HyperCeiler 的「移除 OTA 校验」

排查过程中发现该功能在 HyperOS 4 上有独立的 bug:support_ota_validate 这个标志位被 OTA 服务类在 <clinit>(静态初始化)里读取,用来决定增量更新的 URL/模式;旧实现全局强制返回 false,静态初始化拿到错误值,导致增量元数据直接消失

修复方式:hook 时检查调用栈,栈里存在 <clinit> 就放行原值(保住静态初始化),离开 <clinit> 之后才返回 false(只关校验)。DexKit 定位不到时回退到 FeatureParser.hasFeature 的旧 hook。

提交:fix(updater): support_ota_validate 静态初始化期间放行原值(HyperCeiler)

七、规律与预言

  • 判定方法:直接试。增量过了 = 这次包没碰 init_boot,白赚;卡了 = 走两分钟流程,必过。
  • 趋势:HyperOS 4 进入稳定期(init_boot 不再随小版本变化)后,增量包不再带 init_boot,又会回到「只用模块、不用刷回」的状态。现在的阵痛期是新大版本的特性,不是永久的。
  • 本质:这不是校验开关问题,是「打包策略(空复制也验源)+ root 位置(补丁在 init_boot)」的物理矛盾。只要 KSU 还在 init_boot 上、ROM 还在改 init,这个流程就是标准操作。

附件

阅读全文

DRM 半瘫故障排查与修复总结:QXL 显卡 TTM 死锁

技术 2026/8/31 0

日期:2026-08-31
对象:VM 801(Arch Linux,内核 7.1.5-arch1-2)
结论:根因是 QXL 虚拟显卡 的 TTM 显存管理异常导致关机路径 drm_modeset_lock 死锁,连坐 systemd PID 1;修复为换用 virtio-gpu。

一、现象

  • 系统”半瘫痪”:Hermes 网关、SSH 都在跑,但 systemctl 全部挂死/超时
  • SSH 新会话登录失败,报”传输端点未连接”
  • 内核 hung task 报告:systemd:1 blocked for more than 1228s,栈在 drm_modeset_lock,进程状态 D(不可中断睡眠)

二、排查过程

  1. 先疑 sshd 未运行——拉起后连接恢复,但 systemctl 依旧瘫痪
  2. 怀疑 D-Bus/套接字缺失——实测四个关键套接字全部连通,排除
  3. 最终定位:/proc/1/wchan 显示 PID 1 卡在 drm_modeset_lock,hung task 阻塞时间 245s → 1228s 递增后被抑制

关键证据(journalctl -b -1)

时间 事件
8/8 01:50 起 TTM Buffer eviction failed 持续爆发:8/8 一天 5193 次、8/9 75 次,整个 boot 共 5608 次
8/31 05:31:40 root 从 172.16.15.17(宿主机网段)SSH 登录
05:31:51 执行 shutdown(logind: poweroff requested from client PID 289246)
05:35:26 起 TTM eviction failed 密集出现(当天 340 次),关机流程走显示路径
05:35:53 systemd PID 1 blocked,hung task 报告 21 条,1105s→1228s 后抑制
05:31–06:58 hermes 进程持续运行、网络存活——“看着活着”
06:58:53 VM 终结,07:00:22 重启恢复

三、根因

  • 本 VM 显卡为 QXL 虚拟显卡qxl 驱动,16M VRAM + 64M surface),非 i915
  • qxl 的 TTM 显存管理持续异常(eviction failed 从开机第二天即出现,5608 次)
  • 8/31 05:31 的 shutdown 关机流程(停 Graphical Interface、显存回收)触发 drm_modeset_lock 死锁
  • systemd PID 1 撞锁永久阻塞(D 状态,SIGKILL 无效),整个管理栈失效
  • 此前误判为宿主机 i915 驱动锁死——错误。宿主机 5 个 boot 全部干净,i915 从未是故障源

四、修复

qm shutdown 801        # 优雅关机(修复前该路径必卡死)
qm set 801 --vga virtio # qxl → virtio-gpu
qm start 801

五、验证(三层全部通过)

  1. 结构lsmodvirtio_gpu,无 qxl/dev/dri/card1 + renderD128;本 boot 内核日志 0 条 TTM/hung task/drm_modeset_lock
  2. 行为:完整关机-重启循环,优雅关机 10.7 秒 完成(修复前:卡死 87 分钟被迫强制重启),连续两次通过
  3. 基线:systemd running,hermes-gateway / sshd 均 active,日志零异常

六、后续

  • 本机内核 7.1.5 可随时 pacman -Syu 升级观察(独立于本次修复)
  • 其他 VM 扫描:仅 115 (Win-xp) 用 qxl,但 Windows guest 走厂商驱动、不经 Linux TTM 路径,无同类风险,维持现状
  • 若文档化监控:journalctl --since "1 day ago" | grep -cE "TTM.*eviction failed|hung task|drm_modeset_lock" 应持续为 0

附录:错误结论作废声明

  • i915.enable_guc=0:无效(VM 内无 i915)
  • modprobe.blacklist=i915:无效(同上)
  • “唯一路径 sysrq”:仅对物理机成立;VM 可直接 qm reset
阅读全文

逆向小米搜索框:给 com.android.quicksearchbox 换必应 + 改造跳转 Via

技术 2026/8/31 0

设备:小米 13(HyperOS),KernelSU root
目标:把搜索框的百度换成必应;把网页搜索从写死的 Mi 浏览器改跳到 Via
结果:引擎切换 + 跳转改造均生效,附带一键切换脚本

一、搜索引擎配置藏在哪

小米搜索(com.android.quicksearchbox)的引擎逻辑集中在 com.android.quicksearchbox.xiaomi.searchengine 包,配置有三级数据源:

  1. APK 内置默认assets/websearch-default/searchengineNew.json
  2. 本地缓存(云端下发落盘):/data/user/0/<pkg>/files/data/websearch/websearch-<区域>-<hash>/searchengineNew.json
  3. 云端接口POST https://api.browser.miui.com/global/search/api/searchEngine/forGlobalSearch(默认 30 分钟~1 小时刷新一次)

核心 JSON 结构:

{
  "data": {
    "updateIntervalMinutes": 30,
    "defaultSearchEngineMap": {
      "globalSearchSearchBox": "baidu",
      "globalSearchHotList": "baidu"
    },
    "searchEngineSceneMap": {
      "globalSearchSearchBox": {
        "searchEngines": [
          {
            "searchEngineName": "baidu",
            "searchUrl": "https://m.baidu.com/s?...&word={searchTerms}...",
            "title_zh_CN": "百度"
          }
        ],
        "homepageSearchEngineCount": 3,
        "resetSearchEngineData": false
      }
    }
  }
}

两个场景:搜索框(globalSearchSearchBox)和热搜榜(globalSearchHotList)。{searchTerms} 是关键词占位符。

用户的选择保存在 SharedPreferences searchEngine

Key 含义
current_engine 当前引擎名(默认 baidu
engine_click_count 是否手动切换过
client_scene_info 场景 → {sceneId, currentEngine, hasChanged}

二、把百度换成必应

思路:不动引擎名current_engine 保持 baidu,避免服务端逻辑漂移),只把本地缓存 JSON 中 baidu 条目的 searchUrl / 标题换成必应:

  • searchUrl: https://cn.bing.com/search?q={searchTerms}
  • 标题:必应 / 必應 / Bing
  • 图标:本地 PNG(Glide 不支持 ICO/SVG,注意选 PNG 源)

防覆盖是关键:配置目录和文件都加 chattr +i(immutable)。云端刷新写文件失败时不会清本地文件,也不会切换 hash 文件名。

chattr +i searchengineNew.json
chattr +i websearch-zh-CN-<hash>/

三、点击建议”没反应”的真相

改完必应后测试:点击搜索建议词毫无反应。排查发现网页搜索跳转在 V2/n1.java硬编码了 Mi 浏览器:

intent.setClassName("com.android.browser",
    "com.android.browser.fullsearch.FullSearchActivity");

而这台手机上 com.android.browser 根本没安装——startActivity 抛的异常被 catch 静默吞掉。也就是说:任何引擎都会无反应,跟换必应无关

四、改 smali:跳转 Via

不装 Mi 浏览器、也不做桥接 APK,直接改 QSB 的 dex:把 V2/n1.c() 方法整体替换为”对 Via 发标准 VIEW”:

.method public static c(Landroid/content/Context;Landroid/content/Intent;Ljava/lang/String;Ljava/lang/String;Ljava/util/HashMap;)V
    .locals 3

    if-eqz p2, :cond_0

    :try_start_0
    invoke-static {p2}, Landroid/net/Uri;->parse(Ljava/lang/String;)Landroid/net/Uri;
    move-result-object v0

    new-instance v1, Landroid/content/Intent;
    const-string v2, "android.intent.action.VIEW"
    invoke-direct {v1, v2, v0}, Landroid/content/Intent;-><init>(Ljava/lang/String;Landroid/net/Uri;)V

    const-string v0, "mark.via"
    invoke-virtual {v1, v0}, Landroid/content/Intent;->setPackage(Ljava/lang/String;)Landroid/content/Intent;

    const/high16 v0, 0x10000000
    invoke-virtual {v1, v0}, Landroid/content/Intent;->addFlags(I)Landroid/content/Intent;

    invoke-virtual {p0, v1}, Landroid/content/Context;->startActivity(Landroid/content/Intent;)V
    :try_end_0
    .catch Ljava/lang/Exception; {:try_start_0 .. :try_end_0} :catch_0

    :cond_0
    return-void

    :catch_0
    move-exception v0
    return-void
.end method

c() 的第二个参数恒为 {searchTerms} 替换后的完整 URL(仅被 K() 内部两处调用),替换安全。

工具链与部署(全程 root):

# Termux
pkg install apktool python
apktool d -f --no-res base.apk -o qsb_d   # 只解 smali
# Python 正则整块替换 V2/n1.smali 的 c() 方法
apktool b qsb_d -o qsb_new.apk           # 只重编 smali,资源原样

# 部署:覆盖 base.apk + 删 oat 缓存
cp qsb_new.apk /data/app/<pkg路径>/base.apk
chown system:system base.apk && chmod 644 && restorecon
rm -rf /data/app/<pkg路径>/oat
am force-stop com.android.quicksearchbox

几个关键点:

  • base.apk 无 fs-verity(lsattrV 标志),可直接替换文件
  • 签名校验只在安装时发生;重启后 PMS 读 packages.xml 缓存,不重校验
  • 替换 dex 必须删 oat/(art/odex/vdex),否则 checksum 不匹配;首次启动 JIT 稍慢
  • 备份原 APK 和 websearch 目录到 /data/local/tmp/,出问题覆盖回去 + 删 oat 即回滚

五、一键脚本

把整个流程沉淀成脚本:engine(换引擎)、browser(换跳转目标)、restore(回滚)、status(体检)。换引擎时自动 chattr 解锁/加锁、修 SELinux context;换浏览器时基于原版备份幂等重建 APK。支持 via/chrome/edge,自定义引擎 URL 也行。

qsb-tool.sh status                 # 当前引擎/跳转目标/保护状态
qsb-tool.sh engine bing            # 换成必应
qsb-tool.sh browser via            # 跳 Via
qsb-tool.sh restore all            # 一键回滚
qsb-tool.sh refresh-backup         # OTA 升级后刷新 APK 备份

一键脚本下载qsb-tool.sh

OTA 后一键恢复ota-recover.sh——应用商店/系统更新后跑一次即可(自动检测:资源完整+已patch→只刷引擎;原版→重打补丁;资源缺陷(小米更新包 bug)→自动降级分区版再打补丁)。

依赖:root(KernelSU/Magisk)+ Termux 的 python3apktool(仅 browser 命令需要)。放到任意目录,bash qsb-tool.sh help 查看用法。

五·五、LSPosed 模块方案(进阶,推荐长期使用)

改 dex 的痛点:OTA 升级后失效、云端换 hash 绕过锁。如果你装了 LSPosed(Zygisk 版),可以直接 hook,不改任何文件、永久免疫 OTA

  • Hook 点 1com.android.quicksearchbox.xiaomi.searchengine.f.h(Context)(返回 searchUrl)→ 强制替换为 Bing URL
  • Hook 点 2V2.n1.c(Context, Intent, String, String, HashMap) 后置 → 拦截 Mi 浏览器跳转,重定向到已安装的浏览器(默认 mark.via

用法:下载 qsb-hook.apk 安装 → LSPosed Manager 里启用 → 作用域勾选 com.android.quicksearchbox → 重启 zygote(软重启或重启手机)生效。

⚠️ 注意:混淆类名 V2/n1 随 QSB 版本变化,本模块仅验证于 QSB 13.1.0.08242(HyperOS 4 / Android 17)。其它版本若 hook 失败,LSPosed 日志会提示 hook ... FAIL,不影响原功能。

LSPosed 模块下载qsb-hook.apk

六、经验总结

  1. 改系统应用优先改数据,其次改 dex,最后才重打包:配置 JSON + chattr 保护就解决了 90% 的问题。
  2. 无反应 ≠ 没调用:先看代码里异常是不是被吞了,再看目标组件是否存在。
  3. sed 处理 XML 转义串是坑& 是特殊符号),字符串替换一律用 Python。
  4. OTA 是最大敌人:升级会覆盖 /data/app 的修改,脚本里 refresh-backup + 重放流程即可恢复。
阅读全文

记一次 Android 17 升级后“内部存储文件全部消失”的排查与恢复

技术 2026/8/15 0

日期:2026-08-15
设备:小米 13(HyperOS / Android 17 beta),KernelSU root,数据分区 f2fs
结果:约 110.7GB、242,600 个文件、30,100 个目录,100% 恢复,零覆盖、零删除


一、现象

系统升级到 Android 17 后,/storage/emulated/0(内部存储)里几乎所有旧文件”消失”,只剩
AndroidDCIMDownloadMIUIPictures 等升级后应用重新生成的空目录。

而所有旧文件其实都在:

/storage/emulated/uncasefolded/0/

uncasefolded 是 vold 的内部目录(media_rw 权限),普通应用和文件管理器看不到它,
所以看起来像”文件全没了”。

二、根因

1. 大小写不敏感存储(casefold)迁移

Android 16/17 起,内置存储改为大小写不敏感(目录带 F casefold 标志,可用
lsattr 查看)。系统迁移时会把无法共存的大小写冲突文件搬进
/storage/emulated/uncasefolded/

本次升级中迁移异常(疑似小米 beta 的迁移中断/有 bug),把主用户几乎全部数据
搬进了 uncasefolded/0/ 却没有搬回。vold 迁移进程事后处于空闲状态,不会自动恢复,
需要手动搬回。

2. f2fs 项目配额(projid)——恢复时的拦路虎

手动搬回时,renameEXDEV (errno 18, Cross-device link)

  • 升级后应用新建的 Android/data/<包名> 目录带应用专属 project ID(如 20262),
    旧数据的 projid 是 0
  • f2fs 内核禁止跨 projid 的 rename,root 也无法豁免;
  • 注意:toybox mv 遇到 EXDEV 会静默退化为”复制+删除”,容易误以为 rename 成功。

3. 与 Magisk / KernelSU 模块无关

排查确认 /proc/mounts 挂载表干净,无模块干预痕迹。EXDEV 是文件系统层的
项目配额限制,Mountify 等挂载类模块不影响 rename 语义。

三、诊断命令

# 1. 看两侧内容
su -c 'ls -la /data/media/0/ /data/media/uncasefolded/0/'

# 2. 看目录的 projid 和 casefold 标志(F)
su -c 'toybox lsattr -p -d /data/media/0/Android/data/某包名 /data/media/uncasefolded/0/Android/data/某包名'

# 3. 确认没有可疑挂载点(排除模块/挂载因素)
su -c 'grep -E "uncasefolded|Android/data" /proc/mounts'

# 4. 测试跨 projid rename(预期 FAIL errno=18)
su -c 'touch /data/media/uncasefolded/0/.t; mv /data/media/uncasefolded/0/.t /data/media/0/Android/data/某包名/files/.t'
#     (mv 可能"成功",那是因为它自动退化成复制;用 strace/python 测试才是真相)

# 5. 验证"临时清零 projid 后可以 rename"(关键技巧)
su -c 'toybox chattr -p 0 /data/media/0/Android/data/某包名/files
       mv /data/media/uncasefolded/0/.t /data/media/0/Android/data/某包名/files/.t
       toybox chattr -p 原值 /data/media/0/Android/data/某包名/files'

四、恢复方案设计(数据安全第一)

  1. 先做全量清单:两侧目录的「相对路径 + 类型 + 大小」落盘留档,事后逐条核对;
  2. 只做原子无覆盖搬移:用 renameat2(RENAME_NOREPLACE),目标已存在时内核拒绝,
    杜绝覆盖;绝不使用会覆盖的 mv/cp
  3. 大小写冲突改名保留双方:从 uncasefolded 搬入的冲突项改名
    xxx (uncasefolded)(重名再加数字),两边都保留;
  4. EXDEV 处理:临时把源/目标父目录 projid 清零 → rename → 结束后恢复原值;
    待恢复列表持久化到 pending_projid.tsv,中途断电/被杀,下次运行会先恢复;
  5. 兜底:真·跨挂载点(如 .siexternal)无法 rename 时,文件走
    「复制→校验大小→fsync→无覆盖改名→删源」,目录走「重建→递归」;
  6. 结束逐文件校验:每个源文件都能按映射表解析到目标路径且大小一致、
    uncasefolded 剩余文件数为 0。

同分区搬移全部是 rename,瞬间完成、不占额外空间,权限/时间戳/SELinux 上下文
原样保留。

五、完整脚本 restore_merge.py

脚本本体约 14KB,作为附件提供,正文不再贴全文:

restore_merge.py

子命令:manifest 生成两侧清单、merge 合并、verify 校验、chattr 补 casefold 标志;
具体用法见下一节。

六、使用步骤

# 0. 前提:root(su),python3
#    把脚本放到 root 可执行的位置
su -c 'cp restore_merge.py /data/local/tmp/ && chmod 700 /data/local/tmp/restore_merge.py'

# 1. 生成两侧清单(必须在 merge 之前做,用于校验)
su -c 'python3 /data/local/tmp/restore_merge.py manifest'

# 2. 执行合并(可重复执行,幂等;中断后重跑即可)
su -c 'python3 /data/local/tmp/restore_merge.py merge'

# 3. 校验(期望:src 剩余文件数=0、problems=0、原dst文件消失=0)
su -c 'python3 /data/local/tmp/restore_merge.py verify'

# 4. (可选)给能加 F 标志的目录补 casefold 标志
su -c 'python3 /data/local/tmp/restore_merge.py chattr'

Termux 里 python3 的完整路径是 /data/data/com.termux/files/usr/bin/python3
其他环境直接用 python3

七、本机实测结果

项目 结果
源文件总数 242,600(约 110.7GB)
搬回并校验通过 242,600 / 242,600 ✅
uncasefolded 剩余文件 0(只剩 4.2MB 空目录壳)
原有新文件丢失 0
大小写冲突改名保留 1,542 处 → xxx (uncasefolded)
临时清零的 projid 172 个目录,全部恢复原值
迁移期间应用新写入的文件 43 个(EXTRA,属正常)

verify 报告中唯一一条 SIZE-MISMATCH 是相册应用正在使用的 SQLite -wal
日志文件被应用正常截断所致,并非数据丢失。

八、收尾

# 1. 重启一次手机,让媒体库重新扫描索引(相册/音乐等应用重新"看到"文件)

# 2. 查看所有因大小写冲突而改名的文件(双方都保留,自行取舍合并)
find /sdcard -name "* (uncasefolded*" -print

# 3. 确认一切正常后,删除 uncasefolded 空壳(建议先观察几天)
su -c 'rm -rf /data/media/uncasefolded'

九、注意事项

  • 恢复全程只做 rename(同分区搬移),不复制、不占额外空间、瞬间完成,
    权限/时间戳/SELinux 上下文原样保留;
  • 升级后应用新建的文件和目录不会被覆盖,原样保留;
  • 冲突改名的文件双方都在,没有任何数据被删除
  • 大量旧目录无法加 F(casefold)标志,因为内核要求空目录才能启用;
    这只影响大小写查找语义,不影响正常访问;
  • merge 中途被杀/断电:直接重跑即可(幂等,且会先恢复上次未还原的 projid);
  • 执行前建议先看 vold 是否还在做迁移(su -c 'top -b -n1 | grep vold'),
    确认空闲再动手。

十、进阶:为旧目录启用 casefold(大小写不敏感)

为什么恢复后很多旧目录加不上 F 标志

F 标志 = 目录级大小写不敏感。内核只允许「空目录」或「升级后新建的目录」
直接 chattr +F;旧目录里的目录项(dentry)是启用 casefold 之前创建的旧格式,
直接翻转会报 Directory not empty,即使目录里只有文件。

实测还发现两个关键点:

  1. 危险:目录里若已有大小写重名文件(如 A.txt / a.txt),直接 chattr +F
    会让其中一个文件”隐身”(无法再访问)!所以必须先查重名,有重名的目录整体跳过
  2. 跨目录 rename 的 EXDEV:内核检查「源 inode 的 projid == 目标父目录的 projid」。
    应用创建的文件本身带应用 projid(如 30463),搬到 projid=0 的暂存目录会被拒。
    解法:临时清零源 inode 的 projid,rename 后恢复(文件和目录都可 chattr -p)。

转换方案

对每个无 F 的旧目录:搬空到同分区暂存目录 → chattr +F → 搬回
(搬回时内核按新格式重建目录项)。每目录独立事务,搬出失败自动回滚;
崩溃/中断后下次运行自动从日志配对找回滞留的暂存目录。

完整脚本 normalize_casefold.py

脚本本体约 10KB,作为附件提供:

normalize_casefold.py

用法:su -c 'python3 /data/local/tmp/normalize_casefold.py'

实测结果

  • 第一轮:转换成功 20,684 个目录,失败 274 个(均为应用文件带 projid 的 EXDEV,
    数据完好、自动回滚);中断一次后自动找回 130 个滞留暂存目录,零丢失
  • 第二轮(修复源 inode projid 清零后):144 个全部成功,失败 0
  • 最终仅剩 5 个目录未转换(内含大小写重名文件,为防”隐身”按设计跳过):
    cn.kaiheila/filescom.zongheng.reader/filescom.tencent.hunyuan.app.chat/files
    com.tencent.lolm/filescom.tencent.qqmusic/files
  • 若想转换这 5 个目录,需要先手动改掉其中的大小写重名文件(双方都在,可自行取舍)

常用命令

# 查看目录是否已启用 casefold(含 F 即已启用)
su -c 'toybox lsattr -d /sdcard/某目录'

# 查看跳过的重名目录里有哪些冲突对
su -c 'ls /data/media/0/Android/data/cn.kaiheila/files | sort -f | awk "{n=tolower(\$0); if(seen[n]++) print}"'

十一、为什么 Android 16 没事、升级 17 才出事(根因溯源)

结论先行

Android 16 上小米通过内置的 casefolding_remover 服务关闭了大小写不敏感存储(casefold),
所以一切正常;Android 17 开始该特性被强制启用,升级时系统执行了启用迁移(数据搬进
uncasefolded),随后小米的兼容服务尝试把它”关回去”,但禁用失败Disabling failed),
数据卡在 uncasefolded 里没有搬回。

证据

本机系统属性(su -c getprop):

属性 含义
external_storage.casefold.enabled 1 casefold 被启用
ro.casefolding.adjusted 1 启用迁移已执行
persist.sys.casefolding.status Disabling failed 小米随后尝试”禁用”casefold,失败
init.svc.casefolding_remover stopped 服务跑完即停(oneshot),没有恢复动作

服务本体 /system/bin/casefolding_remover(Rust 实现,对应 AOSP 标准接口
android.os.casefoldingremover.ICasefoldingRemover),从二进制字符串可以看到它的
状态机与错误路径:

Enabled / Enabling / Disabling / Disabled
Enabling failed / Disabling failed
Failed to remove case
Attempting to move folder to dest (does not change casefolding)
persist.sys.casefolding.status
external_storage.casefold.enabled
persist.sys.casefold.enabled.override

时间线还原

  1. Android 16:casefold 被 Xiaomi 关闭(兼容自家应用),存储保持传统大小写敏感
  2. 升级 Android 17:casefold 强制启用,系统执行启用流程——把 /data/media/0 数据
    整体搬进 uncasefolded 暂存、给目录打 F 标志
  3. casefolding_remover 按小米策略尝试禁用 casefold(改回旧状态),
    中途失败(很可能败在本文第四/十章记录的同一个坑:f2fs 内核拒绝跨 projid
    的 rename,它的搬回操作没有兜底)
  4. 失败后系统彻底放弃:uncasefolded/0 的 mtime 停在升级当天 00:29,
    00:34 起应用开始重建空目录,vold 全程空闲——与观察完全吻合

与 root / Magisk 模块无关

挂载表干净、无模块干预痕迹;这是小米官方「启用→禁用」流程失败与 Android 17
强制启用 casefold 的组合结果。root 只是让我们得以手工完成系统没做完的迁移。

后续影响与建议

  • casefolding_remover 每次开机都会再跑一次(init 启动项)。它目前仍会失败;
    即使未来某次”成功”,也只会处理已空的 uncasefolded 壳、最多去掉部分 F 标志,
    不会丢数据,无需干预
  • 当前状态(casefold 启用)与恢复后的文件系统状态一致,保持不动即可
  • 若小米后续推送修复,persist.sys.casefolding.status 会变为正常值,可留意观察
阅读全文

Windows 桌面黑屏故障排查实录

技术 2026/7/31 0

一次「桌面黑屏但窗口正常」的排障记录。期间一度以为罪魁祸首是 NVIDIA 覆盖层,最终证实那只是干扰项——真正的主因是 Windows.UI.Xaml 宿主崩溃,导致 explorer 无法创建桌面外壳窗口(Shell_TrayWnd)

阅读全文

HFish 蜜罐数据库迁移实录:从 SQLite 到 MySQL 的踩坑与总结

技术 2026/6/30 0

背景

一台跑在京东云上的 HFish 蜜罐(2核 2GB),跑了大约 40 天,某天登录面板时提示”数据库错误”。SSH 上去一看,日志里铺天盖地的 database is lockedcontext deadline exceeded

数据库是 HFish 默认的 SQLite,文件体积 1.5GB,里面 file_attack 表 211 万行。蜜罐 7×24 接收全球扫描,每秒几十条写入需求,全部怼在 SQLite 那唯一一个写入槽上,崩盘是迟早的事。

阅读全文

从零搭建 Hexo 博客记录

技术 2026/5/27 0

一直想有个自己的小角落,记录一些技术笔记和生活碎片。折腾了一晚上,博客总算跑起来了,在这里简单记一下过程。

阅读全文
1
avatar
Serein

偶尔更新。
技术、阅读和摄影碎片。

总访问 -