# 原生离线下载独立复审

审核者：子 Agent（gpt-6.1-sol，reasoning_effort=max）。独立复核 Android/JS，由主 Agent 负责修改和最终验收。

范围：只读审查 root 实现的 `JukuOfflineStore.java`、`JukuOfflineModule.java`、`JukuOfflineService.java`、`ReactVideoPackage.kt`、`ReactExoplayerView.java` 离线分支、两个 Android manifest、`JukuMediaClient.java` 来源策略、`src/playback/nativeOffline.ts` 及对应夹具。审核者没有编辑上述文件。iOS 的实现者源码/SDK 检查见[原生离线下载接入](dramivio-native-offline-implementation.md)，不算独立 iOS 完整审查。

结论：本次独立复核确认 3 个 P2，root 已修复，独立正向回归全部通过；限定上述队列机器范围，没有剩余已确认 P1/P2。当前是没有 issuer 接入的有限原生队列 PoC。消费者 `src/queries/playback.ts` 仍抛出 503 `offline_authorization_required`；原生 request metadata、owner 与 expiresAt 不构成许可证、设备 attestation 或受保护内容离线授权。

## 已确认的问题及复现

### P2：重复 clearOwner 的旧 poll 删除重新进入该 owner 后的新目录

位置：`JukuOfflineStore.java:251` 的 `clearOwner()` / `waitRemoved()`。原实现为每次 clearOwner 创建独立异步 poll；poll 持有旧资源 ID，却在结束时查找该 owner **当前**的目录映射，并清除 `clearing:<owner>`。

实际复现：完成原 MP4 下载；第一次 clear 发起删除、暂未完成；排空真实 Media3 删除工作但不推进已排队的 100ms poll；第二次 clear 看到 index 已空而立即返回成功，清掉锁；同 owner 重新 configure 并 enqueue，实际 SimpleCache 创建新 UUID 目录；推进第一条旧 poll，它删除新目录却报告清理成功。没有改写 manager/index/cache，只有媒体 transport 是本地字节夹具。

证据：`offline-android-adversarial.log` 的修复前 `duplicateClearErasesAReenteredOwnersNewDirectory` 断言缺陷通过。root 使用每 owner 的 completion 列表合并到同一次 `waitRemoved`；失败保留持久 clearing 标志，完成前禁止 owner 重激活。修复后独立夹具 `offline-android-review/com/brentvatne/exoplayer/JukuOfflineAdversarialReviewTest.java` 的 `duplicateClearPreservesAReenteredOwnersNewDirectory` 通过，验证两条 Promise 均等待清理终态，重进后的真实新目录/cache 字节在后续 poll 时仍存在。状态：已修复并独立验证。

### P2：旧 DownloadHelper 回调移除同 UUID 的新 preparation，使 Promise 永不完成

位置：`JukuOfflineStore.java:218` 的 `onPrepared` / `:232` 的 `onPrepareError`。原实现先执行 `preparing.remove(id)`，再比较是否为当前 pending；旧事件也因此会移除新条目。

实际复现：enqueue MP4(id,A)，在真实 helper 的主线程 onPrepared 分发前切换 owner B，旧 request 正常报 owner changed；随后 B 使用同 UUID enqueue；排空主线程，旧 helper 的真实回调先移除 B 的 pending，B 的回调也直接返回。B 的 completion 未 settle，真实 DownloadIndex 无此任务。检查已安装 Media3 1.8 的真实 bytecode：`DownloadHelper.release()` 不取消已 post 的 callback，因此这是可达竞态。没有使用反射生成 callback 或用替代 helper。

证据：`offline-android-adversarial-callback.log` 的修复前 `stalePreparedCallbackDropsReplacementAssetPromise` 断言缺陷通过；`offline-android-downloadhelper-bytecode.txt`。root 两个回调都改为先用 `preparing.get(id) == pending` 确认身份，再 remove。修复后独立夹具 `stalePreparedCallbackPreservesReplacementAssetPromise` 通过，B 的 Promise 正常完成，真实下载最终 completed，native playback 返回 owner B。onPrepareError 使用同一保护已源码复核，本次没有单独合成 error callback。状态：已修复并独立验证实际 onPrepared 竞态。

### P2：owner 契约最初拒绝 JS 允许的 HTTP IPv6 loopback

`downloadOwner` 接受 `http://[::1]:8080`；原生 `scope` 最初只接受 localhost/127.0.0.1。仅加入 `::1` 分支仍不满足本次 SDK 28 Uri 解析行为，独立正向夹具抛出 `offline_owner_invalid`。root 将 owner identity 解析改用 `java.net.URI`，接受 `::1`/`[::1]`；源媒体仍用 Android Uri 与原有严格网络策略。修复后独立 `allowedIPv6OwnerCanConfigureAndClear` 通过，root 另有三种 loopback owner 回归。这个修复只影响 owner metadata，没有放宽媒体 URL/DNS 规则。状态：已修复并独立验证 configure/clear 契约。

## 验证边界：ready 不等于 decoder 或许可证证明

实际 HTML200 负夹具完整进入当前 MP4 队列与 cache，native playback snapshot 为 ready；真实 Media3 `Mp4Extractor.sniff` 对这些缓存字节返回 false。证据是独立夹具 `html200CompletesQueueButActualMp4ExtractorRejectsIt`，修复前检查通过。当前 ready 表示 Media3 `STATE_COMPLETED`，不能写成“MP4 可解码已验证”或“受保护视频许可证有效”。消费者入口未开放，MP4 metadata/decoder 验证、provider/license/设备执行验收属于尚未完成的 O00 范围；本复核不把该边界另计为有限队列机器的 P1/P2。

## 已复核的行为与已修复的问题

- 原生 module 普通 Promise 参数与 JS adapter 匹配；guest 使用 `configureOwner('', '')`，expiry 统一毫秒。snapshot 不暴露 source URL/headers；JS 检查 owner、UUID、有效期与精确 `dramivio-offline://asset/<UUID>`，native player 再从 DownloadIndex 的 UUID 找 owned completed asset。
- Android 离线 source 使用真实 Media3 DownloadHelper 创建 MediaSource，CacheDataSource 的 upstream 和 write sink 均为空；缓存缺失不能退回来源网络。owner/expiry/clearing 同时在 open/read 检查。已有真实 manager/index/cache 测试覆盖本地 MP4、HLS manifest/segment/AES key、冷启动、暂停恢复、换号、到期与物理移除；不等于真机播放器/后台完整验收。
- source heads 只有 Referer/User-Agent；独立 OkHttp client 使用 `NO_COOKIES`、没有网站认证 interceptor/shared API jar、DNS 公网地址限制及每次 network redirect 目的检查。只是复核真实源码链路，本次 native transport 夹具不证明实际 HTTPS/redirect/DNS 请求结果。
- root 在复核期间修复 modern AGP 使用 `AndroidManifestNew.xml` 导致下载服务未注册的问题：当前两份 manifest 都含非导出的 DownloadService 与受 BIND_JOB_SERVICE 保护的 PlatformScheduler service；真实 merged manifest 夹具已经覆盖，不能把之前 Config.NONE 的 compile 结果当注册证明。
- root 先前修复同 URL 不同 asset 删除互相擦 cache 的问题：CacheKeyFactory 添加实际 asset UUID 前缀，配套真实 cache 删除保留另一个 asset 的测试。持久 clearing preference 在清理失败/中断时保持锁定。并发多次 clear 的终态竞态由上面的独立复现另行验证。

## 运行证据

独立 reviewer fixtures 使用 Robolectric 4.17 / Android SDK 28、实际 Media3 1.8、SQLite DownloadIndex、SimpleCache 和 DownloadHelper；重用 author 的本地 transport 与生成媒体资源。额外 sourceSet 仅由 `offline-android-review.init.gradle` 指向 `work/`，未修改仓库源代码或正式夹具。没有下载上游媒体、连接生产 API 或发布。

`offline-android-js-adapter-review.log`：独立重跑 7 个 JS adapter 测试全部通过。初始 reviewer native run：并发 clear 与 HTML200 检查通过，IPv6 owner 正向检查失败；单独旧 callback 检查通过。

`offline-android-adversarial-fixed.log`：修复后的独立 4 项全部通过，Gradle exit 0：IPv6 owner configure/clear、重复 clear 后新目录/cache 保留、旧 prepared 回调后新 request 完成、HTML200 实际 extractor 拒绝。root 作者夹具另有 `offline-android-robolectric.log` 的 15 项通过；这是作者运行的更广功能验证，与上述独立执行分开记录。

真机系统调度/foreground service、进程终止/重启、磁盘低空间/损坏、真实 decoder 播放、网络侧 DNS/redirect 隔离与真实 offline license/device attestation 均不由这些 fixtures 证明。没有发现已确认 P1；本次确认的 P2 均由 root 修复并通过上述独立复核。
