diff --git a/core/apk_manager.py b/core/apk_manager.py index d2b2def..037c95f 100644 --- a/core/apk_manager.py +++ b/core/apk_manager.py @@ -539,18 +539,46 @@ class ApkManager: _log.warning(f"[{serial}] exec-in 推送到一半失败(rc={rc}),退回 adb push") return self._push_fallback(serial, apk_path, remote) - # 校验设备上文件大小一致(exec-in 中途断流可能"成功"但少字节) + # 校验设备上文件大小。**必须轮询等它长齐**——见 _wait_remote_size 的说明: + # exec-in 返回后设备侧可能仍在落盘,只量一次会把成功的传输误判成截断。 + got = self._wait_remote_size(serial, remote, total) + if got is not None and got != total: + _log.warning(f"[{serial}] exec-in 推送不完整(设备 {got} / 本地 {total} 字节)," + f"改用 adb push 重传") + return self._push_fallback(serial, apk_path, remote) + + return True, "推送完成 %.1fMB / %.0fs" % (total / 1048576.0, time.time() - t0) + + @staticmethod + def _remote_size(serial, remote): + """设备上文件字节数;取不到返回 None(stat 参数各机型有差异,不据此判失败)。""" try: rs = subprocess.run( [ADB_PATH, "-s", serial, "shell", "stat", "-c", "%s", remote], capture_output=True, timeout=30) - got = int((rs.stdout or b"").decode("utf-8", "replace").strip().splitlines()[-1]) - if got != total: - return False, f"推送不完整(设备 {got} / 本地 {total} 字节)" + lines = (rs.stdout or b"").decode("utf-8", "replace").strip().splitlines() + return int(lines[-1]) if lines else None except Exception: - pass # stat 参数各机型有差异,取不到就跳过校验 + return None - return True, "推送完成 %.1fMB / %.0fs" % (total / 1048576.0, time.time() - t0) + def _wait_remote_size(self, serial, remote, want, timeout=20.0): + """轮询设备上的文件大小,直到等于 want / 超时 / 取不到(None)。 + + 为什么不能只量一次:**adb 进程退出 ≠ 设备上文件写完**。exec-in 把数据交给 + 设备侧(adbd → shell → `cat >`)后,缓冲区里的数据还会继续落盘。实测同一份 + 5.9MB 的 APK:adb 刚返回时 stat 读到 3710967,紧接着 4264448,几秒后才是完整的 + 5933150。旧实现量一次就判"推送不完整",把**成功的传输误报成失败** + (2026-09-14 用户报的就是这个;同一份文件在多台设备上"失败",断点还各不相同)。 + """ + deadline = time.time() + timeout + last = None + while True: + last = self._remote_size(serial, remote) + if last == want or last is None: + return last + if time.time() >= deadline: + return last + time.sleep(0.3) def _push_fallback(self, serial, apk_path, remote): """老 adb 或 exec-in 失败时的退路:原来的 `adb push`(无进度)。""" @@ -565,6 +593,12 @@ class ApkManager: if r.returncode != 0 or "1 file pushed" not in out: return False, (out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})") + # adb push 是同步的(不会出现 exec-in 那种"退出后才落盘"),但仍量一次大小: + # 设备空间不足/文件被并发覆盖时,"1 file pushed" 也可能是残缺的 + total = os.path.getsize(apk_path) + got = self._wait_remote_size(serial, remote, total, timeout=10) + if got is not None and got != total: + return False, f"推送不完整(设备 {got} / 本地 {total} 字节)" return True, "推送完成(adb push)" def _set_item(self, serial, status, msg=""): diff --git a/doc/API.md b/doc/API.md index ab5d8ec..193bfd4 100644 --- a/doc/API.md +++ b/doc/API.md @@ -577,6 +577,12 @@ > 1. **推送到设备**:`推送中 62%(211.0/336.1 MB · 2.6 MB/s · 剩约 48s)` —— 用分块写 > (`adb exec-in`)拿字节级进度;不用 `adb push` 是因为它只在结束时吐一行汇总, > 大包(抖音 336MB≈2 分钟)整个传输过程界面上只能干等 +> ⚠️ **`adb exec-in` 返回 ≠ 设备上写完**:数据还在设备侧(adbd → shell → `cat >`)缓冲里 +> 继续落盘。所以收尾必须**轮询文件大小直到长齐**(`_wait_remote_size`,最长 20s), +> 量一次就判"推送不完整"会把成功的传输误报成失败(2026-09-14 的真实案例:同一份 +> 5.9MB 的 APK 在多台设备上报了 8 次假失败,实际文件最终都是完整的)。真不齐才退回 +> `adb push` 重传。 + > 2. **设备上安装**:`正在设备上安装...(大包要 1-2 分钟,无进度可读)` —— `pm install` > 在设备端解包,系统没给进度接口 >