从 88 次构建失败到两套可刷包:小米 17 OrangeFox Recovery 移植全记录(含 R12.0 UI 移植)
设备:小米 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 镜像)
- R11.3:r11.3-recovery-解压刷入.7z — 解压得
r11.3-recovery.img,sha256f32fee2784cdfc31ecbe7ccd860029ed598f4c7b1fd33e68e8b6dca9b1780290- R12.0:r12.0-recovery-解压刷入.7z — 解压得
r12.0-recovery.img,sha256bba1c21699c85c6f30da2c9ac667af5f489857a7fec605641e73eadc5a8d0961刷入:
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 这样的密文。
更麻烦的是三个互相纠缠的现象:
/data/system_de/0(DE 层)文件名密文/data/media/0、/sdcard(CE 层)文件名密文- 解锁后重启,连已经明文的目录都会退回密文
正常 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-613,while(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.cpp 的 GetMagicValue):
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
两个问题叠加:
- 我先前从参考树抄了
AB_OTA_UPDATER := true,让代码误判为”无 recovery 分区”,去找/boot——而 本机 fstab 里连/boot和/recovery都没声明,Find_Partition_By_Path()双双返回 NULL。 - 结果
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 构建”,这是错的。别人能跑,靠的是三点:
- 最小 manifest:OrangeFox 官方就有
twrp-default.xml(仅 36 个项目,而完整 AOSP 的default.xml是 989 个) - 浅克隆:
repo init --depth=1 - 清理 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 基本正常,但有两个功能静默失效。为定位它们,做了一次三层系统性核对:
- 上游 R12 的
data.cpp里所有SetValuevs 我们这边的 - R12 twres 独占的变量(49 个)vs C++/资源层的引用
- 上游
variables.hvs 我们缺失的宏
结果抓到三处:
| 缺失项 | 症状 | 说明 |
|---|---|---|
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 根因追查,以及一次差点让设备树只留在单盘上的归档疏漏。最大的体会没有变——这类”玄学”问题从来不是玄学,只是证据还不够细;而工程上的坑,往往出在**”我以为它已经存好了”**的地方。