补上此前没有做的验证:S27b 我称之为「端到端验证」,但核查后发现
没有任何测试真正启动过 Python。既有测试只验证了各部件(清单校验、库齐全、
载荷可读),若 Chaquopy 根本无法从模块启动,它们全都不会失败。
新测试首轮 3 项全失败——证明缺口真实存在。
真实 bug:
FileNotFoundError: .../files/chaquopy/AssetFinder/app
at Platform.onStart(Platform.java:141)
at Python.start
根因:Platform.onStart 传给 Python 的是宿主 context,而 Chaquopy 的 Python 侧
用这个 context 的 getAssets() 打开 chaquopy/*.imy。宿主已不再带 chaquopy/
(上一步排除了),所以每次查找都 miss。
根因的根因:S25 只改了 this.am(Java 侧提取代码用的 AssetManager),
没改传给 Python 的 context——两处是不同的消费路径,只覆盖了一处。
修复:Platform 新增 assetContext 字段(模块 wrapper 或宿主),onStart 改传它。
wrapper 语义正好合适:initialize 用同一个 context 做两件事——
getAssets() 读模块资产、getFilesDir() 作解压根;wrapper 只覆盖 getAssets()、
其余委托宿主。而宿主 context 仍用于解压根/preferences/旧文件清理(必须留宿主,
因模块数据目录不可写)。
决定性证据(真机 OK 3 项):
started version=3.10.19 [Clang 18.0.4]
libpython3.10.so origins=[/data/app/com.silent.runtime.python-.../lib/arm64]
spider built=com.fongmi.chaquo.Spider
三条分别证明:真实 CPython 启动、解释器库从模块映射、bridge 可用。
双向:装模块全包 91 项通过;卸载后 91 项通过,降级不崩。
教训:「端到端」不能只看部件是否齐全。S27b 覆盖了清单校验、文件齐全、
载荷可读、可用性探测——看起来全面,却没有一项真正启动解释器,
对「能否从模块启动」完全无知。真正启动一次,立刻暴露了 context 错误。
Verification: device 192.168.50.3:5555. This stage exists because the previous one overclaimed: S27b called its 6 tests 'end-to-end', but no test had ever started the interpreter, so none would have failed even if Chaquopy could not boot from a module at all. The new RuntimePythonStartDeviceTest failed all three of its tests on the first run, which is the evidence that the gap was real. Root cause of the first failure: com.chaquo.python.PyException FileNotFoundError .../files/chaquopy/AssetFinder/app raised from Platform.onStart at Python.start. Platform.onStart passed the HOST context to java.android.initialize, and Chaquopy's Python side opens chaquopy/*.imy through that context's AssetManager; the host no longer ships chaquopy/ after the previous stage's exclusion, so every lookup missed. The deeper cause is that S25 changed only this.am, the AssetManager the Java-side extraction code uses, and left the completely different context handed to Python untouched - two consumption paths, one covered. Fix: Platform gains an assetContext field (the module wrapper when a module supplies one, otherwise the host) and onStart passes it. The wrapper's semantics fit exactly, because initialize uses one context for two jobs - getAssets() to read the module's payload and getFilesDir() as the extraction root - and the wrapper overrides only getAssets() while inheriting the host's writable files directory. The bare host context stays in use for the extraction root, preferences and obsolete-file cleanup, all of which must remain on the host since the module's data directory is not writable. Decisive device evidence, RuntimePythonStartDeviceTest OK (3 tests): 'S28PY started version=3.10.19 (main, Nov 20 2025, 17:54:06) [Clang 18.0.4 ...]', 'S28PY libpython3.10.so origins=[/data/app/com.silent.runtime.python-.../lib/arm64]', 'S28PY spider built=com.fongmi.chaquo.Spider'. Those three prove, respectively, that real CPython started rather than a stub or a cached value, that the interpreter library was mapped from the module directory so the absolute-path load path really ran, and that the bridge works from source to object. Two-way result: with the module installed the whole runtime package passes 91 tests; after uninstalling it, 91 again with no failures, so the degradation path still does not crash. Lesson recorded: 'end-to-end' cannot mean 'all the parts are present' - S27b covered manifest verification, file presence, payload readability and the availability probe, which looks thorough yet was entirely ignorant of whether the interpreter could boot; starting it once exposed the context error immediately
Task-Guard: PLG1-S28
S13 门禁六步计划的第 4 步与第 6 步:构建期排除,以及「装模块可用、
卸载不崩」的双向验证。
实施:app/build.gradle 加排除,两半一起走——8 个原生库与 assets/chaquopy 载荷树。
它们来源不同,排除机制也不同:
- 8 个库(chaquo 模块的 jniLibs)→ packaging.jniLibs.excludes
- assets/chaquopy/**(assets)→ androidResources.ignoreAssetsPatterns
回滚开关 -PpythonBundle=true。
体积实测(全量重打):
- arm64: 100.6 -> 80.6 MiB(-20.05 MiB)
- v7a: 91.4 -> 75.3 MiB(-16.08 MiB)
两个 ABI 的 chaquopy/python 条目均归零。
累计:arm64 从 191.1 MiB 降到 80.6 MiB,累计减少 110.5 MiB。
发现并修复一个真实 bug(本阶段最重要的产出):
端到端测试首轮即失败,20 个载荷文件全部报「declared payload is unreadable」。
根因:AssetManager.open() 的路径是相对 assets/ 目录,不是相对 APK 根。
清单里写的是 assets/chaquopy/build.json(相对 APK 根,与其它模块一致),
直接传入就是错路径 → FileNotFoundException → 报 unreadable。
对照:兄弟方法 manifestSource() 读清单用的正是 "runtime.json"(无前缀)。
修复:RuntimeAndroid 加 assetPath() 剥掉 assets/ 前缀;非 assets/ 下的路径返回 null。
为什么单测没发现:S27a 的单测用自己的内存 reader,它拿到的是清单原始路径,
与真实 AssetManager 的语义不同。只有真机端到端能发现这类「接口语义不匹配」。
双向真机验证:新增 RuntimePythonModuleDeviceTest(6 项)
- 装模块:OK (6 tests),证据 verify ok=true problem=
- 装模块全包:OK (88 tests)
- 卸载模块(零模块态):OK (88 tests),降级不崩
排除与修复属同一单元:若只排除而不修 reader,用户装模块会得到被拒的模块,
而宿主又不带内嵌副本 → Python 完全不可用,即排除本身会造成回归。
故两项一起验证、一起提交。
Verification: full rebuilds of both ABIs plus two-way device verification on 192.168.50.3:5555. Exclusion: app/build.gradle drops the Python runtime, which needs two mechanisms because the halves come from different sources - packaging.jniLibs.excludes for the eight libraries out of the chaquo module's jniLibs, and androidResources.ignoreAssetsPatterns for the assets/chaquopy payload tree. Measured after full rebuilds: arm64 100.6 MiB -> 80.6 MiB (-20.05 MiB) and v7a 91.4 MiB -> 75.3 MiB (-16.08 MiB), with chaquopy/python entries at zero in both; cumulative for arm64 is 191.1 MiB -> 80.6 MiB, 110.5 MiB removed. The most important outcome is a real bug found by the end-to-end test on its first run: all twenty payload files reported 'declared payload is unreadable'. Root cause: AssetManager.open takes a path relative to the assets DIRECTORY, not to the APK root, so 'assets/chaquopy/build.json' as written in the manifest (relative to the APK root, consistent with every other module) produced FileNotFoundException. The sibling manifestSource() reads 'runtime.json' with no prefix for exactly this reason. Fixed by adding assetPath() in RuntimeAndroid to strip the 'assets/' prefix, returning null for paths outside assets/ because a package's AssetManager exposes nothing else and pretending otherwise would misreport the file as missing rather than unsupported. Unit tests could not have caught this: S27a's tests use their own in-memory reader, which receives the raw manifest path and therefore has different semantics from the real AssetManager - only a device end-to-end run reaches the real API. Device results: with the module installed, RuntimePythonModuleDeviceTest passes 6 tests and logs 'verify ok=true problem=', i.e. the module verifies through the production RuntimeLoader path; the whole runtime package passes 88 tests with the module installed and again 88 in the zero-module state after uninstalling, so the degradation path does not crash. hostNoLongerShipsTheRuntime is the guard that keeps this honest, since without it the other tests could pass against a bundled copy and prove nothing. Note also that the exclusion and the reader fix belong to one unit rather than two commits: excluding the bundled runtime while the reader cannot read means a user who installs the module gets a rejected module with no bundled fallback, i.e. the exclusion alone would be a regression - so the fix is a precondition for the exclusion being safe, and both were verified together
Task-Guard: PLG1-S27b
修复 S24 引入的真实接线缺口:契约层加了载荷校验缝,
但没有任何调用方提供实现。
缺口:
- RuntimeVerifier 有 PayloadReader 参数
- RuntimeLoader 从未传入(两处调用都走 5 参重载,payloads = null)
- RuntimeAndroid 未提供实现
后果:声明了载荷的模块(即 python)会被失败关闭拒掉,报
「declared payload cannot be verified without a reader」——行为符合设计
(宁可拒绝也不放行未校验载荷),但功能不可用。
为什么没被发现:既有模块全部只声明 lib/**,这条缝从未被真实路径走到;
S24 的单测直接调 RuntimeVerifier 并自己传 reader,绕过了 RuntimeLoader。
修复过程中发现的第二个设计缺陷:
PayloadReader.open(path) 的签名无法知道该读哪个包——载荷在某个已装模块的
APK 内,而 reader 只拿到一个路径。第一版实现里写了个 currentPackage()
返回 null 的占位,等于让 reader 永远不可用。
已改为 open(packageName, path),与既有 ManifestSource.read(packageName) 对齐。
实施:
- RuntimeVerifier:PayloadReader 签名加 packageName;verifyFiles/verifyPayload 透传
- RuntimeLoader:新增 6 参构造器(带 payloads);两处校验调用传入;
5 参构造器保留并委托
- RuntimeAndroid:新增 payloadReader(),用
createPackageContext(pkg).getAssets().open(path),与 manifestSource() 同一模式;
loader() 传入
- 两个测试类适配新签名
验证:新增 2 项接线测试(此前正是缺这类测试):
- passesThePayloadReaderThroughToVerification 断言两个方向(提供则通过、
不提供则被拒),所以若 reader 再次被漏传它会失败
- rejectsAPayloadWhoseDigestDoesNotMatchThroughTheLoader 确认经
RuntimeLoader 驱动时摘要校验确实生效
runtime 包 196 项单测全绿。
发现方式:在实施第 4 步(构建期排除)时检查「装模块后能否校验通过」才暴露——
即接线缺口由端到端视角发现,而非单测。与 S21 教训同类:
验证范围必须匹配改动范围。
Verification: unit tests only, no device change. Fixes a real wiring gap introduced in S24: the verifier grew a PayloadReader seam, but no caller supplied one - RuntimeLoader passed null at both verification sites and RuntimeAndroid provided no implementation - so a module declaring a payload (the new python module) was rejected fail-closed with 'declared payload cannot be verified without a reader'. That behaviour is correct by design (never accept an unverified payload) but leaves the feature unusable. It went unnoticed because every existing module declares only lib/**, so the seam was never reached through a real path, and S24's own tests call RuntimeVerifier directly with a reader they construct themselves, bypassing RuntimeLoader entirely. A second design flaw surfaced while fixing it: PayloadReader.open(path) had no way to know which package to read from, since a payload lives inside a specific installed module's APK; my first attempt stubbed a currentPackage() returning null, which would have made the reader permanently useless. The signature is now open(packageName, path), aligning with the existing ManifestSource.read(packageName) - the verifier knows which package it is checking and the reader should not have to guess. Implementation: RuntimeVerifier's PayloadReader takes the package name and verifyFiles/verifyPayload pass it through; RuntimeLoader gains a six-argument constructor taking the reader while the five-argument one is kept and delegates; RuntimeAndroid gains payloadReader() using createPackageContext(packageName).getAssets().open(path), the same pattern manifestSource() already uses, and loader() now supplies it; the two test classes were adapted. Verification: two new wiring tests, which is exactly the kind that was missing - passesThePayloadReaderThroughToVerification asserts BOTH directions (supplying the reader lets the module verify, withholding it does not), so it fails if the reader is dropped again, and rejectsAPayloadWhoseDigestDoesNotMatchThroughTheLoader confirms the digest rule is actually reached when RuntimeLoader drives verification. Runtime package: 196 tests, 0 failures. The gap was found by taking an end-to-end view during the build-exclusion step, not by unit tests - the same lesson as S21, that verification scope must match the scope of the change
Task-Guard: PLG1-S27a
S13 门禁六步计划的第 3 步:消除 Python 的急切初始化,
使模块缺失时应用能正常启动而非崩溃。S12 已证明不改会崩。
要修的问题(S12 已定位):
BaseLoader.Loader.INSTANCE(静态初始化)
-> new BaseLoader() -> new PyLoader() -> new Loader() ← 触发 Python.start
Loader 构造器调用 Python.start(Platform.create()),而 Platform 构造器会读
chaquopy/build.json 并加载 8 个库。模块缺失时这条链在类初始化期间抛异常,
后果不是「Python 功能不可用」而是配置加载整体失败——所有源类型都受影响。
PythonSubtitleScriptRuntime 有同一形状。
实施:
- RuntimePython 新增 isRuntimeAvailable(Context) 与 isBundled(Context)
- PyLoader 的 loader 改 volatile + 双检锁惰性创建;新增 isAvailable()
- PythonSubtitleScriptRuntime 同上;保留包内构造器供测试注入替身
可用性探测的设计:isBundled 探测的正是 Chaquopy 自己要打开的
chaquopy/build.json——答案不可能与运行时的实际判断不一致。
失败被限制在真正需要 Python 的那一次调用内,而非扩散到类初始化。
验证:新增 PythonRuntimeLazyInitTest 8 项全绿,分行为与结构两半:
- 行为:纯 JVM 下构造两个类不抛异常(改动前会失败);探测报不可用而非抛异常
- 结构:构造器内不得有 new Loader();创建点必须在可用性检查之后
为什么还要结构断言:行为测试对任何在测试时恰好避开该调用的实现都会通过,
而不变量在于调用所在的位置。
验证断言有牙齿(决定性检查):用 git show HEAD:<file> 取改动前源码跑同一套
结构断言逻辑,两个文件均为「构造器含 new Loader() = True」,改动后为 False——
即这两条断言在改动前确实会失败,不是恒真检查。
单测:runtime + loader + subtitle 共 287 项全绿;
全仓库 5271 项中 7 项失败,与改动前完全相同的既有失败集合(总数 +8 即本阶段新增),
无回归。
Verification: unit tests only, no device change. The problem this fixes was located in S12: BaseLoader's static initialiser creates a PyLoader, whose constructor called new Loader(), which calls Python.start - and Platform's constructor reads chaquopy/build.json and loads eight libraries. With the runtime delivered as an optional module that chain throws during class initialisation, so the failure is not 'the Python feature is unavailable' but 'configuration loading fails', affecting every source type. PythonSubtitleScriptRuntime had the same shape via this(new Loader()). Implementation: RuntimePython gains isRuntimeAvailable(Context) and isBundled(Context); the bundled probe opens the very file Chaquopy's own Platform opens, so the answer cannot disagree with what the runtime will find, and isRuntimeAvailable asks the platform for the module first rather than the host's registry. PyLoader and PythonSubtitleScriptRuntime now create the bridge lazily behind a double-checked lock and an availability check, so a failure is confined to the one call that actually needs Python instead of spreading to class initialisation; the package-private constructor that takes a Loader is kept for tests. Verification: new PythonRuntimeLazyInitTest, 8 tests, 0 failures, 0 errors, split deliberately into behavioural and structural halves. The behavioural half constructs both classes on a plain JVM where no runtime exists - which the old eager constructor could not survive - and requires the availability probes to answer false rather than throw. The structural half reads the sources to require that no new Loader() remains in a no-argument constructor and that the single creation site sits behind an availability check, because a behavioural test passes for any implementation that happens to avoid the call at test time while the invariant is about where the call sits. I checked the structural assertions actually have teeth rather than being vacuous: running the same assertion logic against git show HEAD:<file> gives 'constructor contains new Loader() = True' for both files before the change and False after, so they would have failed beforehand. Package results: runtime + api.loader + subtitle = 287 tests, 0 failures. Whole repository: 5271 tests, 7 failed - the identical pre-existing failure set as before this change (the total rose by exactly the 8 new tests), so no regression. The seven are MpvFontConfigTest, MpvHlsCacheCoordinatorTest, ReaderPlaybackRoutingSourceTest x2, TmdbUIAdapterTest, TmdbSourceOnlyInteractionTest and WebThemeTokenSourceTest, unrelated to PLG1 and reported rather than fixed per AGENTS.md section 2
Task-Guard: PLG1-S26
S13 门禁六步计划的第 2 步。
三个约束决定了设计,均已实测而非假设:
约束 1:chaquo 模块不能引用 app 的 runtime 包(:app 依赖 :chaquo,反向即循环)。
故在 chaquo 侧定义注入缝 Platform.RuntimeModule,宿主启动时安装实现,
与既有 HostLibraries / ManifestSource 同一模式。
约束 2:宿主无法写模块的数据目录。Chaquopy 的 initialize(context, ...) 用同一个
context 决定四件事(importer.py 实测):getAssets() 读 chaquopy/**、
getFilesDir() 作解压根(需可写)、getSharedPreferences() 记账、
getApplicationInfo().nativeLibraryDir(赋值后从未被读取)。
故用 ContextWrapper 只覆盖 getAssets();整体换模块 context 会让解压因权限失败。
约束 3:库的加载次序是拓扑序,不是 Chaquopy 的原序。S12 实测
crypto_chaquopy→crypto_python、ssl_chaquopy→ssl_python、
sqlite3_chaquopy→sqlite3_python、ssl_python→crypto_python,
而裸名序列把其中几个依赖方排在前面——裸名靠搜索路径兜住,
绝对路径加载则必须依赖已映射,故 manifest 必须声明拓扑序。
实施:
- chaquo/Platform.java 新增 RuntimeModule 缝;am 改从缝取;
loadNativeLibs() 优先按声明路径 System.load,无模块时回退原裸名序列
- RuntimePython.java(新增):校验模块 → 解析库路径 → createPackageContext 取
模块 assets → 安装 ContextWrapper provider
- App.java:onCreate 接入(须在触碰 com.chaquo.python 之前)
用 createPackageContext 而非隐藏 API addAssetPath——后者不可逆地改动进程级
AssetManager,模块卸载后难以恢复(S9/S11 已论证)。
纯函数 resolveLibraryPaths 使次序规则可在纯 JVM 单测,与 RuntimeSherpa.prepare 同做法。
验证:runtime 包 186 项单测全绿(含新增 RuntimePythonTest 10 项)。
全仓库 5263 项中 7 项失败,均为既有失败,与 PLG1 无关。
发现的 CI 缺口(比 DV5 更重):Python 的 8 个库与 assets/chaquopy/** 全部是构建产物
(git ls-files 确认未跟踪),由 Chaquopy Gradle 插件产出,依赖本机 Python 3.10
与 pip 网络安装 8 个依赖。插件仓库 CI 需相应改动,三方案已记录,推荐方案 A。
Verification: unit tests only, no device change: runtime package 186 tests, 0 failures, 0 errors, including RuntimePythonTest 10 tests (confirmed in the XML, not just BUILD SUCCESSFUL). Whole-repository run: 5263 tests, 7 failed, all seven pre-existing and unrelated to PLG1 (MpvFontConfigTest, MpvHlsCacheCoordinatorTest, ReaderPlaybackRoutingSourceTest x2, TmdbUIAdapterTest, TmdbSourceOnlyInteractionTest, WebThemeTokenSourceTest) - verified by comparing the failing class set against the known list, which matched exactly. Three constraints shaped the design and each was measured rather than assumed. First, the chaquo module cannot reference the host's runtime package because :app depends on :chaquo, so a reference back would be a cycle; the seam is therefore declared on the chaquo side as Platform.RuntimeModule and installed by the host at startup, matching the existing HostLibraries and ManifestSource pattern. Second, the host cannot write the module's data directory, and Chaquopy's initialize(context, ...) uses one context for four things: getAssets() to read chaquopy/**, getFilesDir() as the extraction root (must be writable), getSharedPreferences() for extraction bookkeeping, and getApplicationInfo().nativeLibraryDir (assigned but never read, per importer.py). So a ContextWrapper overrides only getAssets(); swapping the whole context would make extraction fail on permissions. Third, the library order is a topological sort rather than Chaquopy's own sequence: S12 measured that libcrypto_chaquopy depends on libcrypto_python, libssl_chaquopy on libssl_python, libsqlite3_chaquopy on libsqlite3_python and libssl_python on libcrypto_python, while the bare-name sequence loads several dependents first - bare names survive that via the namespace search path, but a module directory is not on it. The pure resolveLibraryPaths function keeps the ordering rule testable on a plain JVM, following RuntimeSherpa.prepare. createPackageContext was used rather than the hidden AssetManager.addAssetPath, which mutates the process-wide asset manager irreversibly and is hard to undo on uninstall. Also found and recorded a CI gap heavier than the DV5 one: the eight Python libraries and the whole assets/chaquopy/** tree are build products (git ls-files confirms none are tracked), produced by the Chaquopy Gradle plugin, which needs a local Python 3.10 and a networked pip install of eight requirements - so the plugins repository cannot package this module without CI changes, and three options are recorded with adding Python 3.10 plus pip to CI recommended
Task-Guard: PLG1-S25
S13 门禁六步计划的第 1 步:让契约层能校验「非原生库」载荷。
这是 Python 模块化的前置条件——Chaquopy 的主体是 assets/chaquopy/** 树,
而现有 verifyFiles 以 nativeLibraryDir 为根,覆盖不到 assets/**。
实施:
- RuntimeIntegrity 新增 sha256(InputStream)(载荷在 APK 内,不能整份读入内存)
- RuntimeManifest 新增 isNativeLibraryPath(path),按载荷根分类
- RuntimeVerifier 新增 PayloadReader 缝;verifyFiles 按前缀分派到
库校验(File 基)/载荷校验(流基)
- RuntimeCompatibility 白名单加 chaquopy-python310-v1;包名表加 python
PayloadReader 做成注入接口,使校验逻辑保持无 Android API 依赖,可在纯 JVM 单测,
与既有 HostLibraries / ManifestSource 同一模式。
关键安全决定(失败关闭):清单声明了载荷但没有可用 reader 时拒绝而非接受。
接受等于让未校验的载荷进入加载器,与「失败关闭」的目的相反;
这也意味着「不能读模块资产的宿主构建」无法被诱导加载载荷。
向后兼容:新增带 payloads 参数的重载,原有签名保留并委托,
既有 lib/** 型模块无需 reader。
验证:runtime 包 176 项单测全绿(含新增 RuntimePayloadVerificationTest 10 项,
覆盖摘要不符、载荷缺失、无 reader、reader 抛异常、纯库模块兼容、
路径穿越、绝对路径、流式摘要一致性)。
过程中的顺序依赖:首轮 5 项失败,原因是 chaquopy-python310-v1 尚未加入
契约白名单——契约检查在载荷检查之前执行,所以白名单必须先加。
这正是 S13 门禁把「契约层扩展」列为第 1 步的原因。
Verification: contract-layer change verified by unit tests: runtime package 176 tests, 0 failures, 0 errors (RuntimePayloadVerificationTest 10 tests including the new cases, RuntimeVerifierTest 15 tests unchanged). What this enables: the Python runtime's payload is a tree of assets/chaquopy/** files inside the module APK, and the existing verifyFiles rooted at nativeLibraryDir could not cover them, so a manifest declaring such a payload would have been either rejected or silently unchecked. Implementation: RuntimeIntegrity gains sha256(InputStream) because an APK-internal payload cannot be read into memory as a whole; RuntimeManifest gains isNativeLibraryPath to classify declared paths by payload root, stated once rather than inferred per call site; RuntimeVerifier gains a PayloadReader seam and splits verifyFiles into verifyNativeLibrary (File-based, unchanged behaviour) and verifyPayload (stream-based, digest-checked); RuntimeCompatibility gains the chaquopy-python310-v1 contract and the python package name. The seam keeps the verification logic free of Android APIs so it stays unit-testable on a plain JVM, matching the existing HostLibraries and ManifestSource pattern. The safety-relevant decision is fail-closed: a manifest that declares a payload while no reader is available is REJECTED, because accepting it would let an unverified payload reach the loader, which is the opposite of what this class is for; it also means a host build that cannot read module assets cannot be talked into loading one. Backward compatibility is preserved by adding overloads that take the reader while the existing signatures delegate to them, so every existing lib/** module verifies with no reader. A sequencing dependency surfaced and was fixed: the first run failed five tests because chaquopy-python310-v1 was not yet in the accepted-contract whitelist, since the contract check runs before the payload check - which is exactly why the S13 gate lists the contract-layer extension as step 1
Task-Guard: PLG1-S24
产物已就绪(插件仓库 972d606)后,实施宿主侧构建期排除,
并完成正向路径(装模块)与降级路径(未装模块)的双向验证。
实施:app/build.gradle 加排除,带回滚开关 -Pdv5Bundle=true。
S16 已实测 packaging.jniLibs.excludes 对 CMake 产物同样生效。
体积实测(全量重打 A/B 对比):
- arm64: 含 DV5 115,206,636 B -> 排除后 105,497,770 B,差值 9,708,866 B
- 差值归因:库 9,708,848 B + classes17.dex 1 B + classes20.dex 17 B
- 那 18 字节不是排除引起的:同一 flag 重复构建 dex 也会变
(115,206,645 vs 115,206,636),属既有构建非确定性
- 除该库外,两个 APK 的条目集合与其余条目大小完全一致
- 对照项全部保留,含 DV7 用的 libdovi_android.so
双向真机验证:
- 未装模块(库已排除):OK (82 tests),hostShipsLibrary=false probeAvailable=false
- 装模块:OK (4 tests),reason=missing-capability(而非 load-*,证明库成功加载)
- 零模块态(全新安装):OK (82 tests),RuntimeNoModuleDeviceTest 10 项全部执行
新增 RuntimeDv5ModuleLoadDeviceTest(4 项),断言映射来源而非加载成败——
宿主已不带副本,成功只可能来自模块,但显式检查来源能捕获
「未来重新引入内嵌副本」的回归;并断言宿主不自带副本。
修正的测试覆盖缺口(本阶段更有价值的产出):
RuntimeNoModuleDeviceTest 的模块列表硬编码且缺 dv5,而其「全新安装态」检查
遍历该列表,导致装着 dv5 模块时也会通过——一个静默失效的测试。
已补 dv5 并加注释说明该列表须与 RuntimeCompatibility.PACKAGES 同步。
Verification: build-time exclusion plus two-way device verification on 192.168.50.3:5555. A/B full rebuilds: arm64 with DV5 115,206,636 B, excluded 105,497,770 B, difference 9,708,866 B, which is the 9,708,848 B library plus 18 B of dex drift. That 18 B is not caused by the exclusion: rebuilding with the same flag twice gives 115,206,645 vs 115,206,636, so the dex output is non-deterministic on its own; apart from the library the two APKs have identical entry sets and identical sizes for every other entry. Controls confirm nothing else was caught: libavcodec.so, libc++_shared.so, libexo_ass.so, assets/lab/7zz, assets/lab/libc++_shared.so, and the DV7 library libdovi_android.so (446,240 arm64 / 336,208 v7a) all remain. Both ABIs exclude the library. Device results: with no module installed and the library excluded, the whole runtime package passes 82 tests and the probe logs hostShipsLibrary=false probeAvailable=false; with the module installed, RuntimeDv5ModuleLoadDeviceTest passes 4 tests and logs moduleInstalled=true probeAvailable=false reason=missing-capability, where missing-capability rather than load-* is the evidence that the library actually loaded (the emulator lacks Vulkan 1.2 and AImageReader, so the capability probe legitimately fails) - exactly the degradation semantics S14 established. In the zero-module fresh-install state the whole package again passes 82 tests with RuntimeNoModuleDeviceTest executing all ten assertions. The new test class asserts the mapping origin rather than mere load success, because the host no longer ships a copy so a successful load could only come from the module, but checking the origin catches a regression that reintroduced a bundled copy; it also asserts the host does not ship its own copy for the same reason. A silent test-coverage gap was found and fixed: RuntimeNoModuleDeviceTest hardcoded its module list without dv5, and its fresh-install checks iterate that list, so the class would have passed even with the dv5 module installed - a test that fails silently is worse than no test. dv5 was added to both the list and the known-id check, with a comment that the list must stay in step with RuntimeCompatibility.PACKAGES
Task-Guard: PLG1-S23
实施 DV5 渲染器模块化的宿主侧部分(契约、resolver、接入、单测),
并核实插件仓库能否产出该模块。本阶段不加构建期排除——理由见下。
已实施:
- RuntimeCompatibility:白名单加 exo-dv5-renderer-v1;包名表加 dv5
- RuntimeDv5(新增):复用 RuntimeIjk 的 resolver 模式
(install/uninstall/resolve/isAvailable)
- ExoDv5Native.loadLibrary():先问模块(System.load 绝对路径),再回退裸名
- App.onCreate:接入 RuntimeDv5.install(...)
- RuntimeDv5Test(新增):10 项单测
关键设计点(与 IJK 的差异):DV5 渲染器是可选增强而非播放引擎。
ExoDv5GpuMappingPolicy 已有多条降级路线,ExoUtil 用 catch (Throwable ignored) 包裹。
因此 resolver 在模块缺失时返回 null 而非抛异常;模块存在但加载失败时也回退——
「装了模块反而更差」不可接受。
验证:
- RuntimeDv5Test 10 项全绿(tests=10, failures=0, errors=0, skipped=0)
- 全包真机回归 OK (78 tests)
- 未装模块时 hostShipsLibrary=true probeAvailable=false——正确回退到内嵌库
本阶段不加构建期排除:dv5 模块产物尚不存在(webhtv-plugins/dist 只有 ijk/mpv/sherpa)。
若此时排除,用户会失去 DV5 能力却无处可装——正是 S16 踩过的坑。
排除必须与产物同步落地。
发现的 CI 构建缺口(重要):
- sherpa/mpv/ijk 的库都在 git 跟踪路径下(jniLibs / assets/mpv-libs),
CI 克隆 WebHTV 即可打包
- dv5 的库是 CMake 从 exo_dovi_renderer.cpp + third_party 静态库构建的产物,
仓库里没有跟踪的 .so 副本 → 现有 CI 路径不适用
已实测可行路径:CMake 可脱离 Gradle 独立构建
(配置 + 构建 + llvm-strip,21.27 → 9.14 MiB),
产物 SONAME 与 7 个 DT_NEEDED 与 Gradle 产物完全一致,
虽不逐字节相同(9.14 vs 9.26 MiB,NDK 与 strip 参数差异)但不影响正确性——
模块产物只需自身摘要与 catalog 一致。
待定方案(插件仓库侧,需单独授权):推荐在 CI 中增加 CMake 构建步骤
(静态库已跟踪,104 MB),无需入库 .so。
Verification: host-side DV5 modularisation, verified by unit tests and a full-package device run. Unit tests: RuntimeDv5Test 10 tests, 0 failures, 0 errors, 0 skipped (confirmed in the XML result, not just a BUILD SUCCESSFUL). Device: am instrument -e package com.fongmi.android.tv.runtime -> OK (78 tests) on 192.168.50.3:5555 after reinstalling the host APK, which is required because instrumentation runs in the host process. The degradation evidence from the DV5 probe reads 'S14DV5 hostShipsLibrary=true probeAvailable=false', i.e. with no module installed the resolver returns null and ExoDv5Native falls back to the bundled library exactly as before, so the change is behaviour-preserving until a module exists. Implementation: RuntimeCompatibility gains the exo-dv5-renderer-v1 contract and the dv5 -> com.silent.runtime.dv5 package mapping; RuntimeDv5 reuses the RuntimeIjk resolver pattern (install/uninstall/resolve/isAvailable) and returns null rather than throwing when no module is present; ExoDv5Native.loadLibrary tries the module by absolute path first and then falls back to System.loadLibrary, with the module path wrapped so that a present-but-unloadable module cannot make the renderer worse off than having no module; App.onCreate installs the resolver alongside the existing IJK one. The deliberate difference from IJK is that this renderer is an optional enhancement rather than a playback engine - ExoDv5GpuMappingPolicy already has fallback routes and ExoUtil wraps renderer construction in catch (Throwable ignored) - so absence must be a value the caller branches on. No build-time exclusion was added in this stage, because the dv5 module artifact does not exist yet (webhtv-plugins/dist holds only ijk, mpv and sherpa); excluding now would remove DV5 capability with nowhere for users to get it back, which is the trap S16 already hit. The exclusion must land together with the artifact. Also found and recorded a CI gap: the three existing modules take their libraries from git-tracked paths (app/src/main/jniLibs and app/src/arm64_v8a/assets/mpv-libs) so a WebHTV clone is enough to package them, but libexo_dovi_renderer.so is a CMake build product and no tracked .so copy exists in the repository. A standalone CMake build was measured as viable - configure with the NDK toolchain, build the exo_dovi_renderer target, strip with llvm-strip, taking 21.27 MiB down to 9.14 MiB - and the resulting library has the same soname and the same seven system-library DT_NEEDED entries as the Gradle product, though it is not byte-identical (9.14 vs 9.26 MiB, different NDK and strip settings), which does not matter because a module artifact only needs to match its own catalog digest. Three options are recorded for the plugins repository, with adding a CMake step to CI recommended since the 104 MB of static libraries are already tracked; that change is outside this repository and needs separate authorisation
Task-Guard: PLG1-S22
S20 改的是打包内容(移除一个资产),但当时只跑了 forcetech 专项测试——
这不构成「APK 仍正常工作」的证据。本阶段补上这个决定性检查。
全包回归(真机 arm64,18 个测试类):
OK (78 tests)
这确认 S20 的资产移除没有破坏任何东西。
过程中发现的测试隔离缺陷(我 S12 引入):
首轮全包运行报 1 个失败:
preloadingTheDependencyMakesTheDependentLoadable
expected: .../files/s1[2-python-order]
but was: .../files/s1[0-soname-lib]
不是产品回归,是测试缺陷:一个进程只能映射一个库一次,而
RuntimeNoSonameDependencyDeviceTest(S10)与 RuntimePythonLoadOrderDeviceTest(S12)
都用 libcrypto_python.so。谁先跑谁拥有该映射,后跑的那个断言必然失败。
S12 当时只单独跑方法(-e class ...#method),所以从未暴露。
修正:给三个受影响的测试补上前提守卫(assumeTrue),使「前提不成立」表现为跳过
而非假失败。用 assumeTrue 而非删除或改断言,是因为这些测试的价值在于
「单独运行时可观察到具体机制」(S10/S12 的结论正是这样得出的),
守卫保留了该能力,同时让全包运行时诚实跳过。
教训(与 S20 同类):验证的范围必须匹配改动的范围。
S20 改打包却只窄验,S12 改测试却只窄跑,两次都靠扩大验证范围才发现。
同时修正一处我自己的口径错误:此前各阶段累计写「89 项」,那是逐次运行的累加,
不是独立测试集合。runtime 包实际共 78 个测试方法,全包 OK (78 tests) 即为全部。
文档已改为该口径,不再逐阶段累加。
Verification: device 192.168.50.3:5555, whole runtime package: am instrument -e package com.fongmi.android.tv.runtime -> OK (78 tests) across all 18 test classes. This is the check S20 lacked: S20 changed what gets packaged (removed an asset) but only ran the forcetech-specific tests, which is not evidence that the APK still works. The full-package run now supplies that evidence. The first full-package run failed one test, preloadingTheDependencyMakesTheDependentLoadable in RuntimePythonLoadOrderDeviceTest, with 'expected .../files/s1[2-python-order] but was .../files/s1[0-soname-lib]'. That is a test-isolation defect I introduced in S12, not a product regression: a process maps a given library once, and RuntimeNoSonameDependencyDeviceTest (S10) and RuntimePythonLoadOrderDeviceTest (S12) both use libcrypto_python.so, so whichever runs first owns the mapping and the later one's assertion cannot hold. S12 only ever ran its methods individually via -e class ...#method, which is why this never surfaced. Fixed by adding assumeTrue preconditions to the three affected methods (preloadingTheDependencyMakesTheDependentLoadable, and the two in RuntimeNoSonameDependencyDeviceTest), so an unmet precondition shows up as a skip rather than a false failure. assumeTrue was chosen over deleting or weakening the assertions because those methods' value is that they demonstrate a specific mechanism when run alone - which is exactly how the S10 and S12 conclusions were reached - and the guard preserves that while letting a whole-package run skip honestly. The lesson, recorded in the document, is the same shape as S20's: verification must match the scope of the change. S20 changed packaging but verified narrowly; S12 changed tests but ran them narrowly. Both were caught only by widening the verification. Also corrected a reporting error of my own: previous stages accumulated '89 device verifications', but that was a sum of repeated runs, not a set of distinct tests - the runtime package actually contains 78 test methods, so the whole-package OK (78 tests) is the complete set. The document now states that instead of accumulating per stage
Task-Guard: PLG1-S21
实施 forcetech 资产移除(仅 arm64),并在实施过程中发现并修正 S17/S19 的实质性推理错误。
修正的错误:
S17 测得的事实成立(arm64 上该资产从不被裸名加载),但推理不完整——它只追到
System.loadLibrary("mitv") 加载 jni 版本这一步就停了,据此推断「assets 版本只是白拷一次,
v7a 也不需要它」,没有继续追查桩库之后的行为。
用符号表与反汇编补上这一步后,两个同名文件其实是不同用途的库:
- jni/armeabi-v7a/libmitv.so(54,548 B)= 加载器桩,导出
Java_com_gsoft_mitv_MainActivity_loadLibrary/decrypt/encrypt,含字符串
"%s/libmitv.so"、"res/raw/libmitv.so"、"com.gsoft.mitv.zip"、
getCacheDir/getPackageCodePath/System.load/unzip/zdecompress/aes_decrypt
- assets/libmitv.so(3,745,900 B)= 真正的引擎,SONAME libforcetv.so,导出
Java_com_forcetech_android_ForceTV_start/stop——正是 com.forcetech.android.ForceTV
声明的 native 方法
即桩库读 <cacheDir>/libmitv.so(checkLibrary() 刚从 assets 拷来的那份)并 System.load 它。
所以 v7a 需要该资产,arm64 因 abiFilters 过滤掉桩库而无法触达。
S17 的「v7a 也无用」是错的;所幸 S17 自己的建议就是「仅 arm64 排除」,故未导致错误实施。
过程中踩到并修复的第二个陷阱:
把 androidResources { } 写在 productFlavors.arm64_v8a 块里会全局生效——
AGP 9.2.1 中 ignoreAssetsPatterns 只声明在 com.android.build.api.dsl.AndroidResources
与 com.android.build.api.variant.AndroidResources 上,不在 ProductFlavor 上,
Groovy 闭包回退到 android 块级。首次尝试确实把 v7a 的资产也删了,靠实测两个 APK 发现。
已改用 Variant API(androidComponents.onVariants + selector().all() + 按 abi 判定)。
验证(全量重打两个 ABI):
- arm64: 113.4 -> 109.9 MiB(-3.57 MiB),assets/libmitv.so 已排除
- v7a: 97.9 MiB 不变,assets 与 jni 版均保留
真机验证(arm64)9 项全绿:
RuntimeForcetechAbiDeviceTest / RuntimeForcetechCacheCopyDeviceTest /
RuntimeForcetechExtractorAvailabilityDeviceTest 各 OK (3 tests)
三个测试类中前提被本次变更推翻的断言已按新事实修正(改为 assumeTrue 跳过而非删除),
其中 theAssetIsStillPackaged 反转为 theAssetIsExcludedFromTheArm64Build。
教训(已记入文档):追查「某文件是否被使用」时,只测到第一层加载就下结论是危险的。
判据应是「该文件的内容是否被消费」,而不是「该文件名是否出现在某次加载调用里」。
Verification: full rebuilds of both ABIs plus device runs on 192.168.50.3:5555. Implementation: app/build.gradle excludes assets/libmitv.so for the arm64 flavor only, via the variant API (androidComponents.onVariants with selector().all(), checking variant.productFlavors for abi == 'arm64_v8a'). Measured: arm64 118,951,446 B (113.4 MiB) -> 115,205,478 B (109.9 MiB), a 3,745,978 B reduction matching the 3,745,900 B asset plus its zip entry, with no assets/libmitv.so present; v7a stays at 102,603,685 B (97.9 MiB) and keeps both assets/libmitv.so and lib/armeabi-v7a/libmitv.so. Device runs, all green: RuntimeForcetechAbiDeviceTest OK (3), RuntimeForcetechCacheCopyDeviceTest OK (3), RuntimeForcetechExtractorAvailabilityDeviceTest OK (3). The important outcome is a correction, not the 3.57 MiB. S17 measured a real fact (the asset is never reached by a bare-name lookup in arm64) but reasoned incompletely: it stopped at System.loadLibrary('mitv') loading the jni copy and concluded the asset was dead weight in both builds. It never followed what the jni copy does next. Reading the dynamic symbol tables and disassembly shows the two same-named files are different libraries: jni/armeabi-v7a/libmitv.so (54,548 B) is a loader exporting Java_com_gsoft_mitv_MainActivity_loadLibrary/decrypt/encrypt, with strings '%s/libmitv.so', 'res/raw/libmitv.so', 'com.gsoft.mitv.zip', getCacheDir, getPackageCodePath, System.load, unzip, zdecompress and aes_decrypt; assets/libmitv.so (3,745,900 B) is the engine, soname libforcetv.so, exporting Java_com_forcetech_android_ForceTV_start/stop - exactly the natives com.forcetech.android.ForceTV declares. So the loader reads <cacheDir>/libmitv.so, which checkLibrary() just copied from assets, and System.loads it: v7a needs the asset, arm64 cannot reach it because abiFilters removes the loader. S17's conclusion that v7a did not need it was wrong; fortunately S17's own recommendation was already arm64-only, so no incorrect change shipped from it. A second trap was hit and fixed during this stage: writing androidResources { } inside the productFlavors.arm64_v8a block applies globally, because on AGP 9.2.1 ignoreAssetsPatterns is declared only on com.android.build.api.dsl.AndroidResources and com.android.build.api.variant.AndroidResources, not on ProductFlavor, so the Groovy closure falls through to the android-level block - the first attempt did remove the asset from v7a and was caught by measuring both APKs. Three assertions whose premise the change invalidated were updated rather than deleted: they now assumeTrue-skip when the asset is absent, since they remain the evidence for the arm64 removal and can be re-run against v7a or a build with the exclusion disabled, and theAssetIsStillPackaged was inverted to theAssetIsExcludedFromTheArm64Build so that 'absent because we removed it' stays distinguishable from 'absent for another reason'. Lesson recorded in the document: when asking whether a file is used, stopping at the first layer of loading is unsafe - loadLibrary('mitv') loads a loader, and what the loader loads is what is actually consumed, so the criterion must be whether the file's contents are consumed, not whether its name appears in a load call
Task-Guard: PLG1-S20
S17 证明 assets/libmitv.so 从不被加载后,追问一个更大的问题——
依赖它的提取器本身是否可用。
结论:arm64 构建上整个 forcetech 提取器不可用,且这暴露了一个既有缺陷。
结构性原因:forcetech AAR 的原生库只有 armeabi-v7a
(libmitv.so 54,548 / libp2p.so 787,324 / libp3p.so 824,872),
arm64 flavor 的 abiFilters arm64-v8a(app/build.gradle:74)把 jni/armeabi-v7a/
全部过滤掉,而 assets/ 不受 abiFilters 约束,所以只有那个 32 位的
assets/libmitv.so(3,745,900)进了 arm64 APK。
RuntimeForcetechExtractorAvailabilityDeviceTest(真机 OK 3 项)决定性证据:
nativeLibraryDir=/data/app/com.silent.android.webhtv-.../lib/arm64 present=[]
bareNameLookups p2p=UnsatisfiedLinkError p3p=UnsatisfiedLinkError mitv=UnsatisfiedLinkError
三个库全部缺失,三个裸名查找全部失败——而这三条查找正是 Force 的加载路径
(P2PService -> p2p、P3PService -> p3p、MainActivity -> mitv)。
两个不同性质的结论:
- 3.7 MB 资产可无损失移除(本阶段要支持的结论)
- arm64 上 forcetech 提取器已不可用(既有缺陷,独立于本阶段)
既有缺陷(只报告,不修,按 AGENTS.md §2):Source.java:42 无条件注册 new Force(),
arm64 上必然失败,用户遇到 p2p:///mitv:// 源会走失败路径而非得到「能力不可用」的
明确提示。与 S6 修好的「内核选择器无条件列出全部内核」同类,但不在本任务范围内。
对移除方案的影响:arm64 上零能力损失。v7a 的 assets 版本虽同样未被加载
(S17 已证),但未逐一实测 v7a 其他路径,故实施时仅对 arm64 排除,v7a 保持现状。
Verification: device 192.168.50.3:5555 instrumented run of RuntimeForcetechExtractorAvailabilityDeviceTest -> OK (3 tests), establishing that the whole forcetech protocol extractor is non-functional in an arm64 build, not merely that one asset is unused. Structural cause: the forcetech AAR ships its native code only under jni/armeabi-v7a/ (libmitv.so 54,548, libp2p.so 787,324, libp3p.so 824,872), and the arm64 flavor's abiFilters 'arm64-v8a' at app/build.gradle:74 removes all of it, while assets/ is not subject to abiFilters so the 32-bit assets/libmitv.so (3,745,900) is the only forcetech artifact that reaches the arm64 APK. Device evidence: 'nativeLibraryDir=/data/app/com.silent.android.webhtv-.../lib/arm64 present=[]' and 'bareNameLookups p2p=UnsatisfiedLinkError p3p=UnsatisfiedLinkError mitv=UnsatisfiedLinkError' - all three libraries are absent and all three bare-name lookups fail, and those three lookups are exactly the load path Force uses (P2PService to p2p, P3PService to p3p, MainActivity to mitv). Two consequences of different kinds are recorded separately: the 3.7 MB asset can be removed with zero capability loss in arm64, which is what the removal depends on; and the extractor is already broken there, which is a pre-existing defect independent of this work. That defect is reported rather than fixed, per AGENTS.md section 2: Source.java:42 unconditionally registers new Force(), so a p2p:// or mitv:// source takes a failure path instead of being told the capability is unavailable - the same class of registration-versus-availability mismatch that S6 fixed for the kernel selector, but outside this task's scope. For the removal itself, only the arm64 build should exclude the asset: the v7a assets copy is likewise never loaded (S17 showed loadLibrary consults only nativeLibraryDir, and the v7a jni copy is the one a lookup reaches), but v7a's other paths were not measured individually, so v7a keeps its current contents
Task-Guard: PLG1-S19
纯评估,不改任何代码。对剩余可拆项做完整量化并按「收益 ÷ 风险」排序。
精确测量(arm64 debug,全量重打 113.4 MiB):
- Python/Chaquopy 21,026,527 B(17.7%,S13 门禁已就绪,中风险)
- DV5 渲染器 9,708,848 B(8.2%,S15 门禁已就绪 + S16 已解决唯一未决点,低风险)
- thunder 6,445,320 B / jianpian 5,288,528 B(S7 已判定不可模块化)
- lut_presets 4,283,610 B
- forcetech 死重量 3,745,900 B(S17 已取证,最低风险:无需模块体系)
- tflite 3,549,376 B / quickjs 1,327,232 B / avif 899,752 B / pdf 454,695 B
三项新发现(不在原候选清单):
- libexo_dovi_renderer.so 9.26 MiB(S14 发现)
- assets/libmitv.so 3.57 MiB(S17 取证为死重量)
- assets/lab/ 2.67 MiB(是用户可见功能,4 个 Activity 已注册、LabEnv 1,419 行,
非死重量,但属播放核心之外的独立模块候选)
系统性死重量扫描(把 S17 的方法应用到整个 APK):
- assets/ 下全部 ELF 做 ABI 匹配检查,仅 assets/libmitv.so 不匹配
- assets/lab/proot/loader32 也是 ELF32/ARM,但是有意设计——
LabEnv:131 明确「loader32 只存在于 arm64 包里」,供 32 位兼容层用
- 结论:forcetech 资产是唯一的 ABI 型死重量
优先级建议(按收益 ÷ 风险):
1. forcetech 死重量移除(3.57 MiB,零架构成本——不新增契约/降级/UI)
2. DV5 渲染器模块化(9.26 MiB,门禁已就绪,无 hostLibraries、有 SONAME、
降级链已真机验证)
3. Python 模块化(20.05 MiB,收益最大但需改 Platform 资产定位、
加载次序改拓扑序、PyLoader 改惰性并加可用性门禁——S12 已证不改会崩)
明确不建议项含理由,其中 libc++_shared.so 必须留宿主(事故 commit 2a56751af0)。
两处自我纠正:累计数字原写 77.0 MB,实测差值为 81,435,675 B = 77.7 MiB;
且文档混用 MiB 与十进制 MB,已加口径说明并警示不可混用。
Verification: assessment-only stage, no code changed. Produces a quantified ranking of the remaining removable items measured from a full-rebuild arm64 debug APK (118,951,446 B = 113.4 MiB): Python/Chaquopy 21,026,527 B (17.7%, S13 gate ready, medium risk), DV5 renderer 9,708,848 B (8.2%, S15 gate ready and S16 resolved its only open risk, low risk), thunder 6,445,320 B and jianpian 5,288,528 B (both closed by S7 because vendor binaries load by bare name), lut_presets 4,283,610 B, forcetech dead weight 3,745,900 B (S17 already proven, lowest risk because it needs no module machinery at all), tflite 3,549,376 B, quickjs 1,327,232 B, avif 899,752 B, pdf 454,695 B. Three items are newly identified and were absent from the original candidate list: libexo_dovi_renderer.so (9.26 MiB, found in S14), assets/libmitv.so (3.57 MiB, proven dead in S17), and assets/lab/ (2.67 MiB, which is a live user-visible feature with four registered Activities and a 1,419-line LabEnv, so it is not dead weight but a separate module candidate outside the playback-core scope). S17's method was then applied across the whole APK: every ELF under assets/ was checked for ABI match, and only assets/libmitv.so mismatches (ELF32/ARM against a 64-bit process); assets/lab/proot/loader32 is also ELF32/ARM but is deliberate, with LabEnv:131 stating loader32 only exists in the arm64 package for the 32-bit compatibility layer. So the forcetech asset is the only ABI-type dead weight. The recommendation orders work by gain divided by risk: first the forcetech removal (3.57 MiB, zero architectural cost, no new contract, degradation path or UI), then DV5 (9.26 MiB, gate ready, no hostLibraries, has a soname, degradation chain already device-verified), then Python (20.05 MiB, largest but requires changing Platform asset location, converting the load order to a topological sort, and making PyLoader lazy with an availability gate because S12 proved an eager init would throw). Items explicitly not recommended are recorded with reasons, including libc++_shared.so which must stay in the host per the incident at commit 2a56751af0. Two self-corrections are recorded: I first wrote the cumulative figure as 77.0 MB, but the measured difference is 81,435,675 B = 77.7 MiB, and the document mixed MiB and decimal MB, so a units note now states both (200.4 to 119.0 MB, 81.4 MB decimal) and warns against mixing them
Task-Guard: PLG1-S18
重新审查 S7 划定的「协议提取器不可模块化」边界时,发现其中一项与模块化无关的
独立问题——assets/libmitv.so(3.7 MB)在 arm64 构建里根本无法加载。
S7 的结论仍然成立(厂商代码按裸名加载 → 不能模块化)。本阶段发现的是另一个问题:
该资产不是「能不能模块化」,而是「它有没有用」。
加载链(反汇编实证,非推测):
Force.java(仓库内源码)
-> Util.intent(context, scheme) 把 scheme 作为 scheme extra
-> PxPService.onBind() 取出 extra 传给 ForceTV.start(String, int)
-> ForceTV.start(String lib, int) System.loadLibrary(lib) ← 裸名,库名运行时传入
-> MainActivity.<init>()
checkLibrary() Asset.open(libmitv.so) -> Path.copy -> Path.cache()
System.loadLibrary(mitv) ← 从 nativeLibraryDir 找,不是 cacheDir
RuntimeForcetechCacheCopyDeviceTest(真机 OK 3 项)决定性证据:
bareName=mitv outcome=failed: UnsatisfiedLinkError cacheCopyMapped=false cacheCopySize=3745900
asset=libmitv.so size=3745900 elfClass=1 machine=0x28 process64=true
两条独立原因(任一条都足以使该资产无用):
- 架构不匹配:资产是 32 位 ARM,arm64 构建的进程是 64 位
- 位置不在搜索路径:loadLibrary 只查 nativeLibraryDir 与系统路径,cacheDir 不在其中
(与 S7 测得的同一机制)
ABI 对比(实测两个构建):
- arm64:lib/ 中无 mitv;assets 版本 3.7 MB(32 位)→ 无法加载
- v7a:lib/armeabi-v7a/libmitv.so(54,548 B,32 位)→ loadLibrary 命中 jni 版本,
assets 版本仍无用
两个 libmitv.so 哈希不同(e0fed3b5... vs db429e31...),assets 版本只是每次
Service 启动都白拷一次。
收益 3.7 MB——大于候选清单里的 quickjs(1.3 MB)与 tflite(3.5 MB),
且不需要模块化。与 S7 不冲突:S7 回答「能否移出由模块提供」(否),
本阶段回答「是否被使用」(否)。
因涉及裁剪第三方 AAR 内容,属兼容性变更,需 §7 设计门禁与用户批准后实施;
实施时应按 ABI 条件化(仅 arm64 排除,v7a 保持现状)。
Verification: device 192.168.50.3:5555 instrumented run of RuntimeForcetechCacheCopyDeviceTest -> OK (3 tests), establishing that assets/libmitv.so (3,745,900 B) is dead weight. The load chain was established by disassembly rather than inference: Force.java (repository source) -> Util.intent(context, scheme) puts the scheme into a 'scheme' extra -> PxPService.onBind takes that extra and calls com.forcetech.android.ForceTV.start(String lib, int port) -> System.loadLibrary(lib) with a runtime-supplied bare name -> and separately com.gsoft.mitv.MainActivity's constructor calls checkLibrary(), which does Asset.open('libmitv.so') then Path.copy into Path.cache() (= getCacheDir()), followed by System.loadLibrary('mitv'). Reproducing that exact vendor sequence on the device gives: 'bareName=mitv outcome=failed: UnsatisfiedLinkError cacheCopyMapped=false cacheCopySize=3745900' and 'asset=libmitv.so size=3745900 elfClass=1 machine=0x28 process64=true'. So the bare-name load throws UnsatisfiedLinkError and the copied file is never mapped. Two independent reasons make the asset useless: its architecture is 32-bit ARM (ELF class 1, machine 0x28) while the arm64 build runs as a 64-bit process, and a file placed in the cache directory is not on the linker's search path at all - the same mechanism measured in S7 for a module directory. An ABI comparison of both builds shows the arm64 build ships no lib/ copy of mitv while the v7a build ships lib/armeabi-v7a/libmitv.so (54,548 B); the two libmitv.so files have different digests (e0fed3b5... vs db429e31...), so the jni copy is the one a bare-name lookup can reach and the 3.7 MB asset is only ever copied and discarded. This is a different question from S7, which asked whether these libraries can be moved into a module (answer: no, vendor code loads them by bare name); this stage asks whether the asset is used at all (answer: no). The gain is 3.7 MB, larger than the quickjs (1.3 MB) and tflite (3.5 MB) candidates, and it needs no module machinery, no hostLibraries contract and no degradation design. Because trimming a third-party AAR's contents is a compatibility change, the recommendation is recorded for a section 7 design gate and user approval rather than implemented, and any implementation should be conditional on ABI so that v7a keeps its current contents
Task-Guard: PLG1-S17
实测 packaging.jniLibs.excludes 对 CMake 产出的 .so 是否生效。
这是 S15 设计门禁里唯一的未决风险——若不生效,方案需改为 CMake 目标条件化。
结论:生效。无需 CMake 条件化,S15 方案可直接实施。
实测方法(避免增量打包干扰,两次均全量重打):
- 含 DV5(-Pdv5Bundle=true):118,951,446 B = 113.4 MiB
- 排除 DV5(默认):109,242,598 B = 104.2 MiB
差值 9,708,848 B 恰好等于 libexo_dovi_renderer.so 本身,说明排除精确生效。
对照项(排除后仍在,证明未误伤):libavcodec.so、libc++_shared.so、
libdovi_android.so(第三方 AAR,DV7 用)、libexo_ass.so、assets/lab/libc++_shared.so。
过程中的自我纠正(重要):我最初把这个排除写进了默认构建,随后发现 dv5 模块
产物尚不存在(webhtv-plugins/dist 只有 ijk/mpv/sherpa)。那会让用户失去 DV5
能力却无处可装模块——是能力倒退。已完全回退 app/build.gradle(git diff 为空),
本阶段只保留实测结论,不改默认构建行为。实施时须与模块产物同步落地。
同时纠正一个体积口径:115.3 MiB 的旧基线含增量打包残留(S8 已记录此坑),
全量重打后含 DV5 的真实体积是 113.4 MiB。后续体积对比一律以全量重打为准。
Verification: build-time measurement, not a device test. Question: does packaging.jniLibs.excludes reach a .so produced by CMake rather than by the jniLibs source set? This was the single open risk in the S15 design gate, and if it did not hold the plan would have needed a conditionalised CMake target. Answer: it does hold. Two full rebuilds were used, because incremental packaging leaves stale data holes (a trap already recorded in S8): with -Pdv5Bundle=true the APK is 118,951,446 B (113.4 MiB) and contains libexo_dovi_renderer.so; with the exclude applied it is 109,242,598 B (104.2 MiB) and does not. The difference is 9,708,848 B, exactly the size of libexo_dovi_renderer.so, so the exclusion is precise rather than incidental. Controls confirm nothing else was caught: libavcodec.so (6,055,480), libc++_shared.so (1,374,336), libdovi_android.so (446,240, from the third-party exoplayerhdrutils AAR and used for DV7), libexo_ass.so (2,775,600) and assets/lab/libc++_shared.so (1,374,336) all remain. Self-correction: I first added the exclusion to the DEFAULT build, then found that no dv5 module artifact exists yet (webhtv-plugins/dist holds only ijk, mpv and sherpa), so shipping that change would have removed DV5 capability while giving users nowhere to get it back - a capability regression. app/build.gradle was therefore fully reverted (git diff is empty) and this stage records only the measurement, leaving default build behaviour untouched; implementation must land the exclusion together with the module artifact. A second correction: the old 115.3 MiB baseline contained incremental-packaging residue, so the true full-rebuild size with DV5 present is 113.4 MiB, and future size comparisons must use full rebuilds
Task-Guard: PLG1-S16
AGENTS.md 第 7 节要求的实施前门禁产物,记录为文档 16.31 节。
在获得明确批准前不实施。
内容:
- 要实现的能力:未装模块时 DV Profile 5 片源仍走既有降级路径
(CODEC_TONE_MAP / LEGACY_HDR10_FALLBACK)可播,只失去 GPU 映射路径;
装模块后行为不变。属可选增强而非核心能力
- 现有实现(具体位置):exo_dovi_renderer.cpp(1251 行,仓库内源码);
CMakeLists 静态链接 libplacebo/shaderc/libdovi;
ExoDv5Native.loadLibrary 用裸名 + catch Throwable 返回 LoadResult;
probe() 返回值而非抛异常;shouldCreate 门禁;ExoUtil:1144-1157 无条件添加标准渲染器。
引用面 40 处 / 5 文件,无跨模块泄漏
- 涉及仓库完整 commit ID:本仓库 d7e0dde8ee...、插件仓库 a00fb34
- 最佳实践证据表:本仓库 readelf 实测(只依赖系统库、有 SONAME);
S14 真机证明降级链返回值而非抛异常;S7 裸名查找看不到模块目录;
S2-d 可用性查平台的先例。并如实说明上游无对应实现(该库是 WebHTV 自研)
- 四方案对比:不改 / 拆成模块(推荐)/ 运行期下载到 filesDir(否决:绕过签名与摘要
校验,等于新增 RCE 路径,与否决复用 lab 同因)/ 改用预编译 AAR(否决:无收益且失去可重建性)
- 设计复用 RuntimeIjk 的 resolver 模式,并明确点出唯一未决风险:
该库由 CMake 产出而非 jniLibs 源集,packaging.jniLibs.excludes 是否生效
必须在实施第一步实测,备选是 CMake 目标条件化
- 区分 libdovi_android.so(446 KB,来自第三方 AAR exoplayerhdrutils-0.4.0,DV7 用)
与本模块目标,避免混淆
- 风险、与现有功能的关系、六步最小实施、所需验证、回滚路径
结尾给出「建议实施」并明确仍需用户批准。未改动任何生产代码。
Verification: documentation-only design gate for modularising libexo_dovi_renderer.so, added as section 16.31 of docs/PLG1-playback-core-plugin-architecture.md as required by AGENTS.md section 7 before implementation. It records the user-visible capability (with no module installed, Dolby Vision Profile 5 sources still play through the existing CODEC_TONE_MAP or LEGACY_HDR10_FALLBACK routes and only lose the GPU-mapping path; with the module installed behaviour is unchanged) and establishes that this is an optional enhancement rather than a core capability, since ExoDv5GpuMappingPolicy.decide already has multiple fallback routes and ExoUtil wraps the renderer creation in catch (Throwable ignored). Existing implementation is cited at concrete locations: app/src/main/cpp/exo_dovi_renderer.cpp (1251 lines, repository source), CMakeLists.txt statically linking libplacebo/shaderc/libdovi, ExoDv5Native.loadLibrary using the bare name with catch Throwable returning a LoadResult, probe() returning a value, ExoDv5GpuRendererFactory.shouldCreate gating on probe.available(), and ExoUtil.java:1144-1157 adding the standard renderers unconditionally afterwards; the reference surface is 40 uses across 5 files with no cross-module leakage. Full 40-character commit IDs are given for this repository (d7e0dde8ee) and the plugins repository (a00fb34). The evidence table grades this repository's own readelf measurement (system libraries only, soname present), the S14 device proof that the degradation chain returns a value rather than throwing, the S7 finding that bare-name lookup cannot see a module directory, and the S2-d precedent of asking the platform for availability - while stating plainly that no upstream equivalent exists because this renderer is WebHTV's own. A four-way comparison covers no change, a module (recommended), runtime download to filesDir (rejected as it bypasses signature and digest verification and would create an RCE path, the same reasoning that rejected reusing lab), and switching to a prebuilt AAR (rejected as it loses rebuildability for no gain). The design reuses the RuntimeIjk resolver pattern, and names the single open risk explicitly: the library is produced by CMake rather than the jniLibs source set, so whether packaging.jniLibs.excludes applies must be measured as the first implementation step, with conditionalising the CMake target as the fallback. It also distinguishes libdovi_android.so (446 KB, from the third-party exoplayerhdrutils-0.4.0 AAR, used for DV7) from this module's target. Risks, effects on existing features, six implementation steps, verification, and the rollback path are recorded, ending with a recommendation to implement and an explicit note that user approval is still required, so no production code was touched
Task-Guard: PLG1-S15
重新实测 APK 构成(原候选清单有遗漏),并验证新发现的最大单项能否模块化。
新发现:libexo_dovi_renderer.so = 9.26 MiB,是剩余最大单项。它不在原候选清单
§5.2 中,因为该清单按「上游对应」筛选,而此库是 WebHTV 自研。
为什么它是优质候选(三项条件均优于已完成的模块):
- 依赖:只依赖系统库(libandroid/liblog/libmediandk/libvulkan/libdl/libm/libc,
readelf 实测)→ 无需 hostLibraries 契约
- SONAME:有 → 无 S10/S12 那类无 SONAME 依赖风险
- 源码:仓库内(app/src/main/cpp/exo_dovi_renderer.cpp,1251 行)→ 可注入 resolver
- 降级:已存在且是设计意图
体积达 9 MB 是因为 CMakeLists 把 libplacebo/shaderc/libdovi 作为 IMPORTED STATIC
静态链接进来,故自包含。
RuntimeDv5DegradationDeviceTest(真机 OK 3 项):
- probe 返回结果而非抛异常
- 不可用或 null 的 probe 被 shouldCreate 拒绝
- 标准渲染器不受 DV5 渲染器影响
决定性证据:hostShipsLibrary=true probeAvailable=false
即库存在且加载成功,但 probe 报告不可用(模拟器缺 Vulkan 1.2/AImageReader),
且是返回结果而非抛异常——恰好演示了模块化后未装模块时的路径。
过程中的自我纠错:首轮 2 项失败均为测试假设错误而非产品缺陷——
断言「宿主带库则 probe 应可用」但模拟器缺 Vulkan 返回 missing-capability;
Probe 是包私有 record,反射构造需 setAccessible。已改为断言契约而非设备能力。
建议:该库应作为下一个模块化目标,优先级高于 Python——收益 9.26 MiB 且风险显著更低。
实施前仍需 §7 设计门禁与用户批准。
Verification: device 192.168.50.3:5555 instrumented run of RuntimeDv5DegradationDeviceTest -> OK (3 tests), establishing the precondition for modularising libexo_dovi_renderer.so, which a fresh APK composition measurement shows is 9.26 MiB and the largest remaining single item - it was absent from the candidate list in section 5.2 because that list was filtered by upstream correspondence and this library is WebHTV's own. Three conditions make it a stronger candidate than the completed modules: readelf shows it depends only on system libraries (libandroid, liblog, libmediandk, libvulkan, libdl, libm, libc) so no hostLibraries contract is needed; it carries a soname so the S10/S12 dependency-resolution risks do not apply; and its source is in this repository (app/src/main/cpp/exo_dovi_renderer.cpp, 1251 lines) so a resolver can be injected as with IJK. Its size comes from CMakeLists.txt linking libplacebo, shaderc and libdovi as IMPORTED STATIC. The tests verify the degradation chain rather than trusting a reading of it: probeReportsOutcomeInsteadOfThrowing (probe returns a recognised state instead of throwing), factoryReturnsNullWhenRendererIsUnavailable (shouldCreate rejects unavailable and null probes), and standardRenderersAreAddedRegardlessOfTheDv5Renderer. Decisive device evidence: 'S14DV5 hostShipsLibrary=true probeAvailable=false' - the library exists and loaded, yet the probe reports unavailable because the emulator lacks Vulkan 1.2 and AImageReader, and it returns that as a value rather than throwing, which is exactly the path an uninstalled module will take. Two first-round failures were my own test assumptions, not product defects: I asserted that a shipped library implies an available probe, but the emulator returns missing-capability; and Probe is a package-private record whose constructor needs setAccessible. Both were corrected to assert the contract rather than the device's capabilities
Task-Guard: PLG1-S14
AGENTS.md 第 7 节要求的实施前门禁产物,记录为文档 16.29 节。
在获得明确批准前不实施。
内容:
- 要实现的能力:未装模块时应用仍能启动、加载配置、播放;.py 源给明确引导
而非静默失败;装模块后行为与现在一致
- 现有实现(具体文件与符号):chaquo/Platform.java 是仓库内源码;
PyLoader:24 与 PythonSubtitleScriptRuntime:14 急切构造 Chaquopy Loader;
BaseLoader:35,165 静态急切初始化;LoginStateSync:655 已把 files/chaquopy/ 视为同步路径
- 涉及仓库完整 commit ID:本仓库 b8a3a027da...、插件仓库 a00fb34
- 最佳实践证据表:Python.Platform 官方 Javadoc;Chaquopy 上游 7b690f7e 的
importer.py initialize() 显示单个注入 context 同时决定 nativeLibraryDir、
getFilesDir()(ASSET_PREFIX 解压根)、getSharedPreferences(记账)与 AssetZipFile(读资产);
维护者 mhsmith 在 issue 637 警告不要修改 AssetFinder 路径;本仓库 S9/S10/S12 真机证据。
并如实说明:上游无「从模块 APK 加载 Chaquopy 资产」的先例,对应关系是架构对应而非照搬
- 四方案对比:不改 / 照搬上游 / 最小适配(推荐)/ 重打包 Chaquopy 二进制(否决)
- 推荐设计:契约的 loadOrder 用 S12 实测拓扑序而非原序;hostLibraries 为空;
RuntimeVerifier 需扩展(verifyFiles 现以 nativeLibraryDir 为根,覆盖不到 assets/**);
降级改动(S12 已证必须做)
- 风险、与现有功能的关系、六步最小实施、所需验证、回滚路径
结尾给出「建议实施」并明确仍需用户批准。未改动任何生产代码。
Verification: documentation-only design gate for the Python (Chaquopy) modularisation stage, added as section 16.29 of docs/PLG1-playback-core-plugin-architecture.md and required by AGENTS.md section 7 before any implementation. It records: the user-visible capability (app must still start, load config, and play with no module installed; .py sources get an explicit module prompt instead of failing silently; installing the module restores current behaviour); the existing implementation at concrete file and symbol locations (chaquo/Platform.java is repository source extending Python.Platform, PyLoader.java:24 and PythonSubtitleScriptRuntime.java:14 eagerly construct the Chaquopy Loader, BaseLoader.java:35,165 statically and eagerly initialises PyLoader, LoginStateSync.java:655 already treats files/chaquopy/ as a synced path); full 40-character commit IDs for this repository (b8a3a027da) and the plugins repository (a00fb34); a best-practice evidence table grading the official Python.Platform Javadoc, Chaquopy upstream source at 7b690f7e (importer.py initialize() shows the single injected context decides nativeLibraryDir for ctypes, getFilesDir() for the ASSET_PREFIX extraction root, getSharedPreferences for extraction bookkeeping, and AssetZipFile for reading assets), maintainer mhsmith's warning in issue 637 not to modify AssetFinder paths, and this repository's own device evidence from S9/S10/S12 - while stating plainly that no upstream precedent for loading Chaquopy assets from a module APK exists, so the correspondence is architectural rather than copied; a four-way comparison of no change, copying upstream, minimal adaptation (recommended), and repacking the Chaquopy binary (rejected); the recommended design including a contract whose loadOrder is the S12-measured topological order rather than Platform.loadNativeLibs()'s original sequence, an empty hostLibraries map, a RuntimeVerifier extension because verifyFiles currently roots at nativeLibraryDir and cannot cover assets/**, and the degradation changes that S12 proved necessary; risks, effects on existing features, six minimal implementation steps, the verification needed, and the rollback path. It ends with the recommendation to implement and an explicit statement that user approval is still required, so no production code was touched
Task-Guard: PLG1-S13
确定 Python 模块的 loadOrder 契约。chaquo/Platform.java 用裸名按固定次序加载:
crypto_chaquopy, ssl_chaquopy, sqlite3_chaquopy, python3.10, chaquopy_java
但实测依赖图与该次序不一致:crypto_chaquopy 依赖 crypto_python、
ssl_chaquopy 依赖 ssl_python、sqlite3_chaquopy 依赖 sqlite3_python、
ssl_python 依赖 crypto_python——依赖方都在被依赖方之前加载。
结论:现有裸名次序在绝对路径加载下不成立,模块契约必须声明拓扑排序的 loadOrder。
RuntimePythonLoadOrderDeviceTest(真机 OK 2 项,各自单独运行):
- 依赖未从模块目录满足
- 预加载依赖后正常
决定性设备日志:
late-load libcrypto_chaquopy.so = loaded;
libcrypto_python.so origins = [/data/app/com.silent.android.webhtv-.../lib/arm64];
hostDir = /data/app/com.silent.android.webhtv-.../lib/arm64
即加载成功了,但依赖是从宿主自己的目录解析的,不是暂存目录。现有次序之所以能
工作,纯粹因为宿主 nativeLibraryDir 在 linker 搜索路径上——而真实模块里宿主根本
不提供这些库,故该次序会失败。
断言映射来源而非加载成败:宿主自带这 8 个库的全部副本,且宿主目录在搜索路径上,
「不预加载依赖也能成功」是假成功,不能迁移到模块场景。
另一项已核实约束(影响降级设计):BaseLoader 的 Loader.INSTANCE 是静态初始化,
急切执行 new BaseLoader() -> new PyLoader() -> new Loader() -> Python.start(...),
无 try/catch,故模块缺失时配置加载会直接抛异常而非优雅降级。
Verification: device 192.168.50.3:5555 instrumented runs of RuntimePythonLoadOrderDeviceTest (each method run alone, because the first loads a library the second preloads): dependencyIsNotSatisfiedFromTheModuleDirectoryWhenLoadedLate -> OK (1 test) and preloadingTheDependencyMakesTheDependentLoadable -> OK (1 test). The decisive evidence is the device log from the first: 'late-load libcrypto_chaquopy.so = loaded; libcrypto_python.so origins = [/data/app/com.silent.android.webhtv-.../lib/arm64]; hostDir = /data/app/com.silent.android.webhtv-.../lib/arm64' - the load SUCCEEDED but the dependency resolved from the HOST's own directory, not the staged one. Chaquopy's current bare-name order (crypto_chaquopy, ssl_chaquopy, sqlite3_chaquopy, python3.10, chaquopy_java) loads crypto_chaquopy before crypto_python, ssl_chaquopy before ssl_python, sqlite3_chaquopy before sqlite3_python, and ssl_python before crypto_python, so it only works because the host nativeLibraryDir is on the linker search path - and a real module does not ship these libraries at all. Hence the module manifest must declare a topologically sorted loadOrder, matching how the mpv module already orders its nine libraries. The assertion is deliberately about the mapping ORIGIN rather than about load success, because the host ships its own copy of all eight libraries and its directory IS on the search path, so success alone would be a false positive that does not transfer to a module. Also verified and recorded a second constraint affecting the degradation design: BaseLoader's Loader.INSTANCE is a static initializer that eagerly runs new BaseLoader() -> new PyLoader() -> new Loader() -> Python.start(...) with no try/catch (BaseLoader.java:35,165), so a missing Python module would throw during config loading instead of degrading gracefully
Task-Guard: PLG1-S12
确定宿主读取模块资产的实施用机制。S9 证明了 AssetManager.addAssetPath 可行,
但它是隐藏 API,且会不可逆地改动进程级 AssetManager。本阶段比较三种机制,
为 Python 模块化实施选定设计。
结论:实施走公开 API,不需要隐藏 API。
RuntimeModuleAssetAccessDeviceTest(真机 OK 5 项):
- createPackageContext(pkg).getAssets() —— 公开 API,可读模块资产
- 直接按 zip 读模块 APK —— 纯 Java,不涉 Android API,同样可读
- 两条路径字节一致,排除「读到别的东西」
- 模块 context 不暴露宿主资产,证明读的确实是模块的
- 模块缺失时抛 NameNotFoundException,不静默回退到宿主资产
设计取舍:
- 首选 createPackageContext:公开 API、无反射、无进程级副作用,
且天然带包存在性检查
- 备选直接读 APK zip:完全不依赖 Android API,可用于契约层纯 JVM 单测
- 不再使用 addAssetPath:隐藏 API 可能被收紧,
且其进程级不可逆副作用已在 S9 造成测试顺序陷阱
至此 Python 模块化的两个技术前提全部落实且都走公开路径——资产读取(本阶段)
与无 SONAME 依赖解析(S10)。预计收益约 21 MB。
Verification: device 192.168.50.3:5555 instrumented run of RuntimeModuleAssetAccessDeviceTest -> OK (5 tests) comparing the three mechanisms for reading an installed module's assets, with the IJK module as subject because it ships assets/runtime.json which the host does not have: (1) createPackageContext(MODULE_PACKAGE).getAssets().open('runtime.json') returns non-empty content, so the PUBLIC API works and the implementation does not need the hidden AssetManager.addAssetPath that S9 used for feasibility; (2) reading the module APK as a zip via ZipFile.getEntry('assets/runtime.json') also returns non-empty content, giving a plain-Java fallback with no Android API at all; (3) bothMechanismsAgreeOnTheContent asserts the two paths yield identical bytes, ruling out either one silently reading something else; (4) moduleContextDoesNotExposeHostAssets confirms the module context cannot open the host's own lab_template.json, so the reads above really are the module's assets; (5) missingModulePackageIsReported confirms createPackageContext throws NameNotFoundException for an absent package rather than silently falling back to host assets. Design consequence recorded: prefer createPackageContext (public API, no reflection, no process-wide side effect, and a built-in existence check), keep direct zip reading as the contract-layer/JVM-testable fallback, and stop using addAssetPath because it is hidden API whose irreversible process-wide mutation already caused a test-ordering trap in S9
Task-Guard: PLG1-S11
验证「预加载无 DT_SONAME 的库能否满足后续按名依赖」——这决定 Python 运行时
(Chaquopy,约 21 MB)能否模块化。
为什么必须先做这个实验:libpython3.10.so 没有 DT_SONAME(llvm-readelf -d 确认),
而 13 个库按裸名依赖它(libchaquopy_java.so + 12 个 bootstrap-native 扩展模块)。
bionic 源码明确警告:basename 回退以 targetSdk < 23 为条件,之后「无 DT_SONAME 的库
不再能被另一个库通过 DT_NEEDED 找到」。本设备 API 28、targetSdk >= 23,
故按源码推断应当失败。
结论:实测通过——预加载确实满足后续按名依赖,Python 模块化可行。
RuntimeNoSonameDependencyDeviceTest(真机 OK 2 项):
- 对照:libcrypto_python.so(有 SONAME),预加载后按绝对路径加载依赖方,复用预加载副本
- 结论:libpython3.10.so(无 SONAME)同样复用预加载副本,且未出现来自宿主目录的第二份映射
设计使结论可归因且贴合真实布局:
1. 两库分别放在不同子目录,都不在 linker 搜索路径上——真实场景正是如此
(扩展模块解压到 filesDir/chaquopy/bootstrap-native/<abi>/,解释器在模块目录)
2. 断言被依赖库只有一个映射来源;宿主自己也带 libpython3.10.so,
若出现第二个来自宿主目录的映射,说明名字是经搜索路径解析的,结论就不成立
3. 有 SONAME 的一对作为对照,使失败可归因于 SONAME 缺失而非绝对路径加载本身
两次自我纠错(记录以免重犯):
- 首版判别测试报 origins=[],看似「库未映射」,实际是 origins=[""]——
对 /proc/self/maps 的字段切分有误,空字符串列表被打印成 []
- 修正后出现 /data/user/0 与 /data/data 比较失败:同一目录的符号链接别名,
改用 getCanonicalPath 比较
本次只验证可行性,未实施。预计收益约 21 MB。
Verification: device 192.168.50.3:5555 instrumented run of RuntimeNoSonameDependencyDeviceTest -> OK (2 tests): control preloadedLibraryWithSonameSatisfiesDependency (libcrypto_python.so has DT_SONAME; after preloading it by absolute path and then loading libssl_python.so from a DIFFERENT files-dir subdirectory, the dependency resolves to the preloaded copy and is mapped from exactly one directory) and finding preloadedLibraryWithoutSonameSatisfiesDependency (libpython3.10.so has NO DT_SONAME per llvm-readelf -d, and 13 libraries depend on it by bare name; the same experiment passes, so the preloaded copy is reused and no second mapping from the host nativeLibraryDir appears). Design makes the result attributable and faithful to the real layout: the two libraries are staged in separate directories, neither on the linker search path, mirroring a module where the interpreter lives in the module nativeLibraryDir while extension modules are extracted to filesDir/chaquopy/bootstrap-native/<abi>/; the assertion requires exactly one mapping origin so a search-path resolution via the host's own copy would be caught; and the soname-carrying pair is the control. This contradicts the inference from bionic's own comment that libraries without DT_SONAME can no longer be found by DT_NEEDED from another library once the basename fallback is gated on targetSdk < 23 - measurement wins over that inference. Two self-corrections recorded: an earlier probe reported origins=[] which was actually [""] from wrong /proc/self/maps field splitting, and /data/user/0 vs /data/data symlink aliasing required getCanonicalPath comparison
Task-Guard: PLG1-S10
验证 AssetManager.addAssetPath 能否让模块资产对宿主可见——这决定 Python
运行时(Chaquopy,20.9 MB,剩余最大可拆项)能否模块化。
结论:资产可以重定向,且 Python 具备模块化的结构条件。
RuntimeAssetPathDeviceTest(真机 OK 3 项):
- 探针有效(宿主自己的资产可读)
- 模块资产默认不可见(单独运行时)
- 决定性:addAssetPath 后模块的 assets/runtime.json 可被宿主读取;
宿主没有该资产,故成功不可能来自宿主副本
为什么 Python 与 thunder 结论不同(关键区别):
thunder/jianpian 的资产定位与原生库加载都在厂商 AAR 二进制里,不可改;
而 chaquo/Platform.java 是仓库内源码,同时控制 getPath()/extractAssets()
(资产)与 loadNativeLibs()(原生库)。因此 Python 模块化不需要 S7 那条走不通的
「预加载满足裸名查找」路径,可直接改成从模块目录加载。
测试顺序陷阱:addAssetPath 是进程级且不可撤销,同进程内先跑的决定性测试会把
模块 APK 加进去,使「默认不可见」基线失效。首轮因此报 1 个失败,单独运行则通过。
已改为 assumeTrue 跳过而非假失败,并在类注释写明本类需单独运行。
本次只验证可行性,未实施。预计收益约 20.9 MB。
Verification: device 192.168.50.3:5555 instrumented run of RuntimeAssetPathDeviceTest -> OK (3 tests): hostAssetsAreReadable (probe works), moduleAssetsAreNotVisibleByDefault (passes when run alone: addAssetPath is process-wide and irreversible, so a sibling test that adds the module APK first invalidates this baseline - now skipped via assumeTrue instead of reported as a false failure), and addAssetPathMakesModuleAssetsReadable (decisive: after AssetManager.addAssetPath(moduleApk) the module's assets/runtime.json becomes readable through the host AssetManager, and the host does not ship that asset so the success cannot come from a host copy); structural review of chaquo/src/main/java/com/fongmi/chaquo/Platform.java shows it is repository source controlling BOTH getPath()/extractAssets() (assets) and loadNativeLibs() (native libraries), which is what makes Python modularisable where thunder/jianpian are not
Task-Guard: PLG1-S9
核实并记录此前所有体积数字的口径——它们都基于 debug 构建。
发现 app/src/debug/assets/exo-ass/ 含 9.3 MB 测试样本
(sample-fonts.mkv 7.3 MB + sample-embedded.mkv 2.5 MB),只在 debug 源集,
不进正式包。main/leanback/mobile 三个源集都没有 exo-ass 目录。
口径说明:
- 我报告的所有体积都是 debug 构建(本仓库约定不打正式包)
- 相对收益依然有效——同一构建类型的前后对比
- 绝对数字是 debug 数字,比正式包大约 9.3 MB
- 当前 debug APK 115.3 MB;推算正式包约 106.0 MB
为什么值得单独记录:若不说明,读者会把 115.3 MB 当成正式包体积并据此
判断收益。相对值不受影响,但绝对值必须以 debug 为前提来读。
Verification: source-set inspection: app/src/debug/assets/exo-ass contains 9,746,246 B of test fixtures (sample-fonts.mkv 7,277,511 + sample-embedded.mkv 2,464,983) while app/src/main/assets, app/src/leanback/assets and app/src/mobile/assets contain no exo-ass directory at all, so those fixtures never ship; zip entry accounting confirms all assets/exo-ass entries total 9,746,246 B in the debug APK (120,872,956 B total), so the shipping size is roughly 111,126,710 B (106.0 MB); relative savings reported earlier (-44.6 / -9.9 / -22.5 MB) remain valid because each compares the same build type before and after
Task-Guard: PLG1-S8
验证「按绝对路径预加载能否满足后续裸名查找」——这决定协议提取器
(thunder/jianpian/forcetech,共 15.5 MB)能否模块化。
这些库由第三方 AAR 的二进制代码加载(XLLoader 调
System.loadLibrary("xl_thunder_sdk")),不像 IJK 那样是仓库内源码、可注入
resolver。宿主唯一手段是预加载模块副本,指望后续裸名请求复用它。
结论:不行。System.loadLibrary 抛 UnsatisfiedLinkError,即使该库已通过绝对
路径加载且已在 /proc/self/maps 中可见。原因是 Android 在进入 dlopen 之前先由
class loader 的 nativeLibraryDirectories 把裸名解析成路径;模块目录不在其中,
失败发生在名字解析阶段,与库是否已映射无关。
三项测试构成可归因的证据链:
- 对照:宿主目录内的 libc++_shared.so 裸名加载正常(机制没坏)
- 发现:模块库预加载后裸名查找仍失败(归因于位置)
- 反差:宿主自己控制调用时能加载模块库(RuntimeIjk 的做法)
没有第一个对照,第二个结论可能被误读成「裸名机制坏了」。
架构影响:可模块化的是宿主自己控制加载的库(mpv/sherpa/ijk,IJK 可行是因为
其 loader 是仓库内源码);thunder/jianpian/forcetech 是二进制 AAR,不改厂商
代码就无法模块化。故这 15.5 MB 必须留在 APK——这是架构边界,不是待办事项。
真机 OK (3 tests)。
Verification: device 192.168.50.3:5555 instrumented run of RuntimePreloadDedupDeviceTest -> OK (3 tests): (1) control - a library inside the host's own nativeLibraryDir loads by bare name, so the mechanism itself works; (2) finding - a module library preloaded by absolute path is STILL invisible to a later System.loadLibrary(bare) call, which throws UnsatisfiedLinkError while the preloaded copy remains mapped in /proc/self/maps, proving the failure is at name resolution (class-loader nativeLibraryDirectories) rather than at loading; (3) contrast - the host can load a module library when it controls the call, which is what RuntimeIjk does
Task-Guard: PLG1-S7
S6 只做了单测与编译验证,这里补设备级证据。
RuntimeNoModuleDeviceTest 新增 2 项(零模块状态):
- 菜单只列可用内核,数量与 availableKernels 一致;不含 MPV/IJK;每行标签非空;
可用内核恰为 {EXO, SYSTEM}(两个内置引擎仍被提供,应用不会不可用)
- availableKernelAt(1) 为系统内核而非 IJK——过滤后菜单下标与 KERNEL_ORDER
位次不再对应,映射写错会让零模块用户选到启动不了的引擎
真机 OK (10 tests)。
又一次踩到同一个坑:S6 改了宿主代码,必须重建并重装宿主(仪器化测试的类
跑在宿主进程内)。这次在跑之前就重建并确认 APK 内含 availableKernels,未再浪费一轮。
Verification: device 192.168.50.3:5555 in the fresh-install state (all three module packages uninstalled): RuntimeNoModuleDeviceTest -> OK (10 tests), including the 2 new kernel-picker assertions - the menu lists exactly the usable kernels (2: EXO and SYSTEM), contains no MPV/IJK row, every row has a non-blank label, and availableKernelAt(1) resolves to the system engine rather than IJK (proving menu indices go through the available list, not the KERNEL_ORDER rank table); host APK was rebuilt and reinstalled before the run (confirmed it contains availableKernels) since instrumented tests execute inside the host process
Task-Guard: PLG1-S6b
验证每个新用户都会遇到的初始状态——一个模块都没装。
这是现场最常见的配置,也最容易漏测:开发模块的人手上总是装着模块。它尤其
关键,因为 bundled 库已全部移除——若可用性判定或加载路径假设了模块存在,
全新安装会崩溃或提供一个无法启动的内核。
RuntimeNoModuleDeviceTest(8 项)断言负空间:
- MPV 与 IJK 均报告不可用,isPlayerAvailable 为 false(内核选择器不会提供
无法启动的引擎,回退链也不会选中)
- EXO 与系统内核仍可用(应用整体不会因缺模块而不可用)
- 模块探测不抛异常;MPVLib.isBundled 为 false(确认 bundled 库确已移除)
- IJK resolver 拒绝解析,回退 System.loadLibrary
- MPV 准备干净失败、不半加载
- 宿主不谎称缺模块已安装;不存在的模块目录不被当作已安装
- 每个已知模块 id 都有可信包名(侧载回退路径有据可依)
测试用 assumeTrue 守卫:模块已装时跳过而非失败,跳过会明确显示,不伪装成通过。
真机 OK (8 tests)。累计真机验证 49 项。
Verification: device 192.168.50.3:5555 in the FRESH-INSTALL state (all three module packages uninstalled, registry cleared): RuntimeNoModuleDeviceTest -> OK (8 tests) - MPV and IJK both report unavailable, isPlayerAvailable(MPV/IJK) false, EXO and SYSTEM remain available, module probes report absence without throwing, MPVLib.isBundled false (confirming the bundled libraries are gone), the IJK resolver declines so IjkLibLoader falls back to System.loadLibrary, MPV preparation fails cleanly without half-loading, the host does not claim absent modules are installed, every known module id has a trusted package name, and a non-existent module directory is not mistaken for an install
Task-Guard: PLG1-S5
这是本方案最后一个高风险未验证项:sherpa_onnx.native.path 能否真正重定向
SDK 的加载路径,纯路径断言无法证明。
RuntimeSherpaLoadDeviceTest 用 /proc/self/maps 取证 libsherpa-onnx-jni.so 的
映射来源,并断言映射路径属于模块包目录、且不属于宿主目录。
关键前提:测试 APK 不自带任何 .so(unzip -l 已确认),所以加载成功即可归因,
不可能是宿主内置副本。
真机 192.168.50.3:5555 -> OK (2 tests):
- 门禁判定 Source.MODULE 且配置路径 = 模块目录
- 设置属性后 SDK 未提前加载
- LibraryUtils.load() 后库已映射,路径含 com.silent.runtime.sherpa
- 路径不含 com.silent.android.webhtv
- 空注册表时回退 BUNDLED 且不设置属性
该测试必须单独运行:SDK 每进程只加载一次并缓存结果,同进程其他测试先触碰
sherpa 类会让结论依赖执行顺序。
首轮失败源于测试自身缺陷(configurer 只记录路径未设置系统属性),
已改为与生产接线完全一致。
Verification: bash ./gradlew :app:assembleMobileArm64_v8aDebugAndroidTest -> BUILD SUCCESSFUL; device run on 192.168.50.3:5555 via 'adb shell am instrument -w -e class com.fongmi.android.tv.runtime.RuntimeSherpaLoadDeviceTest' -> OK (2 tests): gate resolves Source.MODULE with path=<module nativeLibraryDir>, /proc/self/maps proves libsherpa-onnx-jni.so is mapped from com.silent.runtime.sherpa/.../lib/arm64 and NOT from com.silent.android.webhtv/, and with an empty registry the gate falls back to BUNDLED leaving sherpa_onnx.native.path unset; test APK ships no .so so the load is attributable to the module
Task-Guard: PLG1-S0E
回填 docs/C73-beta-merge-review-dev2-20261016.md 的交付坐标:合并提交
0b7ebfd087(HEAD^2 实测等于 origin/beta 12d7d559e5)、恢复标签
recovery/C73-beta-merge-review-dev2-20261016/20261016052352-0b7ebfd0872a、
推送 origin/dev2 40c0ce860e..0b7ebfd087、PR #451(OPEN / MERGEABLE /
mergedAt=null,只创建不合并),以及 PR 文件集与
git diff --name-only origin/beta HEAD 逐项一致(各 8 条)。
并在 docs/upstream-player-dependency-merge-assessment-2026-08-20.md 的稳定
任务索引中 C72 行之后补 C73 行,记录本轮合并对象、五层零复活证据与 2 项必修
的修复口径。
Verification: 纯文档提交,无生产代码/构建/测试改动;评估索引任务行 ID 集合
由 beta 的 86 变为 87,comm 比对确认 beta 行零丢失、C73 无重复。
Task-Guard: C73-coords
Verification: documentation-only closure commit recording already-verified results: backfilled delivery coordinates in docs/C73-beta-merge-review-dev2-20261016.md (merge commit 0b7ebfd087 with HEAD^2 == origin/beta verified, recovery tag, push 40c0ce860e..0b7ebfd087, PR #451 OPEN/MERGEABLE/mergedAt=null, PR file set identical to git diff --name-only origin/beta HEAD at 8 paths each) and added the C73 row to the assessment ledger index after C72; re-verified the ledger task-row ID set is beta 86 -> now 87 with zero beta rows lost (comm check) and no duplicate C73; no build, test, or production code change in this commit
Task-Guard: C73-coords
Verification: leanback arm64 debug on 127.0.0.1:5559 (1920x1080): reverted the button-focus pin from 4fab3b0360 - LabActivity.java is now byte-identical to 2812ae02e1; with the content scrolled to the bottom, UP from 取消 lands on 不走代理的域名 [453,693][1467,797] (the nearest visible control, matching the screenshot) and UP from 确定 lands on 代理端口; RIGHT still moves between the buttons; gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.lab.* + assembleLeanbackArm64_v8aDebug BUILD SUCCESSFUL, lab 31 tests 0 failures
Task-Guard: LAB-TV-REMOTE-FOCUS-20261013
Verification: leanback arm64 debug on 127.0.0.1:5559 (1920x1080): with content scrolled to the bottom, UP from 取消 lands on 根目录 and UP from 确定 (reached by RIGHT) also lands on 根目录, instead of the previous proxyNoProxy; RIGHT still moves 取消 <-> 确定, DOWN from 确定 still reaches the switches, and the dropdown/folder/rootInput chain is unchanged; gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.lab.* + assembleLeanbackArm64_v8aDebug BUILD SUCCESSFUL, lab 31 tests 0 failures
Task-Guard: LAB-TV-REMOTE-FOCUS-20261013
Verification: leanback arm64 debug on 127.0.0.1:5559 (1920x1080): local mode focus order is now 配置源 -> 文件夹 -> 根目录 -> 取消 -> 开关 (and UP reverses: 开关 -> 文件夹 -> 配置源), so the file-picker button is reachable from the source dropdown (was: DOWN skipped it and went straight to 根目录); URL mode is 配置源 -> URL 输入框 -> 根目录 -> 取消 (UP reverses); the nextFocus chain skips GONE targets so both modes stay correct; gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.lab.* + assembleLeanbackArm64_v8aDebug BUILD SUCCESSFUL, lab 30 tests 0 failures
Task-Guard: LAB-TV-REMOTE-FOCUS-20261013
Verification: leanback arm64 debug on 127.0.0.1:5559 (1920x1080): import dialog initial focus is now the 选择离线包文件 link (2dp accent stroke) instead of the multi-line paste box; link->DOWN 取消/导入, link->UP paste box, paste box->DOWN link (was: DOWN/UP/RIGHT all stuck in the paste box, only TAB escaped); command-row run button focus held at t0/t+3s/t+6.2s on 5559 too; gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.lab.* + assembleLeanbackArm64_v8aDebug BUILD SUCCESSFUL, lab 28 tests 0 failures
Task-Guard: LAB-TV-REMOTE-FOCUS-20261013
Verification: leanback arm64 debug on 127.0.0.1:5561: command-sheet initial focus is now sheetBtnEdit (run disabled for the uninstalled package) instead of sheetCommandPreview, edit button shows the 2dp focus stroke, and UP returns to the preview field; gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.lab.* + assembleLeanbackArm64_v8aDebug BUILD SUCCESSFUL, lab 27 tests 0 failures
Task-Guard: LAB-TV-REMOTE-FOCUS-20261013
Verification: compile leanback+mobile Arm64_v8aDebug(UnitTest)JavaWithJavac passed; both process UnitTestManifest passed; leanback 4227 tests 8 failures, mobile 5039 tests 7 failures, all 15 classified as Windows environment (CRLF checkout + File.renameTo) on beta-identical files; following suite 11 classes 0 failures on both flavors incl FollowingWorkStartupSourceTest 3/3; merged manifest drops WorkManagerInitializer and keeps FollowingWorkInitializer; 2 mutations turn the new locks red; baseline sha256 restored
Task-Guard: C67-beta-merge-review-dev1-20261013
Add section 9 recording the end-to-end validation on the real Android 7.1.2
(API 25) device at 192.168.50.3:5587, which confirms the audit's core findings
against real hardware rather than symbol analysis alone:
- the shipped libmpv.so cannot be dlopened on API 25 (cannot locate symbol
vkGetPhysicalDeviceQueueFamilyProperties2) while the identical command
succeeds on API 28
- the device's own libvulkan.so exports none of the six required Vulkan 1.1
entry points (193 ARM / 191 x86 exports inspected)
- control experiments separate the Vulkan dependency from environmental noise
- on API 25 the pre-fix View#setImportantForAutofill is absent while
ViewCompat.setImportantForAutofill returns early, confirming the fix
Also note the houdini translation-layer artifact (St3__1 namespace) so future
native-library triage on translated devices uses the target-architecture
linker instead of an x86 app_process probe.
Verification: real Android 7.1.2 (API 25) device verification, documented in the audit's new section 9: (1) decisive A/B - the shipped v7a libmpv.so dlopened via the device's own ARM linker fails with 'cannot locate symbol vkGetPhysicalDeviceQueueFamilyProperties2' on API 25 but loads OK on API 28 with the identical command; (2) the device's own /system/lib/arm/libvulkan.so (193 exports) and /system/lib/libvulkan.so (191 exports) export none of the 6 required Vulkan 1.1 symbols; (3) control experiments confirm the probe is valid (OK on API 28) and the failure is Vulkan-specific (a probe without the Vulkan dependency loads OK on API 25); (4) A/B on the fixed defect: on API 25 'View#setImportantForAutofill' is absent (NoSuchMethodException) while ViewCompat.setImportantForAutofill exists and returns early without touching the API-26 method; (5) leanback/armeabi-v7a debug APK built and installed via scripts/build_arm64_debug_install.sh and cold-started with 0 FATAL EXCEPTION / NoSuchMethodError. Documentation-only commit; no production code changed.
Task-Guard: ANDROID7-REALDEVICE-EVIDENCE
The header and section 5 conclusions still described a single unfixed
Java-layer defect. Update them to state that three real defects were found and
fixed, leaving the libmpv.so Vulkan 1.1 / BIND_NOW blocker as the only
remaining Android 7 gap.
Verification: documentation-only wording correction: the header conclusion and the section 5 summary now state that three real defects were found and fixed (two of which crashed on Android 7) instead of implying a single remaining Java-layer defect. The only remaining Android 7 blocker is the bundled libmpv.so Vulkan 1.1/BIND_NOW issue. No production code changed.
Task-Guard: ANDROID7-AUDIT-SUMMARY-FIX
Record the per-fix verification evidence for the ViewCompat API guard, the
LabRunner.isAlive API-1 helper, and the StringBuffer Matcher overloads, plus
the aggregate lint error reduction from 254 to 236. The remaining Android 7
blocker is unchanged: the bundled libmpv.so references Vulkan 1.1 core entry
points that the platform libvulkan.so only exports from API 28, combined with
BIND_NOW eager binding.
Verification: documentation-only closure update: the audit document now records all three fixed defects with their individual verification evidence (ViewCompat API guard for the Git cloud/Remote trust dialog crash, LabRunner.isAlive API-1 helper replacing Process#isAlive, and StringBuffer Matcher overloads replacing the API-34 StringBuilder overloads), the aggregate lint error reduction 254->236, and restates the single remaining Android 7 blocker (bundled libmpv.so requiring API-28 Vulkan 1.1 entry points with BIND_NOW eager binding). No production code changed in this commit.
Task-Guard: ANDROID7-AUDIT-DOC-CLOSURE
MpdSanitizer passed StringBuilder buffers to Matcher#appendReplacement and
Matcher#appendTail. Those overloads only exist since API 34 and no desugaring
backports java.util.regex, so on every device below API 34 the call threw
NoSuchMethodError. Source.fetch() catches Exception and silently returned the
unsanitized URL, so MPD frame-rate/AdaptationSet-id repair quietly did nothing
(instead of crashing playback).
Switch all five buffers to StringBuffer, whose overloads have existed since
API 1 and behave identically here (single-threaded local use).
Verification: MpdSanitizer: all 5 Matcher#appendReplacement and 5 appendTail call sites switched from the StringBuilder overload (API 34) to the StringBuffer overload (API 1), which is semantically identical for these single-threaded local buffers and matches the existing LabRunner.expandNested pattern; javap on the compiled class shows 0 appendReplacement(StringBuilder)/appendTail(StringBuilder) and 5+5 StringBuffer variants; :app:compileLeanbackArm64_v8aDebugJavaWithJavac + :app:compileMobileArm64_v8aDebugJavaWithJavac BUILD SUCCESSFUL; lint NewApi errors for those symbols gone and total lint errors dropped 246->236; scripts/build_arm64_debug_install.sh --flavor leanback --abi arm64-v8a built and installed over the existing package on 192.168.50.3:5555; decisive device runtime test: invoking MpdSanitizer.sanitize() on an invalid-frameRate MPD and an invalid-AdaptationSet-id MPD now returns OK with changed=true, whereas the same probe against the pre-fix APK threw java.lang.NoSuchMethodError: appendReplacement(Ljava/lang/StringBuilder;Ljava/lang/String;)
Task-Guard: ANDROID7-MPDSANITIZER-MATCHER-API34
LabRunner.isRunning/runningCount/runningKeys and three LabTerminalActivity
call sites used Process#isAlive(), which only exists since API 26. Those paths
run from LabAutoStart on app startup (and from the lab list adapters), so an
Android 7 user with an installed lab entry crashed with NoSuchMethodError.
That error is an Error rather than an Exception, so LabAutoStart's
catch (Exception ignored) could not absorb it.
Add LabRunner.isAlive(Process) built on exitValue(), which throws
IllegalThreadStateException exactly while the process is still running, and
route every call site through it. Semantics match the API-26 method and the
helper is available from API 1.
Verification: LabRunner/LabTerminalActivity: replaced all 6 Process#isAlive() (API 26) call sites with a new LabRunner.isAlive(Process) helper that uses exitValue()/IllegalThreadStateException (API 1) with identical semantics, verified by a standalone JVM probe (running process -> IllegalThreadStateException -> alive; finished process -> exitValue returns -> not alive); javap on compiled classes shows 0 remaining java/lang/Process;.isAlive invocations; :app:compileLeanbackArm64_v8aDebugJavaWithJavac and :app:compileMobileArm64_v8aDebugJavaWithJavac BUILD SUCCESSFUL; full :app:testLeanbackArm64_v8aDebugUnitTest suite passes (9197 tests, 0 failures) including the existing lab tests; lint NewApi errors for java.lang.Process#isAlive gone and total lint errors dropped 252->246; scripts/build_arm64_debug_install.sh --flavor leanback --abi arm64-v8a built and installed over the existing package on 192.168.50.3:5555, and a cold start (which runs LabAutoStart at +3s) left the process alive with zero FATAL EXCEPTION/NoSuchMethodError in logcat
Task-Guard: ANDROID7-LAB-PROCESS-ISALIVE
The flagged symbols in assets/libmitv.so are ARM compiler-runtime helpers
(__aeabi_*, _Unwind_*) resolved by the platform at runtime, and the file is
a 32-bit ARM object shipped from the third-party forcetech AAR rather than a
minimum-API problem. Record that distinction accurately.
Verification: documentation-only precision fix: corrected the assets/libmitv.so row after verifying with 'file' that the library is 32-bit ARM (EABI5) shipped inside the arm64-v8a APK from the third-party forcetech-release.aar; its undefined symbols are ARM compiler-runtime helpers provided by the platform at runtime, not an API-level issue. No production code changed.
Task-Guard: ANDROID7-API24-SUPPORT-DOC2
Append the decisive end-to-end evidence for the ViewCompat fix (Git cloud
dialog opens and renders on the dev1 device with no fatal exception) and the
complete per-symbol audit of all 54 native libraries in the shipped release
APK, which confirms libmpv.so remains the only true Android 7 blocker.
Verification: documentation-only update to docs/android7-support-audit-2026-10-11.md recording (a) the end-to-end device proof for the ViewCompat fix (Git cloud dialog opened and rendered on 192.168.50.3:5555, zero FATAL EXCEPTION for the session) and (b) the full 54-native-library re-check of the shipped Release/apk/mobile-arm64_v8a.apk, where only libmpv.so (API>=28 Vulkan 1.1 + BIND_NOW), libexo_dovi_renderer.so (API>=26, Java-guarded) and assets/libmitv.so (runtime-provided ARM EABI symbols) are flagged; no production code changed in this commit
Task-Guard: ANDROID7-API24-SUPPORT-DOC
GitCloudDialog.buildView() and RemoteTrustDialog.input() called
View#setImportantForAutofill(int) directly. TextInputEditText,
AppCompatEditText, EditText and TextView do not declare that method, so it
resolved to android.view.View#setImportantForAutofill, which only exists
since API 26. Both call sites run unconditionally while the dialog is
created, so opening the Git cloud or Remote trust settings dialog on
Android 7.0/7.1 threw NoSuchMethodError and crashed.
Route both call sites through androidx.core.view.ViewCompat, which returns
early when SDK_INT < 26 and delegates to the very same framework method on
API >= 26, leaving behavior on supported API levels unchanged.
Also add docs/android7-support-audit-2026-10-11.md with the full minimum-API
audit: declared minSdk 24, dependency floors (max 24), per-symbol native
library check, runtime-downloaded node/python components, and the remaining
Android 7 blocker (bundled libmpv.so references Vulkan 1.1 core entry
points that the platform libvulkan.so only exports from API 28, combined
with BIND_NOW eager binding).
Verification: compileLeanbackArm64_v8aDebugJavaWithJavac + compileMobileArm64_v8aDebugJavaWithJavac BUILD SUCCESSFUL; javap on compiled classes shows invokestatic androidx/core/view/ViewCompat.setImportantForAutofill (SDK_INT<26 early-return, API>=26 delegates to the identical framework call) replacing android/view/View#setImportantForAutofill (API 26); lint NewApi errors for that symbol gone (254->252 total errors); scripts/build_arm64_debug_install.sh --flavor leanback --abi arm64-v8a built and installed over the existing package on 192.168.50.3:5555; cold start via monkey LEANBACK_LAUNCHER left the process alive with no FATAL/NoSuchMethodError in logcat; audit document docs/android7-support-audit-2026-10-11.md records the full API-24 investigation incl. the libmpv.so Vulkan-1.1/BIND_NOW blocker
Task-Guard: ANDROID7-API24-SUPPORT
用户反馈 Android 9 盒子装完即闪退并反复下载 update.apk 覆盖安装。核实日志确认
targetSdk 本来就是 28;日志里的 FragmentManager$FragmentLifecycleCallbacks /
ValueAnimator$DurationScaleChangeListener CNFE 来自 me.jessyan.autosize 的可选探测,
被记录后跳过(同一镜像 leanback 包每次启动都有这 16 行且正常启动),
androidx.window.extensions 在清单中 required=false,均非崩溃原因。
真实死循环根因:Update.hasUpdate() 用 code != VERSION_CODE || !isCurrent(name) 判定,
把"不同"当成"更新"。本次 stable(v5.6.0-202609221328) 与 beta(v5.6.0-beta-202610091755)
清单 versionCode 同为 560,而 getPreferredUpdate() 优先选 stable,于是 beta 装机时被推荐
安装更旧的 stable 包,装完又反过来推荐 beta,两个渠道无限互相覆盖。
改为仅严格更新才算更新:更高 versionCode 才算更新,更低 versionCode 永不当作更新,
同 versionCode 时按发布标签尾部的构建时间戳(yyyyMMddHHmm[ss])判新旧,相同或缺少可比
时间戳时不更新(缺时间戳沿用历史语义,以免本地/非标构建失去升级能力)。
Verification: JVM focused unit tests: ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests AppVersionBuildStampTest --tests UpdateOrderingTest --tests UpdateChannelTest --tests UpdaterFutureTest => BUILD SUCCESSFUL, 21 tests / 0 failures / 0 errors / 0 skipped. Regression locked with the real channel manifest literals v5.6.0-202609221328 (stable) vs v5.6.0-beta-202610091755 (beta): an older manifest with the same versionCode is no longer reported as an update, a newer one still is. Root cause evidence: both channel manifests carry code=560 while their APKs ship different Room VERSION (45 vs 48), so the previous 'different means newer' rule made the two channels overwrite each other in a loop.
Task-Guard: C59
Verification: origin/beta=fb4fd7d99ca1547ea624f3d5f1b7f3cd81b880a5 unchanged on final fetch; merge-tree equals index tree b52c5aedfbb06824fa36f8d920a59e646e170c30; zero conflicts; net diff app/docs only; CustomCsp theme fix and regression tests; leanback/mobile focused tests; dual flavor Java and AndroidTest Java compilation; diff check; Gradle stop and clean successful
Task-Guard: beta-sync-review-dev4-20261011
Verification: docs-only: records 5557 device verification evidence - debug leanback arm64 build installed via overwrite (signature SHA-256 identical to local debug keystore, artifact hash d5953c0c matches device base.apk), A/B against the pre-change APK (73c6567a) reproduced the identical ijk-smoke.mp4 timeout/freeze so it is not a regression, sintel-trailer.mp4 plays to the end with no stuck progress node after 3 seeks, 3 rounds of repeated VIEW intents exercising setPlayer(null) keep playing with no stuck spinner, crash buffer empty. Honestly records what was NOT provable on device (IJK rebuffer readout, same-episode replay semantics) and the prefs overwrite observation. No code change; no re-run of already-green tests.
Task-Guard: SWEEP-DEVICE-EVIDENCE-5557-20261009
Verification: docs-only closure: records the four committed omission fixes (4319403502, 7809ff4952, d7b9439201, f2ccce36c9) with their recovery tags, proves the player-spinner write/clear sites are now closed (single VISIBLE/GONE write per flavor), and records with file:line evidence the deliberately-pinned contracts left unchanged (Exo-only stall watchdog from 60ad0d0969 + PlayerManagerLifecycleSourceTest, C47 seek gate clause, PlaybackOwnershipSourceTest isOwner/isEmpty). No code or build change in this session; no re-run of already-green verification.
Task-Guard: LOADING-SPINNER-OMISSION-SWEEP-20261009
手机版历史卡片信息区底色是 shape_vod_name(#33000000) 压在壁纸上,与明暗调色板无关,
但集数行(adapter_vod.xml 的 @+id/remark)在 f63b0a81bc 统一 token 时从 @color/white_70
改成了 ?attr/colorOnSurfaceVariant,日间表解析为 #44474F:压在默认(梦想紫)壁纸上实测
对比度仅 1.4:1,历史记录的集数信息几乎不可读。
改回与标题行同一套与调色板无关的角色 ?attr/webhtvColorOnWallpaper(#FFFFFF),两种明暗
表取值相同;实机实测同一底上由 1.4:1 提升到 7.42:1。HistoryAdapterTest 增加守卫,
固定集数行与标题行必须使用该角色。
Verification: unit: :app:testMobileArm64_v8aDebugUnitTest runs 4919 tests; HistoryAdapterTest(3) incl. the new episode-line foreground-role guard passes; the only 5 failures are in 4 unrelated source-scan suites (TmdbSourceOnlyInteractionTest, PlayerPlaybackRegressionSourceTest, ReaderPlaybackRoutingSourceTest, WebThemeTokenSourceTest) that read files absent from this diff, so they are pre-existing. device: mobileArm64_v8aDebug APK overwrite-installed (-r, no uninstall) on 192.168.50.3:5557 where the signature hash 4dbe7298 matches the local debug key; HistoryActivity episode line measured white #FFFFFF on the info fill over the dream-purple default wall = 7.42:1 (old ?attr/colorOnSurfaceVariant #44474F = 1.40:1; user screenshot 1.40:1), and 9.4-11.2:1 on the emulator's as-found aurora-glass wall. Emulator wall pref restored to the as-found value 10.
Task-Guard: HIST-REMARK-CONTRAST-20261008
Verification: ThemeController now resolves explicit theme_mode before system mode; ThemeDialogModeTest and ThemeControllerContractTest passed; full Leanback theme suite passed; leanback arm64 Debug built and adb install -r succeeded on 192.168.50.3:5559; temporary verification files removed
Task-Guard: TV-THEME-MODE-DARK-20261007
Verification: Baseline mobile layout instrumentation 4/4 failed with module detail width collapsing to 16px at 320dp/2x font; fixed arm64 mobile APK and AndroidTest built successfully; adb install -r coverage on emulator-5560; fixed CacheManagementDialogLayoutTest 4/4 passed across narrow portrait, 1.3x/2x Chinese font, and English landscape; real 720x1600/320dpi/1.3x UIAutomator and screenshots confirmed full-width module details, readable action rows, and complete bottom actions after scroll; emulator display settings restored; LSP diagnostics clean; diff --check clean.
Task-Guard: mobile-cache-layout
Verification: doc-only correction: net-diff figure updated from the pre-fix merge state (4 paths) to the final PR state (6 paths = 4 dev4 paths + CacheManagementDialog fix + this doc), measured by git diff --name-status origin/beta dev4; markdown clean; no code change
Task-Guard: C39-doc-closure-dev4
Verification: C37 doc-only correction: the committed dev3 net diff vs origin/beta is 11 paths (9 modified + 2 added: TmdbDetailFollowingTombstoneDeviceTest.java and this task doc), not 9; 9 was the pre-commit tracked-only count while the 2 new files were still untracked. Corrected three places in the task record. Verified: git diff --name-only origin/beta HEAD = 11, and gh pr view 406 files set is byte-identical to that list (MATCH). No code, test, or build artifact changed; no re-verification needed.
Task-Guard: C37-beta-merge-review-dev3-20261004-doc
Verification: C37: merged latest origin/beta (tip 6d152c4a24, PR#403 zero-content + PR#404 24 files) with 0 conflicts and 0 reverted-content revival (fd29d76ee 21/21 paths identical to beta, PR#353 symbols=0, net diff vs beta = 9 task-owned paths). Review round 1 found and fixed a real defect in the unpushed commit aa5345c83: TmdbDetailActivity judged 'already followed' with 'existing != null', but FollowingStore.resolveTmdb deliberately returns the tombstone row (C9 anti-revival guard), so after unfollowing the button stayed on 已追更 and re-following was impossible on inline playback; added isFollowed(item) = item != null && !item.isDeleted() at both decision sites. Evidence: FollowingUiSourceTest 15/15 both flavors; full JVM mobile 5117 tests 0 failures 0 errors, leanback 4273 tests 0 failures 0 errors; assembleMobileArm64_v8aDebug + assembleLeanbackArm64_v8aDebug BUILD SUCCESSFUL; device 192.168.50.3:5559 (overwrite install) TmdbDetailFollowingTombstoneDeviceTest + FollowingMigrationDeviceTest = 3 tests OK; guard proven non-vacuous by re-falsification on the final merged tree (pre-fix code FAILS with expected:<加入追更> but was:<已追更>, fixed code OK); check_ui_tokens.sh --strict identical on merged tree and pure origin/beta tree (violations=2, zero new); git diff --check clean
Task-Guard: C37-beta-merge-review-dev3-20261004
已追更时再次点击播放页(mobile/leanback VideoActivity 与内联播放中的
TmdbDetailActivity)的追更按钮,此前会跳转到追更列表页打断播放。现在改为
就地取消:写墓碑、取消该条 one-shot 检查、刷新按钮为“加入追更”并提示
“已取消追更”,全程停留在当前播放页。未播放的详情页保持原有
“已追更→打开追更详情”约定。
Verification: FOLLOW-PLAYBACK-CANCEL: playback-page 追更 button is now an in-place toggle. mobile/leanback VideoActivity and inline-playing TmdbDetailActivity write the tombstone, cancel the per-item one-shot check, refresh the button back to 加入追更 and toast 已取消追更 without navigating. Evidence: :app:testMobileArm64_v8aDebugUnitTest and :app:testLeanbackArm64_v8aDebugUnitTest FollowingUiSourceTest = 15 tests / 0 failures each; :app:assembleLeanbackArm64_v8aDebug and :app:assembleMobileArm64_v8aDebug BUILD SUCCESSFUL; scripts/build_arm64_debug_install.sh --flavor mobile --serial 192.168.50.3:5559 installed over the existing package; device tap on the followed show's playback page stayed on VideoActivity (mResumedActivity unchanged) with deleted_at=1791123588777/enabled=0 and the screenshot shows the 已取消追更 toast plus the button reverted to 加入追更; non-playing TmdbDetailActivity still opens FollowingActivity as documented; git diff --check clean
Task-Guard: FOLLOW-PLAYBACK-CANCEL
Verification: Targeted NodePortSelectionTest, NodeBundleInstallTest, and NodeLocalBundleTest passed via :app:testMobileArm64_v8aDebugUnitTest; mobile arm64-v8a debug APK assembled and covered-installed to 192.168.50.3:5555; device tests confirmed the supplied ghfast and catpaw sources reach 猫源已就绪 without configuration-load timeout; diff check and LSP diagnostics clean
Task-Guard: cat-source-load-timeout-20261001
Verification: docs-only: records the fourth-session decisive device evidence that closes the last outstanding acceptance item for player-button-wiring. Re-calibrated the device first — emulator-5556 had been re-installed by another worktree with a different mobile build (201,409,966 bytes; has onCodecCapabilityPanel but no player_button_hidden_seeded), so the fix build was re-installed via install -r -d and verified by md5 b1228ac8518d40eb344bab22f2db124b (matches local, same signing key 95E4B2E7). Corrects a prior misdiagnosis: the 0,0-0,0 bounds were parent-relative (bottom container origin 0,920), not an unlaid-out view; and app:id/action is GONE in window mode, so real landscape fullscreen is a prerequisite. With multiThreadProxy center (488,951) and codecCapability center (604,951): tapping 604,951 opened CodecCapabilityDialog (screenshot plus uiautomator dump showing 硬解能力 title, chip hardware qcom / board SM-N9700, four tabs, 当前媒体轨道 2/2, video/avc 854x480, decoder OMX.qcom.video.decoder.avc, 硬解查询 当前规格可硬解, 取消/复制); tapping 488,951 opened MultiThreadProxyDialog, proven by the dumpsys Added Fragments multi-thread-proxy tag count going 0 -> 4 (probe validity itself verified against the known report_fragment_tag) and confirmed by screenshot showing 多线程播放加速, 全局并行线程数 10, 全局分片数 256. No code or test change in this commit; markdown clean.
Task-Guard: player-button-wiring-verify4
Verification: docs-only: records the third-session decisive device evidence for player-button-wiring — (1) dex-level APK comparison proves emulator-5554/5558 were running pre-fix builds (player_button_hidden_seeded and onCodecCapabilityPanel absent), falsifying the prior sessions' implicit premise; (2) overwrite-install of the local mobile debug APK on emulator-5556 (same signing key 95E4B2E7, install -r -d, md5 b1228ac8518d40eb344bab22f2db124b) yields player_button_hidden=multi_thread_proxy plus seeded=true on real device, and View Hierarchy shows multiThreadProxy GONE / codecCapability VISIBLE by default and VISIBLE after the user enables it — three-state end-to-end proof for the default-hidden contract; (3) remaining §2.1 click->dialog evidence scope and reproduction recipe documented. No code or test change in this commit; markdown clean.
Task-Guard: player-button-wiring-verify3
Verification: cat source dead-proc recovery: NodeRuntime now probes :node process liveness before reuse (serviceAlive), resets stale state on death; CatSpider resolves current NodeRuntime port per request and triggers restartIfDead on connection failures. Verified: Mira battle ground truth (search 404 matches user log then node dies), --rerun-tasks compile SUCCESSFUL 1m10s, 154 unit tests pass (Node 60 / Cat* 37 / SiteApi 16, 0 failures), fixed strings confirmed in dex, debug APK streamed install to 5555, app boots no crash.
Task-Guard: catnode-dead-proc-recovery
Verification: docs-only: appends the second-session device-verification findings (double-tap pause works but dump still fails; fragment-name signal confirms the click does not reach MultiThreadProxyDialog; BACK exits fullscreen; show-bar tap is a toggle and backfires). No code or test change in this commit.
Task-Guard: player-button-wiring-verify2
Verification: docs-only: records the emulator-5556 device-verification blocker for the player-button-wiring fix (no code or test change in this commit); markdown lint clean
Task-Guard: player-button-wiring-verify
Verification: MultiThreadProxyPlayerUiSourceTest 5/5 pass on mobile flavor (incl. new multiThreadButtonIsHiddenByDefaultButStillUserConfigurable); compileMobileArm64_v8aDebugJavaWithJavac + compileLeanbackArm64_v8aDebugJavaWithJavac BUILD SUCCESSFUL; mobile targeted set 413 tests/1 failed and leanback full 4045 tests/10 failed, all reproduced on clean baseline HEAD (pre-existing CRLF multi-line source-text assertions in untouched files)
Task-Guard: player-button-wiring
Verification: Docs-only closure: recorded commit 720d2bcbd3, recovery tag recovery/C25-beta-sync-review-dev1-20260926/20260927005542-720d2bcbd3d1 and PR https://github.com/Silent1566/webhtv/pull/378; no code, build or test change in this commit
Task-Guard: C25-beta-sync-review-dev1-20260926-doc-closure
Verification: SiteAdapterSelectionTest passed; assembleLeanbackArm64_v8aDebug passed; 5557 pixel verification: selected site cell now 30129 px #381E72 fill with #FFFFFF text
Task-Guard: tv-site-dialog-contrast
Brings the plan's authoritative summary back in line with what is actually committed. The status line now records that the remaining two matrix rows were closed (playback regression in 4.27, TV remote in 4.29, with WebHome left independent by the user's decision), that the TV theme channel's wholesale failure was located and fixed in 4.28 (the baseline was inferred from uiMode instead of the palette the flavour actually compiles), and that two real defects were then fixed: semantic attrs that were declared but assigned by no theme, which crashed several dialogs during inflation (4.30), and the standalone input style inheriting a container style, which left the fields borderless with clipped text (4.31). The single next action now states the real position: scrimOpacity is wired per the approved path 1 (task THEME-SCRIM-WIRE-P1-20260926, commit 350ea614b, implementation and acceptance recorded in docs/THEME-SCRIM-OVERLAY-20260925-design.md sections 6 and 13) - both dialogs keep their own scrim hue and only let the user drive alpha, a strict no-op while unset - with one device-level pixel confirmation still outstanding (compare the two extreme scrimOpacity values on a device that can open the TMDB person dialog and show a non-zero difference in the scrim region); overlayOpacity has no semantically correct native landing site and is now labelled web-theme-only in the editor; and the remaining uncovered areas are translucent-fill matching, StateListDrawable/RippleDrawable backgrounds and remote web theme pages. The stale task id and doc reference were corrected to the actual commit and standalone document.
Task-Guard: THEME-PLAN-REFRESH-20260926
Verification: 刷新主题计划的权威状态与下一步(仅文档,无代码变更)。当前状态行补充:4.7 设备矩阵其余两行已收口(播放回归 4.27、TV 遥控 4.29,WebHome 经用户决定独立)、TV 主题通道整体失效已定位并修复(4.28,根因是基线误用 uiMode 推断而非按 flavor 实际编译的调色板)、此后修复两个真实缺陷(4.30 声明但无主题赋值的语义属性导致多个弹窗 inflate 崩溃;4.31 独立输入框误用容器样式导致无边框且文字裁切)。下一步唯一动作改为本轮真实结论:scrimOpacity 已按用户批准的路径 1 接线完成(任务 THEME-SCRIM-WIRE-P1-20260926,提交 350ea614b,实现与验收记录在 docs/THEME-SCRIM-OVERLAY-20260925-design.md 第 6/13 节;两个弹窗保留自身遮罩色相、仅由用户控制 alpha、未设置时严格 no-op),仍缺一次设备级像素确认(需在有内容源且能打开 TMDB 人物详情弹窗的设备上取两极值比对遮罩区差异>0);overlayOpacity 经查无语义正确的原生落点,已在编辑器标注仅 Web 主题生效;其余未覆盖项为半透明填充匹配策略、StateListDrawable/RippleDrawable 背景、远程 Web 主题页面。原第二项引用的任务 ID 与文档链接已校准为实际提交号与独立文档路径。
Task-Guard: THEME-PLAN-REFRESH-20260926
Documents 4.31, the record for 336c471b8 whose code and tests were already committed and verified; this commit only writes the acceptance record. It captures the root cause (the unification commit pointed these fields at Widget.WebHTV.Input, whose parent was a TextInputLayout container style, while none of the layouts contains a TextInputLayout, so nothing drew a border, and the style it replaced was the one carrying @drawable/selector_dialog_input plus the text metrics - together producing borderless fields with clipped text inside their fixed 44dp heights), the fix (Widget.WebHTV.Input is now a standalone field style drawing its own themed background and text colours, matching the bare AppCompatEditText fields already used correctly in dialog_ad_block_stats, with no new resources), the new UiStyleSourceTest guard, the device evidence from real taps on dev3 5559 for both the TMDB data configuration and AI general configuration dialogs, the style test result, and the rollback anchor.
Task-Guard: THEME-INPUT-STYLE-DOC-20260926
Verification: 文档记录 4.31(补 336c471b8 的验收记录,代码与测试已在上一提交完成并验证,本次只写文档)。内容:记录根因(0af2340d4 把输入框样式换成 Widget.WebHTV.Input,而后者父为 TextInputLayout 容器样式;这些布局无 TextInputLayout 故无边框,且被替换的旧样式自带 @drawable/selector_dialog_input 与文字样式,叠加导致固定 44dp 内文字裁切)、修复内容(Widget.WebHTV.Input 改为独立输入框样式,自带主题感知背景 ?attr/colorPrimary/colorOutline/colorSurfaceContainerHighest、textColor/textColorHint/cursor/14sp,与 dialog_ad_block_stats 既有正确做法一致,无新增资源)、守门测试 UiStyleSourceTest#standaloneInputStyleProvidesItsOwnBox、设备证据(dev3 5559 真实点击:TMDB 数据配置 与 AI 通用配置 两个弹窗输入框均恢复边框与完整文字,FATAL=0)、自动化证据(:app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.ui.style.*' BUILD SUCCESSFUL)与回滚锚点。本任务仅文档,无代码或资源变更。
Task-Guard: THEME-INPUT-STYLE-DOC-20260926
The unification commit moved the TMDB/AI input fields from Widget.WebHTV.LightDialog.Input to Widget.WebHTV.Input, whose parent was Widget.Material3.TextInputLayout.OutlinedBox - a TextInputLayout container style that draws the outline on the container and reserves space for a floating label. Two facts made that a total failure: none of those layouts contains a TextInputLayout (19 bare TextInputEditText fields across dialog_tmdb_source, dialog_ai_config, dialog_ai_prompt_config and dialog_group_rule_edit), so nothing drew a border at all; and the style it replaced was the one carrying android:background=@drawable/selector_dialog_input plus the text size, colour and cursor. The result was exactly what was reported - borderless fields whose text is squeezed and clipped inside their fixed 44dp heights. Widget.WebHTV.Input is now a standalone field style with no container parent, drawing @drawable/selector_dialog_input (whose focused and default states resolve through ?attr/colorPrimary, ?attr/colorOutline and ?attr/colorSurfaceContainerHighest) and setting ?attr/colorOnSurface text, ?attr/colorOnSurfaceVariant hints, the themed cursor drawable and 14sp - the same shape the tree already uses correctly elsewhere, such as the bare AppCompatEditText fields in dialog_ad_block_stats, with no new resources. UiStyleSourceTest#standaloneInputStyleProvidesItsOwnBox parses the style and fails if it regains a TextInputLayout parent or loses its own box and text colour. Verified on dev3 192.168.50.3:5559 by real taps: Settings > TMDB > TMDB data configuration now shows bordered fields with the full API key, language, API host and image host no longer clipped, and Settings > AI service > AI general configuration shows the same recovery - FATAL EXCEPTION stayed 0 in both, confirming a class-wide fix rather than a one-off. The style unit tests pass. Documentation for this fix lives in a separate commit because it falls outside this session's declared scope.
Task-Guard: THEME-INPUT-STYLE-REGRESSION-20260926
Verification: 修复 4.31 输入框无边框且文字裁切。根因(0af2340d4 引入):该提交把 TMDB/AI 等布局的输入框样式从 Widget.WebHTV.LightDialog.Input 换成 Widget.WebHTV.Input,而后者父样式是 Widget.Material3.TextInputLayout.OutlinedBox——这是给 TextInputLayout 容器用的样式,描边由容器绘制、内边距为浮动标签预留。但 (1) 这些布局里根本没有 TextInputLayout(dialog_tmdb_source/dialog_ai_config/dialog_ai_prompt_config/dialog_group_rule_edit 共 19 处全是裸 TextInputEditText),没有任何东西画边框;(2) 被替换掉的旧样式恰恰含 android:background=@drawable/selector_dialog_input(可见输入框外观)以及 textSize/textColor/cursor。两者叠加 = 无边框 + 文字在固定 44dp 高度里被挤压裁切,与用户截图一致。修复:Widget.WebHTV.Input 改为独立输入框样式(不再继承任何容器样式),自带 android:background=@drawable/selector_dialog_input(焦点/默认两态,颜色为 ?attr/colorPrimary / ?attr/colorOutline / ?attr/colorSurfaceContainerHighest)、android:textColor=?attr/colorOnSurface、android:textColorHint=?attr/colorOnSurfaceVariant、@drawable/shape_cursor_dialog、14sp;该写法与仓库既有正确做法一致(dialog_ad_block_stats 三套 source set 的独立输入框就是裸 AppCompatEditText + 同一 drawable),未新增资源。守门测试 UiStyleSourceTest#standaloneInputStyleProvidesItsOwnBox:解析 Widget.WebHTV.Input 定义,断言父样式不得含 TextInputLayout、必须自带 @drawable/selector_dialog_input、必须自带 android:textColor。设备验证(dev3 192.168.50.3:5559,覆盖安装,真实点击路径):设置→TMDB→「TMDB 数据配置」输入框恢复可见圆角边框,API Key 304ca56b1b7b57ca7a47d9b59946be94、语言 zh-CN、API 域名、图片域名均完整显示不再裁切,排版正常,FATAL EXCEPTION=0;设置→AI 服务→「AI 通用配置」同类输入框同样恢复边框与完整文字,FATAL=0,证明是整类修复而非单点。自动化::app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.ui.style.*' BUILD SUCCESSFUL。注:本次提交只含代码与测试;配套文档 4.31 记录因超出本会话声明范围,已另存为 /tmp/doc_431.patch 待下个文档任务提交。
Task-Guard: THEME-INPUT-STYLE-REGRESSION-20260926
Verification: QuickAdapterSelectionTest passed (2 tests, leanbackArm64_v8aDebugUnitTest); assembleLeanbackArm64_v8aDebug passed; Robolectric binding test proves current site is activated and others are not
Task-Guard: tv-site-selection-style
Fixes the reported crash: tapping TMDB data configuration under Settings > TMDB killed the app. The exception was UnsupportedOperationException: 'Failed to resolve attribute at index 3: TypedValue{t=0x2/d=0x7f040665}' thrown from TypedArray.getColorStateList inside TextView.readTextAppearance during inflation. That resource id resolves to attr webhtvColorOnSurface, and all five TextAppearance.WebHTV.* styles set android:textColor to the custom webhtvColorOnSurface / webhtvColorOnSurfaceVariant attrs, which webhtv_attrs.xml declares but no theme ever assigns - Theme.WebHTV only maps the standard Material attrs onto the same tokens. Reading such a text appearance therefore throws inside the View constructor, so every dialog using Widget.WebHTV.Label, Label.Secondary, Helper or SliderLabel crashed on open: dialog_tmdb_source, dialog_ai_config, dialog_ai_prompt_config, dialog_speed and dialog_buffer, not just the one reported. The fix points those five styles at the standard attrs the theme does assign, ?attr/colorOnSurface and ?attr/colorOnSurfaceVariant, which Theme.WebHTV maps to exactly the same token colours, so nothing changes visually while the dependency on unassigned attributes is removed. ThemeContractTest#everyReferencedSemanticAttrIsAssignedByATheme now scans all ?attr/webhtv* references across main/mobile/leanback and requires each to have a matching <item> assignment - it failed before the fix naming exactly [webhtvColorOnSurface, webhtvColorOnSurfaceVariant] and passes after - so the declarations and their assignments can never drift apart again. Verified on dev3 192.168.50.3:5559 through the user's original path: the TMDB data configuration dialog now opens and renders its title, source description, API key, language, API and image hosts and both buttons, process alive, FATAL EXCEPTION = 0, where it previously crashed every time. Theme and dialog unit suites pass, as does the leanback javac compile. The defect came from the earlier theme-integration commits and predates the scrim work, which did not touch these files.
Task-Guard: TMDB-DIALOG-INFLATE-CRASH-20260926
Verification: 修复用户报告的「设置 → TMDB → TMDB 数据配置」一按即崩。根因链(每环有证据):(1) 崩溃异常为 UnsupportedOperationException: Failed to resolve attribute at index 3: TypedValue{t=0x2/d=0x7f040665},抛于 TypedArray.getColorStateList ← TextView.readTextAppearance ← MaterialTextView.<init>,即 inflate 读 textAppearance 时崩溃;(2) 0x7f040665 在 app/build/intermediates/runtime_symbol_list/mobileArm64_v8aDebug/.../R.txt 解析为 attr webhtvColorOnSurface;(3) app/src/main/res/values/webhtv_type.xml 的 5 个 TextAppearance.WebHTV.* 把 android:textColor 指向 ?attr/webhtvColorOnSurface(Variant);(4) webhtv_attrs.xml 只声明这些属性,全仓库无任何主题赋值(grep 'name="webhtvColor' 过滤掉 format="color" 后为空),Theme.WebHTV 仅将标准 Material 属性映射到同一批 token(colorOnSurface→@color/webhtv_color_on_surface、colorOnSurfaceVariant→@color/webhtv_color_on_surface_variant)。影响面大于单点:Widget.WebHTV.Label/Label.Secondary/Helper/SliderLabel 全部受影响,dialog_tmdb_source、dialog_ai_config、dialog_ai_prompt_config、dialog_speed、dialog_buffer 等弹窗均会崩。修复:webhtv_type.xml 5 处改为主题确实赋值的标准属性 ?attr/colorOnSurface / ?attr/colorOnSurfaceVariant,二者在 Theme.WebHTV 中指向完全相同的 token 色,故视觉零变化,仅移除对无人赋值属性的依赖。守门测试:ThemeContractTest#everyReferencedSemanticAttrIsAssignedByATheme 扫描 main/mobile/leanback 三套 res 的全部 ?attr/webhtv* 引用并要求每个属性在 values/ 中找到 <item name=...> 赋值;该测试在修复前失败并精确报出 [webhtvColorOnSurface, webhtvColorOnSurfaceVariant],修复后通过。设备验证(按用户原始路径,dev3 192.168.50.3:5559,覆盖安装):设置 → TMDB → 「TMDB 数据配置」弹窗正常打开,渲染出标题、数据源说明、API Key、语言 zh-CN、API 域名、图片域名与取消/确定按钮,进程存活 pid 32224,FATAL EXCEPTION = 0;修复前该操作必崩(用户截图 09:37 与我抓取的 09:36/09:37 崩溃栈一致)。自动化::app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.theme.*' --tests 'com.fongmi.android.tv.ui.dialog.*' BUILD SUCCESSFUL;:app:compileLeanbackArm64_v8aDebugJavaWithJavac BUILD SUCCESSFUL。归属:该缺陷源自主题整合提交(webhtv_attrs.xml/webhtv_type.xml,6cdc7dd36 等),非本轮 scrim 工作引入,后者未触碰这两个文件。无数据格式、偏好键或资源替换类变更。
Task-Guard: TMDB-DIALOG-INFLATE-CRASH-20260926
Implements the approved path 1 (preserve the default look) and the agreed overlayOpacity editor label. The dialogs keep their own scrim colour - translucent white in light mode, translucent black in dark - and the user drives only its alpha, because substituting the token's colorScrim (translucent black in both modes) was measured to invert the light-mode scrim from lightening to darkening. ThemeController gains configuredScrimOpacity(boolean light), which reads the user's explicitly configured alpha and returns null when unset - the distinction from 'the value happens to equal the default' is what keeps the shipped rendering byte-identical - plus a pure applyScrimOpacity(int base, Float opacity) that returns the base untouched for null and otherwise replaces only the alpha byte. The mobile EpisodeDetailDialog and TmdbPersonDialog overlay literals now route through it, delegating to the shared maths, and the overlayOpacity label states it applies to the web theme only in all three locales. Automated evidence: theme unit tests pass with three new contracts - the dialogs must keep 0x99F4F7FA and must never reference colorScrim(), with arithmetic asserted directly (null is a strict no-op, 0f/1f map to 0x00/0xFF alpha, out-of-range clamps, 0.85f yields 0xD9F4F7FA); the accessor distinguishes unset from a value equal to the token baseline (0.322f still reports as a user value); and the three locale labels exist. Regression coverage over theme.*, ui.dialog.*, EpisodeAdapterTest, and TmdbDetailActivityLayoutTest - all of which reference the changed files - passes, as do the mobile and leanback javac compiles. Honest gap: no device screenshot was captured because both dialogs are only reachable through the TMDB detail flow and this device currently has no watch history or site configuration, and the earlier 'network blocked' reading was itself wrong (TMDB drops ICMP and returned 401 only for a keyless request; with the device's configured key search/person returns 200 with real data), so the runtime pixel effect still lacks device confirmation and is recorded as such with a follow-up procedure.
Task-Guard: THEME-SCRIM-WIRE-P1-20260926
Verification: 按路径 1(保默认外观)实现 scrimOpacity 接线 + overlayOpacity 编辑器标注。设计:不把 token 的 colorScrim 灌进弹窗,而是保留每个弹窗自己的遮罩色相,只让用户控制 alpha,从而默认外观逐字节不变。实现:ThemeController 新增 configuredScrimOpacity(boolean light)——读取 profile 中用户显式设置的 scrim alpha,未设置返回 null(关键:必须区分'未设置'与'恰好等于默认值'),并按调用方自己解析的 light/dark 选择槽集;新增纯函数 applyScrimOpacity(int base, Float opacity)——null 原样返回、否则只替换 alpha 字节保留 RGB。mobile EpisodeDetailDialog.java:289 与 TmdbPersonDialog.java:372 的 overlay 由字面量改为 applyScrimOpacity(light ? 0x99F4F7FA : <深色字面量>, light),本地 helper 委托共享函数。overlayOpacity 的 label 在三份 strings.xml 追加'仅 Web 主题生效'。自动化证据(全部通过):(1) :app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.theme.*' BUILD SUCCESSFUL,新增 3 条契约——scrimOpacityKeepsTheDialogOwnColourAndOnlyMovesAlphaWhenSet 断言两弹窗保留 0x99F4F7FA、必须不出现 colorScrim()、必须委托共享函数,并直接断言算术行为(null 严格等于 0x99F4F7FA/0xB3000000、0f→0x00F4F7FA、1f→0xFFF4F7FA、-1f/2f 被夹取、0.85f→0xD9F4F7FA);configuredScrimOpacityReportsUnsetSeparatelyFromAnyValue 用反射注入 profile,断言未设置→null、仅设 light 时 dark 仍 null、设成 0.322f(等于 token 基线)仍报用户值而非未设置;overlayOpacityIsLabelledAsWebOnly 断言三语文案。(2) 回归:同任务覆盖 theme.*、ui.dialog.*、EpisodeAdapterTest、TmdbDetailActivityLayoutTest 全部通过(这些测试直接引用被改动文件)。(3) 双 flavor :app:compileMobileArm64_v8aDebugJavaWithJavac 与 :app:compileLeanbackArm64_v8aDebugJavaWithJavac 均 BUILD SUCCESSFUL。如实记录的验证缺口:未取得设备端截图证据——两弹窗仅能从 TMDB 详情路径进入,当前设备 192.168.50.3:5559 无观看历史、无站点配置,无法在合理成本内到达;同时排除了'网络阻塞'误判(TMDB 封 ICMP 且裸请求缺 api_key 才 401,用设备内配置 key 实测 search/person 返回 200 带真实数据),故该弹窗在有内容源设备上应可打开但本次未实测。因此 scrimOpacity 目前只有单元级行为证据、缺设备级像素确认,已在文档 13.3 节明确标注并给出后续补验步骤(设两极值打开 TmdbPersonDialog,遮罩区差异应>0、分隔线应 0 差异)。设备偏好已按字节还原为基线 /tmp/sc_base.xml。
Task-Guard: THEME-SCRIM-WIRE-P1-20260926
Assessment-only follow-up, no code change. The corrected wiring path from the previous note - replacing the runtime overlay literals in mobile EpisodeDetailDialog and TmdbPersonDialog with ThemeController.current().colorScrim() - turns out to alter the default appearance and is therefore a regression that must not be applied as-is. Quantified: mobile EpisodeDetailDialog light uses 0x99F4F7FA (white, alpha 0.600) while the token baseline is 0x52000000 (black, alpha 0.322), differing in hue, brightness and strength; its dark value 0xB3000000 (alpha 0.702) differs in strength from 0x8A000000 (alpha 0.541); TmdbPersonDialog light likewise uses 0x99F4F7FA versus the black baseline; and its dark 0x8F000000 (0.561) is within 0.02 of the baseline. In other words these two dialogs use a translucent white scrim in light mode where colorScrim is translucent black, so a straight swap would turn a lightening overlay into a darkening one. Two viable paths are recorded instead: preserve the default look by keeping the literals as the default and only overriding when the user explicitly sets scrimOpacity (which requires exposing whether the slot was overridden, since colorScrim currently bakes the user value into alpha and cannot distinguish a user-set 0.322 from unset), or accept a deliberate visual unification onto the colorScrim baseline as a separately reviewed visual change. Conclusion: the real blocker for scrimOpacity is not finding a consumer but the mismatch between the current default scrim colour and the token baseline, so nothing should be implemented until a path is chosen; this task stays unimplemented. The plan's next-action line was updated to match and the document's section ordering was normalised to 10, 11, 12.
Task-Guard: THEME-SCRIM-DEFAULT-20260925
Verification: 进一步阻塞复核(仅文档,无代码变更)。第 11.3 节提出的修正路径——把 mobile EpisodeDetailDialog 与 TmdbPersonDialog 的运行时 overlay 字面量换成 ThemeController.current().colorScrim()——经色值量化后确认会改动默认外观,属回归,不能直接实施。逐项对照:mobile EpisodeDetailDialog 浅色现有 0x99F4F7FA(白,alpha 0x99=0.600)对 token 基线 0x52000000(黑,alpha 0x52=0.322),色相/明暗/强度全不同;深色现有 0xB3000000(alpha 0.702)对基线 0x8A000000(alpha 0.541),强度不同;TmdbPersonDialog 浅色现有 0x99F4F7FA 对 0x52000000,同样全不同;深色现有 0x8F000000(0.561)对 0x8A000000(0.541),仅差 0.02。即浅色下这两个弹窗用的是半透明白遮罩,而 colorScrim 基线是半透明黑,直接替换会把浅色弹窗由提亮变压暗。据此记录两条可选路径:路径1保默认外观——保留字面量作默认,仅在用户显式设置 scrimOpacity 时才覆盖,代价是需把'用户是否覆盖'暴露到 ThemeTokens(当前 colorScrim 已把用户值烘进 alpha,无法区分'用户设为 0.322'与'未设置');路径2接受外观统一——把默认遮罩统一到 colorScrim 基线,属有意的视觉变更,须独立评审。结论:scrimOpacity 接线的真正阻塞不是找消费点,而是现有默认遮罩色与 token 基线不一致;在选定路径前不应实施,本任务保持未实施状态。已同步修正主文档下一步描述。文档节序整理为 10→11→12,无重复编号。本任务未运行构建或设备测试(纯文档)。
Task-Guard: THEME-SCRIM-DEFAULT-20260925
Assessment correction, no code change: while implementing, verification of where each dialog actually obtains its scrim colour disproved the premise the plan rested on, so the implementation was aborted and every code edit reverted (ThemeRole.java, ThemeBinder.java and the two layouts restored; SCRIM hits 0, webhtv:scrim hits 0, worktree otherwise clean). The three full-screen dialogs do declare android:background=@color/webhtv_color_scrim in XML, but all of them overwrite it at runtime with hardcoded literals: mobile EpisodeDetailDialog.java:290,297 sets root.setBackgroundColor(light ? 0x99F4F7FA : 0xB3000000), TmdbPersonDialog.java:372,383 sets view.setBackgroundColor(light ? 0x99F4F7FA : 0x8F000000), leanback EpisodeDetailDialog.java:316,322 sets root.setBackgroundColor(light ? 0xFFFFFFFF : 0xFF101214) which is an opaque page colour rather than a scrim, and TmdbVideoPlayerDialog.java:322 sets container.setBackgroundColor(Color.BLACK) in fullscreen. Those XML declarations are therefore dead code at runtime, and the only live webhtv_color_scrim uses are not scrims at all (two 1dp dividers plus an 88dp row background per source set in dialog_ad_block_stats, and a poster placeholder in dialog_receive), so no live scrim consumer driven by that token exists today. Consequently the original design - add ThemeRole.SCRIM, teach ThemeBinder about ColorDrawable, tag the layouts - would not have had any effect even if implemented. The corrected wiring is the four-line change that assigns the colour in the first place: replace the two runtime overlay literals with ThemeController.current().colorScrim(), which already carries the user's scrimOpacity because ThemeResolver writes withAlpha(base.colorScrim(), scrimOpacity). That touches two Java files outside this session's declared scope, so it is left for an approved follow-up. Recorded the falsification, the corrected path, the now-unnecessary binder work (cost drops from medium to low) and the lesson - evaluating a token's landing site must verify the colour's final effective source, not just its XML declaration - in the design document, and updated the plan's next-action line accordingly.
Task-Guard: THEME-SCRIM-WIRING-20260925
Verification: 实施前复核证伪设计前提,实施中止并全部回退,仅保留文档修正(无代码变更,工作树除两份文档外干净)。关键事实:设计文档第 3 节认定'三个全屏弹窗的 XML scrim 背景是活的遮罩、可直接接线',逐文件核对运行时取色后证伪——(1) mobile EpisodeDetailDialog.java:290,297 运行时 root.setBackgroundColor(overlay),overlay=light?0x99F4F7FA:0xB3000000 硬编码;(2) TmdbPersonDialog.java:372,383 运行时 view.setBackgroundColor(overlay),overlay=light?0x99F4F7FA:0x8F000000 硬编码;(3) leanback EpisodeDetailDialog.java:316,322 运行时 root.setBackgroundColor(background),background=light?0xFFFFFFFF:0xFF101214,是不透明全屏底色而非遮罩;(4) TmdbVideoPlayerDialog.java:322 全屏时 container.setBackgroundColor(Color.BLACK)。即 webhtv_color_scrim 在这四处 XML 声明均为运行时被覆盖的死代码。剩余'活着'的 webhtv_color_scrim 用法全部不是遮罩:dialog_ad_block_stats 三个 source set 各 2 处 1dp 分隔线 + 1 处 88dp 行背景,dialog_receive 为海报占位底。因此当前代码不存在任何由 webhtv_color_scrim 驱动的活遮罩消费点,原方案(新增 ThemeRole.SCRIM + ThemeBinder 支持 ColorDrawable + 布局打 tag)即使实施也不会生效。已回退本轮全部无效代码改动:git checkout 还原 ThemeRole.java、ThemeBinder.java、dialog_episode_detail.xml、dialog_tmdb_person.xml,复查 SCRIM 命中=0、webhtv:scrim 命中=0,git status 仅剩两份文档改动。文档修正:新增第 11 节记录证伪、正确接线点(把两处运行时硬编码 overlay 字面量改为 ThemeController.current().colorScrim(),因 ThemeTokens.colorScrim() 已含用户 scrimOpacity)与教训(评估 token 落点必须验证最终生效来源而非只看 XML);第 6 节建议改为指向该正确路径;第 7 节旧步骤标记为已废弃仅留追溯;第 5 节方案 B 描述同步更新(binder 契约扩展经复核不需要,工作量由中降为低);主文档下一步描述同步修正。正确路径涉及两份新的 Java 文件(mobile EpisodeDetailDialog.java、TmdbPersonDialog.java),超出本会话声明的路径范围,故不在本会话实施,留待用户批准新范围。
Task-Guard: THEME-SCRIM-WIRING-20260925
Assessment-only: no code, resource, lock or runtime change. Produced docs/THEME-SCRIM-OVERLAY-20260925-design.md and registered the task plus next action in the theme plan. Every conclusion is traceable: ThemeRole has no SCRIM/OVERLAY role (0 hits), so neither slot has a native consumer and both editor sliders are dead controls. Their semantics differ from dialogOpacity - scrimOpacity and overlayOpacity are absolute alphas (withAlpha(base.colorScrim(), ...) / withAlpha(base.colorOverlayLight(), ...)) whose bases are 0x52000000 (light) and 0x8A000000 (dark/TV) for scrim, while dialogOpacity is a scale with a 1.0 baseline, so applyShellOpacity's opacity>=1 no-op rule must not be reused. The binder handles no ColorDrawable (0 hits) and even the explicit-tag path still requires baseline==current. scrimOpacity does have correct existing consumers - the full-screen overlay backgrounds of dialog_episode_detail, dialog_tmdb_person and dialog_tmdb_video_player - but webhtv_color_scrim is also used for 1dp dividers and an 88dp row in dialog_ad_block_stats (3 places per source set) plus a poster placeholder in dialog_receive, so a global swap is wrong and the wiring must be scoped per view. overlayOpacity's semantics do not hold: overlay_light's only use is a 1dp divider in dialog_quick_search while the real image veils use overlay_dark, which 4.23 froze. Official evidence confirms the intent - Material's mtrl_scrim_color is exactly #52000000, matching the project's light webhtv_color_scrim - and the plan's own slot table already defines scrimOpacity as the page/dialog outer scrim, making this an implementation gap rather than a design gap. A material risk is recorded: TmdbPersonDialog:133 and EpisodeDetailDialog:240 disable window dim (setDimAmount(0f)) but TmdbVideoPlayerDialog:326 keeps 0.72f, so a view scrim would stack with it; the first implementation should therefore wire only the two clean dialogs. The document compares no-change, the recommended scoped-wiring option, rebinding the veils and unfreezing overlayDark, window-level dim, and removing the slots from the editor, and states the recommendation, minimal implementation steps, acceptance criteria and rollback path, citing source excerpts instead of line numbers so references do not rot. No build or device test was run, as this task changes docs only.
Task-Guard: THEME-SCRIM-OVERLAY-20260925
Verification: 设计研究任务(仅文档,未改代码)。产出 docs/THEME-SCRIM-OVERLAY-20260925-design.md,并在主题主文档登记任务与下一步。所有结论均双向可回溯,已逐条复核:(1) ThemeRole 无 SCRIM/OVERLAY 角色(grep -c = 0),故两槽无原生消费点,编辑器滑块是死控件;(2) resolver 语义差异——scrimOpacity 是绝对 alpha(withAlpha(base.colorScrim(), scrimOpacity),ThemeResolver:168,184),基线 light=0x52000000(0.322)/dark与TV=0x8A000000(0.541),而 dialogOpacity 是缩放且基线 1.0,实施时不能套用 applyShellOpacity 的 opacity>=1 no-op 判定;overlayOpacity 派生自 colorOverlayLight(0x14FFFFFF);(3) binder 不处理 ColorDrawable(grep -c = 0),且 singleColor 的显式 tag 路径仍要求 baseline==current;(4) scrim 消费点清单——3 个全屏弹层遮罩(dialog_episode_detail/dialog_tmdb_person/dialog_tmdb_video_player)语义正确,但 dialog_ad_block_stats 三个 source set 各 3 处用于 1dp 分隔线/88dp 行底、dialog_receive 用于海报占位底,因此不能全局替换、必须按视图限定作用域;(5) overlay 语义错配已证实——overlay_light 唯一用法是 dialog_quick_search.xml:80 的 1dp 分隔线,真正的图片 veil 用被 4.23 冻结的 overlay_dark(shape_tmdb_episode_overlay/shape_tmdb_recommendation_info 的 centerColor);(6) 官方一手证据复核通过——Material Components mtrl_scrim_color 恰为 #52000000,与本项目浅色 webhtv_color_scrim 完全一致;(7) 设计文档原文(第 14 槽表格与 Dialog 处理清单)本就把 scrim 定义为 Dialog 外遮罩,故 scrim 属实现遗漏而非设计缺失;(8) 关键风险已记录——TmdbPersonDialog:133 与 EpisodeDetailDialog:240 设 setDimAmount(0f),但 TmdbVideoPlayerDialog:326 设 0.72f,视图 scrim 会与窗口 dim 叠加,故建议首版只接前两个弹窗。方案对比含保持现状/推荐方案B/改绑 veil 解冻 overlayDark/窗口级 dim/移出编辑器五条,并给出推荐、最小实施步骤、验收标准与回滚路径。引用采用原文片段而非绝对行号以避免漂移。本任务未运行构建或设备测试(纯文档,无代码变更)。
Task-Guard: THEME-SCRIM-OVERLAY-20260925
Verification: NativeBridgeGuardTest passed; leanback arm64 debug build and overwrite install on 192.168.50.3:5561 passed; 3 real VOD interface reloads retained translated loaders (total 2/3/4) and left app process alive with empty crash buffer
Task-Guard: MIGRATE-HOUDINI-FIX-DEV4-20260925
Verification: closed the last row of the 4.7 device matrix on dev3 192.168.50.3:5559 (V1923A, API 28, light system) with the leanback arm64 debug build overwrite-installed and focus driven through adb shell input keyevent DPAD_*. Focus movement advanced one cell at a time across the home navigation row ((48,176)->(304,176)->(572,176)->(840,176)) and into the content grid, each step changing 265674-292954 pixels with the focus rect matching the focused=true node in the UI dump; 4-5 selected=true nodes coexisted and stayed distinguishable from the focused one; BACK from both the settings page and the exit dialog returned to HomeActivityCurrent without a stuck or leftover activity; the focused button ring measured #FFFFFF on the #FBFCFF panel (6.39:1, above the focus/surface >= 3:1 gate) and the focus band changed colours identically under the probe and Default, so focus colour is neither lost nor corrupted by theming; under the probe (primary=focus=#C62828, surface=#FFF8E1) the settings page differed from Default by 11474 px at every focus step while a focus move changed 292414 px, showing theming and focus coexist. Probe correction: focus and primary share the same frozen baseline in TV (#A8C7FA both), and the binder only rewrites a shared baseline colour when the roles agree, so an initial probe splitting them (primary=#C62828, focus=#7B1FA2) made the channel refuse every rewrite - a probe defect, not a product one. Two false leads were excluded by reading resources: the fixed translucent focus bar and brand colours (yellow_500 etc.) are deliberate exemptions, and the TV exit dialog is a hardcoded brand surface (shape_exit_confirm_dialog #FBFCFF/#E5EAF3, shape_exit_confirm_badge #E8F0FE/#C7DBFF, selector_exit_confirm_secondary #DCEAFF/#0B57D0/#F8FAFD) that the binder must skip, so the 4.28 fix introduced no regression. Scope stated honestly: 5559 is a tablet-characteristics device without android.software.leanback, so this validates theme-time focus/selection/disabled visibility under D-pad navigation, not real remote hardware. Device state restored byte-identically, night mode back to no, mobile flavour reinstalled, no leftover test files.
Task-Guard: TV-REMOTE-STATES-20260925
Verification: TV 遥控(焦点/选中/返回)三态验收完成,4.7 设备矩阵按设计项收口。设备 dev3 192.168.50.3:5559(V1923A,API28,浅色系统),leanback arm64 debug 覆盖安装,用 adb shell input keyevent DPAD_* 驱动焦点。子项证据:(1) 焦点移动通过——首页导航行 (48,176)->(304,176)->(572,176)->(840,176) 逐格推进,DPAD_DOWN 进入内容网格并继续移动,每步截图变化 265674-292954 像素,焦点框坐标与 UI dump 的 focused=true 节点逐一对应;(2) 选中态通过——UI dump 同时含 4-5 个 selected=true 节点(导航项+网格卡片),选中与焦点项可区分;(3) 返回通过——设置页 BACK 与退出弹窗 BACK 均回到 HomeActivityCurrent,无卡死或残留;(4) 状态可见无丢色——焦点按钮边框 #FFFFFF 对弹窗面板 #FBFCFF 对比度 6.39:1(设计门禁 focus/surface >= 3:1),焦点带在探针与 Default 下换色分布逐项相同(#5D566A->#464050 x246 等);(5) 主题与焦点共存——探针 primary=focus=#C62828/surface=#FFF8E1 下设置页 4 个焦点步各与 Default 相差 11474 像素(bbox (73,72)-(1847,445)),焦点移动步变化 292414 像素,两者互不干扰。探针构造修正:冻结调色板中 focus 与 primary 同色(TV 基线均 #A8C7FA),binder 规定共享基线色的角色取值一致才改写,首个 primary=#C62828/focus=#7B1FA2 的探针会让通道整体拒绝改写,属探针缺陷,改用同值后才有效。排除两个误判:首页/设置页焦点高亮条在探针与 Default 下完全相同,核对资源确认是刻意固定的半透明遮罩与品牌色(yellow_500 等,按设计豁免),真正消费 focus token 的 leanback 侧是 TmdbCastPresenter 与 main 共享的 selector_tmdb_card;TV 退出弹窗面板/按钮两组完全相同,核对 app/src/leanback/res/drawable 确认是硬编码字面量品牌样式(shape_exit_confirm_dialog #FBFCFF/#E5EAF3、shape_exit_confirm_badge #E8F0FE/#C7DBFF、selector_exit_confirm_secondary #DCEAFF/#0B57D0/#F8FAFD),binder 按契约本就跳过,因此 4.28 未引入回退。验收边界如实标注:5559 为 tablet 特性设备(无 android.software.leanback),本行验的是主题语境下焦点/选中/禁用态可见性,用 DPAD 代遥控导航,不等于真实遥控器硬件验证,后者仍无设备。环境还原:偏好按字节恢复(cmp 一致)、夜间模式 no、设备装回 mobile flavor(leanback launcher=0)、应用可正常启动、无残留测试文件。
Task-Guard: TV-REMOTE-STATES-20260925
Verification: TV (leanback) ignored the custom theme profile entirely. The leanback source set overrides every webhtv_color_* token with the dark table (53/53 identical to main/values-night) and ships no values-night, so TV views always inflate dark; but ThemeController.frozenPalette()/resolveWith() inferred the baseline from Resources.getSystem().uiMode, so on a light-mode device the baseline was the light table while inflation produced the dark one. ThemeBinder only rewrites colours that exactly equal the baseline, so the entire TV channel silently degraded to a no-op. Falsifying experiment on one build, flipping only the system dark setting: light-mode TV home 0 / settings 0 changed pixels vs dark-mode TV home 159174 / settings 11474. Fix: compiledDarkPalette() compares the flavour's actually compiled webhtv_color_* resources against the 19 binder roles and darkPaletteFor() makes an identifiable compiled table authoritative (TV stays dark even when the user picks light), falling back to the old uiMode rule only when the palette cannot be identified; both entry points share the decision. Automated: :app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.theme.*' 105 tests green, adding tvFlavourBaselineMustDescribeTheCompiledResources and darkPaletteDecisionKeepsMobileStableAndForcesTvOntoItsCompiledTable. Device (dev3 192.168.50.3:5559, API 28, light system, overwrite install of the final revision): TV home 36999 / settings 11474 changed pixels, the settings value matching the earlier dark-mode control exactly; mobile stayed responsive on a deterministic native settings page (19827 px), so no regression. Device state restored byte-identically (cmp against /tmp/w_base.xml), night mode back to no, mobile flavour reinstalled. Also recorded but not fixed (pre-existing, out of scope): launching SearchActivity without the keyword extra NPEs in SearchFragment.setKeyword:208.
Task-Guard: TV-THEME-PALETTE-20260925
Verification: TV 主题通道失效定位与修复。根因:app/src/leanback/res/values/webhtv_tokens.xml 的 53 项 token 全量覆盖为暗色表且与 main/res/values-night 53/53 逐项相同,leanback 无 values-night,故 TV 侧 inflate 颜色恒为暗色;而 ThemeController.frozenPalette()/resolveWith() 原先用 Resources.getSystem().uiMode 推断浅深,浅色系统下基线取 ThemeTokens.light(),与 ThemeBinder 的精确匹配契约不符,导致 TV 整条主题通道静默 no-op。证伪实验(同一 TV 包仅切系统深浅):浅色 TV 首页 0 / 设置页 0 像素,深色 TV 首页 159174 / 设置页 11474 像素。修复:新增 compiledDarkPalette() 用本 flavor 实际编译的 webhtv_color_* 资源逐项比对 19 个 binder role,再由 darkPaletteFor(compiled, mode, systemDark) 让可识别的编译表成为权威(TV 恒暗,显式选浅色也仍暗),仅无法识别时回退原 uiMode 规则;frozenPalette() 与 resolveWith() 共用该判定。自动化::app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.theme.*' 105 项全绿,新增 tvFlavourBaselineMustDescribeTheCompiledResources 与 darkPaletteDecisionKeepsMobileStableAndForcesTvOntoItsCompiledTable 两个契约测试。设备(dev3 192.168.50.3:5559 API28 浅色系统 覆盖安装 最终版本):修复后 TV 首页 36999 / 设置页 11474 像素,设置页数值与修复前深色对照值 11474 完全一致;手机端同探针确定性原生设置页 19827 像素仍正常响应,无回归。环境还原:偏好按字节恢复至 /tmp/w_base.xml(cmp 一致)、夜间模式恢复 no、设备装回 mobile flavor 且可正常启动。顺带记录未修的既有缺陷:无 keyword extra 启动 SearchActivity 会 NPE(SearchFragment.java:208),与本次改动无关。
Task-Guard: TV-THEME-PALETTE-20260925
Verification: FollowingUiSourceTest 13 passed; mobile arm64 debug installed on 192.168.50.3:5557; card actions sit in an equal-width row under the poster
Task-Guard: following-card-actions
Verification: dev3 192.168.50.3:5559 (V1923A/API28) with the playprobe custom theme fully active — playback via the exported HomeActivity push path (HomeActivity.checkAction -> VideoActivity.push) rather than the TMDB detail page. Exo kernel (player=EXO), phase=READY with firstFrameMs=3748, dropped=0/rebufferCount=0/rebufferTotalMs=0, decoder profile firstFrames=1 failures=0 drops=0 recoverableErrors=0 blacklisted=false, audio established via OMX.google.aac.decoder + SoftAAC2 44100Hz, position advanced 3363 -> 4362ms with OSD 00:10/03:00, 209922 pixels changed inside the player bbox (28,13,728,464) between samples, and Back -> HomeActivity -> VideoActivity resumed at positionMs=47314 (phase=READY); FATAL EXCEPTION=0 throughout and the theme touched only surrounding UI, never the video surface. Recorded the environment blocker honestly: the device cannot reach api.themoviedb.org (ping 2/2 loss), so the TMDB detail page stalls at its loading state (>10 min, unrelated to theming) and the push path was used to decouple the player from that dependency; two earlier false negatives from am start on the non-exported VideoActivity are documented as invalid evidence. Device prefs restored byte-identical to the pre-experiment baseline (cmp equal, md5 da54c8a4a153a04aa18c8fbdf4e64c7d) and the pushed test video removed. WebHome global skin stays independent per the user's 2026-09-24 decision; TV remote remains the only unexecuted matrix row (needs a leanback device).
Task-Guard: THEME-PLAYBACK-REGRESSION-20260925
Verification: 播放回归设备验收:dev3 192.168.50.3:5559 (V1923A/API28) 探针主题 playprobe 全激活下经 HomeActivity 推送入口播放 180s mp4;Exo 路径 player=EXO,phase=READY firstFrameMs=3748,dropped=0/rebufferCount=0/rebufferTotalMs=0,TV-exo-decoder-profile firstFrames=1 failures=0 drops=0 recoverableErrors=0 blacklisted=false,音频 OMX.google.aac.decoder + SoftAAC2 44100Hz 建立,播放推进 pos 3363->4362ms 且 OSD 00:10/03:00,两帧间播放器区域 bbox(28,13,728,464) 变化 209922 像素,Back 回首页再入 VideoActivity 二段 phase=READY positionMs=47314 续播,全程 FATAL EXCEPTION=0;主题仅改周边 UI 未侵入播放器画面。环境阻塞记录:设备到 api.themoviedb.org 100% 丢包导致 TMDB 详情页停在加载态(>10 分钟无进展,与主题无关),故以本地推送链路解耦验证;直接 am start 非导出 VideoActivity 的两次误判已纠正并写入文档。设备偏好已按字节还原至实验前基线 /tmp/w_base.xml(cmp byte-identical,md5 da54c8a4a153a04aa18c8fbdf4e64c7d;profile=Default theme_color=-291840 detail_open_mode=1),设备测试视频已删除。
Task-Guard: THEME-PLAYBACK-REGRESSION-20260925
Verification: 4.7 device matrix: 16-slot row and light/dark row pass with distinguishable probes (light panel #E0E0E0 + #C62828 primary + #C8E6C9 container vs dark #212121 + #80DEEA + #006064, groups independent, no restart needed); resource-recycling row passes over 55 cold-start/dialog/back cycles with FATAL=0 and PSS 331175 -> 313289 kB (no growth); transparency row FAILED and exposed dialogOpacity as a functional dead slot - lower bound 0.70 vs upper bound 1.00 produced byte-identical dialogs (0 changed pixels) because the token only reached the editor preview swatch and the Web snapshot; fixed by applying it to the dialog window shell (ThemeBinder.applyShellOpacity unwraps InsetDrawable and scales the drawable's own fill alpha, strict no-op at >=1 so the frozen baseline stays byte-identical, never touches text alpha per the documented contract); after the fix 0.70 vs 1.00 differ by 374849 px with bbox x474..1444 y342..736 exactly the dialog, 1.00 shows opaque #7B1FA2 and 0.70 shows #6D2B8C (the probe blended 70/30 with the wallpaper) while the title stays #FFFFFF unchanged; new contract+arithmetic test dialogOpacityReachesTheWindowShellAndNotTheText with ThemeBinder.scaleAlpha made package-private for JVM assertion; two failures fixed by correcting my own wrong expectation (0.70*255 rounds to 179) and by updating the 4.19 assertion that still matched the old return-shape; mobile+leanback theme tests and both flavors compileWithJavac PASS; check_ui_tokens --strict PASS; device prefs byte-verified restored. New residual: scrimOpacity and overlayOpacity remain zero-reference dead slots (needs a semantic decision, not just a fix)
Task-Guard: THEME-DIALOG-OPACITY-20260924
Verification: root-caused and fixed the framework-text-role gap at the theme layer: Material's Base.V14.Theme.Material3.Light points android:textColorPrimary/Secondary at its own m3_default_color_* selectors which hard-reference m3_sys_color_light_on_surface(_variant) (#1D1B20/#49454F), so Activity-context widgets never followed ?attr/colorOnSurface* and ThemeBinder could not rewrite those unknown values; Theme.WebHTV previously mapped only the Material colorOnSurface attrs, not the framework android:textColor* ones (only the Dialog overlay did); added webhtv_text_primary/secondary selectors preserving Material's disabled emphasis and mapped textColorPrimary/Secondary/Tertiary/Hint onto them so both fixes apply: widgets now resolve our tokens AND match baseline roles so the binder can rewrite them; new guard ThemeBaseWiringTest#activityThemeMapsFrameworkTextRolesToWebhtvTokens; check_ui_tokens --strict first rejected my new resource (hex literals in my own comment) which I removed, now violations=0 legacy=0 contrast 38/0 colors=56; mobile+leanback theme tests and FULL suites pass with zero failing classes, both flavors compileWithJavac PASS; dev3 192.168.50.3:5559 API28 same-coordinate before/after: import-dialog EditText hint went from Material default #50545C to exactly #44474F (webhtv_color_on_surface_variant) and then followed to #FFFFFF under the white onSurfaceVariant probe (it previously ignored themes entirely); panel #CFD8EB vs #7B1FA2 confirms same-screen comparison; device prefs byte-verified restored
Task-Guard: THEME-TEXTCOLOR-MAPPING-20260924
Verification: Beta merge from origin/beta (4b8e6bb80) completed without conflicts; merge content retained in index after resetting only the accidental task-guard baseline commit. No runtime validation run for this reconciliation commit; task review and risk-based verification continue before release.
Task-Guard: C19-beta-sync-review-dev1-20260924
Verification: Focused contract test passed: :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.dialog.TmdbSourceDialogInflationContractTest. Runtime TV verification on 192.168.50.3:5555: scrolling to API route opened picker automatically; DPAD_DOWN+ENTER selected official API; focused API input then reopened its picker by ENTER. Leanback arm64 debug APK built and overwritten via scripts/build_arm64_debug_install.sh. Device preference backup restored byte-for-byte (SHA-256 1c8d9b5e39c28f88d39e72af5bfe6f55c31df07981a0c6849c9554f15b5b2da0).
Task-Guard: FIX-TV-TMDB-ROUTE-AUTO-SWITCH
Verification: bash -n passed; real repository start/check/finish/tag/archive lifecycle exercised; a separate fresh start succeeded after archive.
Task-Guard: task-guard-recovery-archive
Verification: programmatic platform widgets in the theme editor now take explicit semantic colours: root cause is that Theme.WebHTV (the Activity theme) never maps android:textColorPrimary, so new TextView/Button/EditText from the Activity context fall back to Material's #49454F (= its on_surface_variant, not our #44474F token) which the binder correctly refuses to rewrite as an unknown colour; ThemeDialog mobile+leanback set colorOnSurface on the edit-mode label and the light/dark toggle plus a new outlinedPill() background (framework Button ships its own light plate, so text-only colouring would go white-on-white), and ThemeColorPickerDialog colours hexLabel/hexInput(text+hint)/useHex the same way; new guard test programmaticEditorWidgetsAlwaysGetTokenColours matches new TextView/EditText/Button incl. fully-qualified forms and follows the declared variable name so widgets coloured elsewhere still pass (it caught and corrected one false positive on first run); mobile+leanback theme tests PASS (ThemeEditorContractTest 12/0) and full mobile+leanback suites PASS with zero failing classes, both compileWithJavac PASS, check_ui_tokens --strict PASS violations=0 legacy=0 contrast 38/0; dev3 192.168.50.3:5559 API28 on the #7B1FA2 probe: 正在编辑 label went #49454F -> #FFFFFF and the 浅色 toggle became a coordinating outlined pill; device prefs byte-verified restored
Task-Guard: THEME-PROGRAMMATIC-WIDGETS-20260924
Verification: PASS: compileLeanbackArm64_v8aDebugJavaWithJavac; PASS: compileMobileArm64_v8aDebugJavaWithJavac; PASS: TV device NX627J Android 9 192.168.50.3:5563 - panel now spans y~50..1030 of 1080 (was 652px) after switching from MaterialAlertDialog to a self-drawn Dialog; PASS: module title/detail now use theme text colors (were hardcoded #202124/#5F6368) fixing dark-on-dark contrast; PASS: focus ring is a 3dp white stroke with no fill so labels stay bright, and the primary Close button was switched to TonalButton so nothing looks pre-focused; PASS: focus order explicitly wired - device verified UP from refresh now lands on cleanupStandard (previously skipped the whole cleanup row to totalLimit), then retention; no FATAL EXCEPTION
Task-Guard: CACHE-MGMT-01-TVUI2
Verification: restored the Material 3 primary-on-surface readability contract: ThemeResolver.readableAccent keeps hue/chroma and walks HCT tone to the nearest passing tone (Material baseline scheme measures 4.97-10.91:1, our frozen palettes 4.94-10.76, both confirmed from the Material 1.14.0 AAR and measured locally); onPrimary is recomputed when primary is repaired; focus now follows primary because the frozen palette ships focus==primary and the binder only rewrites a shared baseline colour when both roles agree (that divergence was why the first repair attempt left buttons unchanged); ThemeContrast gained the four primary/surface pairs so repair and gate can never disagree; 6 new tests incl. byte-identical empty profile and seed palettes not repaired; 2 pre-existing tests updated where they encoded pre-fix behaviour (documented, not silently loosened); mobile+leanback theme tests and full suites plus both compileWithJavac PASS with zero failing classes; check_ui_tokens --strict PASS; dev3 API28 device: dialog action text went from #0B57D0 (1.28:1, unreadable) to #AAC0FF (4.57:1) on the #7B1FA2 probe while keeping hue, preset chips likewise became legible; device prefs byte-verified restored
Task-Guard: THEME-PRIMARY-CONTRAST-20260924
Verification: Runtime TV remote verification on 192.168.50.3:5555: API route opened picker, DPAD_DOWN+ENTER selected official API and input text changed to 官方 API(直连); image route opened picker, three DPAD_DOWN+ENTER selected wsrv.nl 图片代理 and input text changed to wsrv.nl 图片代理. Unit contract test passed: :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.dialog.TmdbSourceDialogInflationContractTest. Leanback arm64 debug APK built and overwritten via scripts/build_arm64_debug_install.sh.
Task-Guard: FIX-TV-TMDB-ROUTE-REMOTE-PICKER
Verification: TouchOptimizationHelperSourceTest realigned to the 584b64514 single-inflate form while preserving its original intent (fragment callbacks registered before the content view; wall then sync inside the override) plus a new assertion that the resolved binding instance is the one passed to setContentView; full mobile unit suite ./gradlew :app:testMobileArm64_v8aDebugUnitTest reports 712 classes / 4863 tests / 0 failures / 0 errors with fresh result timestamps, so the suite is green again after the pre-existing stale red
Task-Guard: TEST-STALE-BASETHEME-ASSERT-20260924
Verification: 33 flavor files / 69 raw MaterialAlertDialogBuilder sites migrated to WebHtvAlertDialogBuilder (mobile 20/38, leanback 13/31); guard test widened to main+mobile+leanback (0 raw builders repo-wide) and AdRuleManageDialogLayoutTest updated; mobile+leanback theme unit tests BUILD SUCCESSFUL; both flavors compileWithJavac PASS; full mobile unit suite (712 classes) passed except one pre-existing stale assertion TouchOptimizationHelperSourceTest (its file and the leanback BaseActivity it asserts on are untouched by this task, breakage dates to 584b64514); check_ui_tokens --strict PASS violations=0 legacy=0 contrast 38/0; dev3 192.168.50.3:5559 API28 mobile HistoryActivity delete-all confirm dialog now shows 307808 px exactly #7B1FA2 with four panel sample points #CFD8EB -> #7B1FA2 under the purple probe; device prefs byte-verified restored
Task-Guard: THEME-DIALOG-BUILDER-FLAVORS-20260924
Verification: Unit contract test passed: :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.dialog.TmdbSourceDialogInflationContractTest; Leanback arm64 debug APK built with scripts/build_arm64_debug_install.sh and overwritten to 192.168.50.3:5555
Task-Guard: FIX-TV-TMDB-ROUTE-REMOTE
Verification: mobile+leanback theme unit tests BUILD SUCCESSFUL incl. new dialogWindowBackgroundIsRecolouredOutsideTheViewTree; leanback compileWithJavac PASS; check_ui_tokens --strict PASS violations=0 legacy=0 contrast 38/0; dev3 192.168.50.3:5559 API28 FollowingActivity import-subscription dialog (same scenario that previously kept the compiled panel) now shows 391852 changed pixels of which 364824 are exactly the probe #7B1FA2, four panel sample points all #CFD8EB -> #7B1FA2, title white via onSurface, primary untouched so buttons stayed baseline blue; device prefs byte-verified restored
Task-Guard: THEME-WINDOW-BACKGROUND-20260924
Verification: mobile+leanback theme unit tests BUILD SUCCESSFUL incl. new dialogThemeArgumentsResolveColorPrimaryConcretely (parses every res style + parent chain, requires nearest colorPrimary to be a concrete @color, correctly allows inheriting ThemeOverlay.WebHTV.Dialog.NoInset and would reject the removed self-referencing overlay); mobile+leanback compileWithJavac PASS; check_ui_tokens --strict PASS violations=0 legacy=0 contrast 38/0; dev3 192.168.50.3:5559 API28 FollowingActivity cancel-following dialog now renders with FATAL EXCEPTION=0 and Theme.AppCompat=0 (100% reproducible crash before); decisive device experiment proved ThemeOverlay_WebHTV_Dialog as themeResId does NOT crash so only the self-referencing overlay was defective and the other 36 sites were left untouched
Task-Guard: THEME-DIALOG-THEME-ID-FIX-20260924
Verification: mobile+leanback compile BUILD SUCCESSFUL; mobile+leanback theme.* and ui.dialog.* unit tests PASS incl. new guard materialAlertDialogsAreBuiltThroughTheThemedBuilder (0 raw builders left in src/main) and updated DialogRoundedCornerSourceTest; check_ui_tokens --strict PASS violations=0 legacy=0 contrast 38/0; dev3 192.168.50.3:5559 API28 FollowingActivity 导入订阅 dialog (1-arg builder, previously unbound) shows 9980 changed pixels with title/message text moving to #ADADAD under the purple probe vs 0 rewrites before; baseline 409c1b52b rebuilt and reinstalled to prove the 12 ThemeOverlay-as-themeResId crashes are pre-existing (identical stack via git stash), not a migration regression; device prefs byte-verified restored
Task-Guard: THEME-DIALOG-BUILDER-MAIN-20260924
Verification: mobile theme unit tests BUILD SUCCESSFUL incl. backgroundChannelCoversMaterialShapeAndInsetWrappers; dev3 192.168.50.3:5559 API28 LinkDialog(BaseAlertDialog) A/B with 32s cold start + tap: 277730 changed pixels of which 245581 are exactly the probe #7B1FA2 (dialog panel recoloured, previously skipped because the fill is a MaterialShapeDrawable wrapped in InsetDrawable) plus 7870 px text #ADADAD and 4145 px #FFFFFF; primary left untouched so the confirm button stayed baseline blue; device prefs byte-verified restored
Task-Guard: THEME-SHAPE-BACKGROUND-20260924
Verification: mobile+leanback theme unit tests BUILD SUCCESSFUL incl. followingPageOptsIntoTheSharedAppearanceContract and nativeActivitiesOutsideBaseActivityAreExplicitlyAccountedFor; dev3 192.168.50.3:5559 API28 FollowingActivity A/B with 14s cold start: exactly the 14735 baseline #0B57D0 (colorPrimary) pixels left the baseline under the orange #FB8C00 preset and became #88511D, secondaryContainer #DFE2E6 -> #FFDCC2, error #552422 unchanged as expected, FATAL EXCEPTION=0; device prefs byte-verified restored
Task-Guard: THEME-FOLLOWING-BIND-20260924
Verification: PASS: compileLeanbackArm64_v8aDebugJavaWithJavac; PASS: compileMobileArm64_v8aDebugJavaWithJavac; PASS: TV device NX627J Android 9 192.168.50.3:5563 - dialog outer bounds [96,54][1824,1026] = 1728x972 of 1920x1080 (~90%x90%); PASS: content root is a scrollable Android ScrollView covering the content area; PASS: after remote-key scrolling, autoCleanup/retention/totalLimit/cleanupLight/cleanupStandard/cleanupDeep/refresh/close all visible and focusable; PASS: no FATAL EXCEPTION after fixing the LayoutParams ClassCastException regression
Task-Guard: CACHE-MGMT-01-TVUI
Verification: PASS: recovery anchor updated with current test count 34/34, completed offline hardening, exact offline blocker (assigned device 192.168.50.3:5563 offline for 3 consecutive goal turns), the 5 device-dependent acceptance items, and the single next action to take once the device recovers
Task-Guard: CACHE-MGMT-01-ANCHOR
Verification: PASS: compileMobileArm64_v8aDebugJavaWithJavac; PASS: compileLeanbackArm64_v8aDebugJavaWithJavac; PASS: cache pure JUnit 34/34 including new out-of-root deletion guard and symlink-skip tests
Task-Guard: CACHE-MGMT-01-BOUNDS
Verification: PASS: blocker root cause captured from device stack trace: VodConfig.load NPE at OkHttp.newCall(OkHttp.java:174) because the configured VOD url was null; this blocks source-dependent playback/lyrics/karaoke/EPG regression checks and is unrelated to cache-management changes
Task-Guard: CACHE-MGMT-01-ROOTCAUSE
Verification: PASS: regression blocker recorded with direct device evidence; local ijk-smoke.mp4 and remote sample both returned MediaSession Source error state=7 on NX627J; no VOD/EPG config loaded; TV uiautomator returned null root; these acceptance items remain unchecked
Task-Guard: CACHE-MGMT-01-REGRESS
Verification: PASS: on NX627J Android 9, after running STANDARD cleanup (result PARTIAL, 0 deleted), mpv.conf md5 remained d696b6350e5cfc151769066120b803c3, fonts.conf remained cf92f416c8dc00d940765b5243032887, and WebHome preference lines hash remained 7963216a978342b128f5a24e3c909fee
Task-Guard: CACHE-MGMT-01-CONFIG
Verification: PASS: NX627J Android 9 rotation and night-mode toggles kept cache dialog controls intact with process alive and no FATAL/ANR; PASS: tv main database content file unchanged by cache dialog; PASS: cache_mgmt_inventory_duration_ms=9, modules=13, warnings=16
Task-Guard: CACHE-MGMT-01-COMPAT
Verification: PASS: documentation state no longer pins a stale HEAD; reflects current verified implementation and remains committed
Task-Guard: CACHE-MGMT-01-DOCS2
Verification: PASS: acceptance checklist updated from authoritative evidence: inventory scan 9ms/13 modules/16 warnings on NX627J Android 9; mobile and TV cleanup completion verified; cache pure JUnit 25/25; arm64 debug builds compiled and installed
Task-Guard: CACHE-MGMT-01-DOCS
Verification: PASS: mobile device smoke on LIO-AN00 Android 9 (192.168.50.3:5555) including cleanup completion dialog; PASS: TV leanback device smoke on NX627J Android 9 (192.168.50.3:5563) including cleanup completion dialog and persisted cache_mgmt_cleanup_history; PASS: cache pure JUnit 25/25
Task-Guard: CACHE-MGMT-01-EVIDENCE
Verification: git diff --check passed; XML parsing passed for main and leanback layouts; FollowingUiSourceTest BUILD SUCCESSFUL; leanback arm64-v8a Debug build and overwrite installation to 192.168.50.3:5561 succeeded; runtime reproduction verified the TV header scrolls with content, FollowingActivity remained resumed, screenshots captured, and no FATAL EXCEPTION occurred; Gradle daemons stopped
Task-Guard: FOLLOWING-TV-FOCUS-VISIBILITY-20260923
Verification: PASS: mobile debug install and device smoke on LIO-AN00 Android 9 at 192.168.50.3:5555; PASS: cache dialog layout shows module list, limits, auto-cleanup, retention, total limit, and all three cleanup buttons; PASS: light cleanup confirmation and result dialog completed; NOTE: leadback package installs on HD1910 but TV focus-navigation acceptance remains unverified
Task-Guard: CACHE-MGMT-01-UI
Verification: git diff --check passed; FollowingUiSourceTest BUILD SUCCESSFUL; leanback arm64-v8a Debug build and overwrite install to 192.168.50.3:5561 succeeded; runtime UI verified top controls at y=56, recycler at y=152, first item title at y=192, FollowingActivity resumed, no FATAL EXCEPTION
Task-Guard: FOLLOWING-TV-TOP-INSET-20260923
Verification: mobile arm64 debug build Success and overwrite install on 192.168.50.3:5559; cold start nav_position=1 renders 3 bottom items vod/live/setting at 295x140 each with icons, while lab/following stay hidden by config; FATAL EXCEPTION=0
Task-Guard: HOME-NAV-VISIBILITY-20260922
Verification: Fixed the remaining runtime gap where one DPAD move could leave focus on the previous row: Leanback ConfigAdapter now waits for the target RecyclerView child to attach, requests focus once, and removes the attach listener after success; no smooth scrolling or repeated delayed focus callbacks; InterfaceEntryInteractionTest and ConfigHistoryCurrentSourceGuardTest passed (10 tests total); Leanback arm64-v8a Debug APK compiled and was covered-installed with install -r to 192.168.50.3:5561; runtime UI dump verified consecutive DPAD_DOWN sequence across all six interface rows and add button, then DPAD_UP back across all six rows; Gradle daemon stopped and temporary UI dumps cleaned; git diff --check passed
Task-Guard: leanback-interface-focus-attach
Verification: InterfaceEntryInteractionTest passed with 8 tests, 0 failures, and 0 errors; ConfigHistoryCurrentSourceGuardTest passed with 2 tests; leanback arm64-v8a debug Java compilation passed; latest Debug APK built and covered-installed to 192.168.50.3:5561; removed smooth scrolling and repeated delayed focus callbacks, now stopping scroll and using one post-on-animation focus after direct layout-manager positioning to eliminate focus flicker; current interface remains focusable but cannot be reused; git diff --check passed; task_guard check passed
Task-Guard: leanback-interface-focus-no-flash
Verification: InterfaceEntryInteractionTest passed with 8 tests, 0 failures, and 0 errors; ConfigHistoryCurrentSourceGuardTest passed with 2 tests; leanback arm64-v8a debug Java compilation passed; latest Debug APK built and covered-installed to 192.168.50.3:5561; ConfigDialog now uses the LightDialog full-size overload so its on-show sizing is not overwritten by the compact default; dialog margins are 24dp (48dp total); DPAD row focus gets delayed retries after scroll; git diff --check passed; task_guard check passed
Task-Guard: leanback-interface-current-focus-and-config-fill
Verification: InterfaceEntryInteractionTest passed with 8 tests, 0 failures, and 0 errors; ConfigHistoryCurrentSourceGuardTest passed with 2 tests; leanback arm64-v8a debug Java compilation passed; Debug APK built and covered-installed to 192.168.50.3:5561; ConfigDialog content hierarchy now expands to the full screen-sized window; current interface row remains focusable while click reuse and delete protection remain; git diff --check passed; task_guard check passed
Task-Guard: leanback-interface-current-focus-and-config-fill
Verification: dev3 mobile: invalid hex #GGGGGG rejected with Enter #RRGGBB and no profile write; TAB/DPAD focus traversed 16 settings rows without crash; FATAL=0; theme remains reset to default
Task-Guard: L2-MATRIX-RESIDUAL-20260922
Verification: layout structure passed: ScrollView has exactly one direct child and proxyHostInput is present once; git diff --check passed; :app:processMobileArm64_v8aDebugResources passed with BUILD SUCCESSFUL
Task-Guard: tmdb-proxy-layout-crash
Verification: InterfaceEntryInteractionTest passed with 7 tests, 0 failures, and 0 errors; leanback arm64-v8a debug Java compilation passed; TV interface list and edit dialogs now use screen-sized windows with 32dp margins on every edge, transparent window backgrounds, and zero decor padding; git diff --check passed; task_guard check passed
Task-Guard: leanback-interface-dialog-fullscreen-margins
Verification: InterfaceEntryInteractionTest passed with 7 tests, 0 failures, and 0 errors; leanback arm64-v8a debug Java compilation passed; BUILD SUCCESSFUL in 32s with 98 actionable tasks; TV interface dialog now focuses the editable name field initially and provides an explicit D-pad focus chain through URL, backup addresses, file actions, confirm, and cancel; git diff --check passed; task_guard check passed
Task-Guard: leanback-interface-dialog-focus-chain
Verification: InterfaceEntryInteractionTest passed with 7 tests, 0 failures, and 0 errors; leanback arm64-v8a debug Java compilation passed; BUILD SUCCESSFUL in 45s with 98 actionable tasks; TV interface list and edit dialogs now adapt to screen width and height; the list exposes the add button; the edit dialog supports modifying and saving the interface name; git diff --check passed; task_guard check passed
Task-Guard: leanback-interface-dialog-adaptive-layout
Verification: VideoActivityLayoutTest passed; mobile and leanback arm64-v8a debug Java compilation passed; BUILD SUCCESSFUL in 58s with 98 actionable tasks; both players now persist sourceBindingKey immediately through syncHistory after selecting a playback line; git diff --check passed; task_guard check passed
Task-Guard: history-flag-binding-save
Verification: InterfaceEntryInteractionTest and ConfigHistoryCurrentSourceGuardTest passed with 0 failures; mobile and leanback arm64-v8a debug Java compilation passed; mobile arm64-v8a Debug APK built successfully and installed with overlay installation on 192.168.50.3:5561; clean relaunch verified app remains responsive; runtime UI verified current source 嗷呜 remains visible, its use action is disabled, edit remains available, delete remains visible with protected-current feedback behavior, and add remains available; git diff --check passed; task_guard check passed
Task-Guard: interface-current-delete-feedback
Verification: InterfaceEntryInteractionTest and ConfigHistoryCurrentSourceGuardTest passed; mobile and leanback arm64-v8a debug Java compilation passed; mobile arm64-v8a Debug APK built and installed with adb install -r on 192.168.50.3:5561; runtime UI verified current source 嗷呜 remains visible with disabled use text and hidden delete action while edit remains available; git diff --check; task_guard check
Task-Guard: interface-current-source-actions
Verification: InterfaceEntryInteractionTest passed; mobile and leanback arm64-v8a debug Java compilation passed; mobile arm64-v8a Debug APK built and installed with adb install -r on 192.168.50.3:5561; new interface test autobackup persisted urlsJson=["http://primary.test.invalid","backup.test.invalid"] and reopened with backup address visible; temporary test configs removed; git diff --check; task_guard check
Task-Guard: interface-new-backup-persistence
Verification: InterfaceEntryInteractionTest passed; mobile and leanback arm64-v8a debug Java compilation passed; mobile arm64-v8a Debug APK built successfully; APK installed with adb install -r on 192.168.50.3:5561; runtime UI verified that the VOD interface manager lists all configs and exposes explicit edit, delete, and add actions; git diff --check passed; task_guard check passed
Task-Guard: interface-config-list-interaction