diff --git a/frontend/utils/cacheManager.js b/frontend/utils/cacheManager.js index 7564a8d..2961fa2 100644 --- a/frontend/utils/cacheManager.js +++ b/frontend/utils/cacheManager.js @@ -150,11 +150,18 @@ export async function getCacheInfo(force = false) { // 它们的子目录文件不在 currentSizeKB 也不在 sandboxBytes(_statDir 不递归), // 必须单独加,否则"已用空间"会少 1-几十 MB // 注:draft / progress / preload / guide / others 是 storage key,已被 currentSizeKB 包含,不重复加 - const SANDBOX_ONLY_CATEGORY_IDS = new Set(['sandbox-tmp', 'sandbox-residual']) - const sandboxOnlyBytes = categories - .filter((c) => SANDBOX_ONLY_CATEGORY_IDS.has(c.id) && c.sizeBytes > 0) - .reduce((sum, c) => sum + c.sizeBytes, 0) - const appUsedBytes = currentSizeKB * 1024 + sandboxBytes + sandboxOnlyBytes + // ★ 2026-08-03 移除 sandboxOnlyBytes: + // getSandboxTotalSize 现在走 native / 完整 data 目录扫描,已覆盖全部 doc 子目录 + // (业务 tmp、share/avatar/canvas、databases、cache),再按分类加一遍会双计 + // (排查 345.8MB vs 系统 413MB 时发现)。分类 sizes 仍展示在列表,但不进 appUsedBytes。 + const appUsedBytes = currentSizeKB * 1024 + sandboxBytes + // ★ 2026-08-03 诊断:appUsedBytes 构成,定位"已用空间 X MB"从哪来 + console.info( + `[cacheManager] appUsedBytes=${Math.round(appUsedBytes / 1048576)}MB = ` + + `storage(${Math.round((currentSizeKB * 1024) / 1048576)}MB) + ` + + `sandbox(${Math.round(sandboxBytes / 1048576)}MB); ` + + `categories=[${categories.map((c) => `${c.id}:${Math.round(c.sizeBytes / 1048576)}MB`).join(', ')}]` + ) const quotaTotalBytes = limitSizeKB * 1024 const raw = quotaTotalBytes - appUsedBytes const quotaAvailableBytes = Math.max(0, raw) diff --git a/frontend/utils/ioPath.js b/frontend/utils/ioPath.js index 8497310..9412914 100644 --- a/frontend/utils/ioPath.js +++ b/frontend/utils/ioPath.js @@ -532,10 +532,27 @@ async function queryAndroidAppBytes() { reason: 'no_stats' } - // getAppBytes() 是 long 返回,proxy 不稳 → parseFloat 兜底 - const bytes = parseFloat(plus.android.invoke(stats, 'getAppBytes')) - if (!Number.isFinite(bytes) || bytes < 0) { - console.warn('[ioPath] queryAndroidAppBytes: invalid bytes =', bytes) + // ★ 2026-08-03 修复:getAppBytes() 在部分设备 proxy 只返回 base.apk 的 21MB(系统"存储"里实际 413MB)。 + // 改为分别读 getApkBytes / getDataBytes / getCacheBytes 再求和——三者是 getAppBytes 的组成部分, + // 单独读时 proxy 解析更可靠(避免 'getAppBytes' 被误解析到 'getApkBytes')。 + // 任一为 NaN/负 → 丢弃该部分;三部分都无效 → 判失败,调用方走目录扫描降级。 + const rawParts = [ + parseFloat(plus.android.invoke(stats, 'getApkBytes')), + parseFloat(plus.android.invoke(stats, 'getDataBytes')), + parseFloat(plus.android.invoke(stats, 'getCacheBytes')), + ] + const parts = rawParts.filter((v) => Number.isFinite(v) && v >= 0) + // 若三部分都有效且完全相同,很可能是 proxy 把三个方法都解析成了同一个(如都返回 base.apk)→ 不可信 + if (parts.length === 3 && parts[0] === parts[1] && parts[1] === parts[2]) { + console.warn('[ioPath] queryAndroidAppBytes: apk/data/cache identical, proxy unreliable =', parts) + return { + supported: false, + reason: 'invalid_bytes' + } + } + const bytes = parts.reduce((s, v) => s + v, 0) + if (parts.length === 0 || bytes <= 0) { + console.warn('[ioPath] queryAndroidAppBytes: invalid bytes =', parts) return { supported: false, reason: 'invalid_bytes' @@ -565,10 +582,14 @@ async function queryAndroidAppBytes() { /** * 计算"app 已用空间"。 * - * Android API ≥ 26:优先走 StorageStatsManager.getAppBytes()(系统记账,O(1))。 - * 原生查询任一环节失败 → 降级到旧实现"APK + Σ4 沙盒根遍历"(4s 单根兜底,与降级前等价)。 - * Android API < 26:直接走旧实现(queryStatsForPackage 自 API 26 才有)。 - * iOS:旧实现 4 沙盒根遍历(iOS .app bundle 沙盒外不可访问,按 sandbox 估算)。 + * Android: + * - 优先走 StorageStatsManager(系统"存储"口径 = APK + data + cache),O(1)。 + * getAppBytes 改成 getApkBytes + getDataBytes + getCacheBytes 三部分求和, + * 规避部分设备 proxy 把 getAppBytes 误解析成只返回 base.apk(21MB vs 系统 413MB)的 bug。 + * - native 返回值 < MIN_NATIVE_BYTES(30MB,本 app 自带 160MB 静态资源不可能低于此)→ 视为不可信, + * 降级到"APK + Σ4 沙盒根遍历"(PRIVATE_WWW 递归数全解压资源、PRIVATE_DOC 顶层 + 分类补子目录)。 + * Android API < 26:直接走 4 根遍历(queryStatsForPackage 自 API 26 才有)。 + * iOS:4 沙盒根遍历(iOS .app bundle 沙盒外不可访问,按 sandbox 估算)。 * H5 / 小程序:return 0。 * * 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes。 @@ -596,20 +617,124 @@ async function queryAndroidAppBytes() { * * 单个根失败/超时 → 0(不影响其他根) */ + +/** + * 获取 Android APK 安装包大小(native,O(1)) + * 走 Context.getApplicationInfo().getSourceDir() + java.io.File.length() + * 非 Android / 任一环节失败 → 0 + * @returns {Promise} + */ +async function getAndroidApkSize() { + // #ifdef APP-PLUS + try { + if (typeof plus === 'undefined' || !plus.android) return 0 + const main = plus.android.runtimeMainActivity() + if (!main) return 0 + // 走 Context.getApplicationInfo()(App.vue#setPermissions 验证过可工作) + // 不走 PackageManager.getApplicationInfo(pkg, 0):plus.android proxy 对 PackageManager + // 的 getApplicationInfo 重载方法暴露不稳定,会报 "pm.getApplicationInfo is not a function"。 + const appInfo = main.getApplicationInfo() + if (!appInfo) return 0 + const sourceDir = plus.android.invoke(appInfo, 'getSourceDir') || appInfo.plusGetAttribute?.('sourceDir') + if (!sourceDir) return 0 + const File = plus.android.importClass('java.io.File') + const f = new File(sourceDir) + const size = f.length() + if (typeof size === 'number' && size > 0) return size + return 0 + } catch (e) { + console.warn('[ioPath] getAndroidApkSize failed:', e?.message) + return 0 + } + // #endif + // #ifndef APP-PLUS + return 0 + // #endif +} + +// native(StorageStatsManager)返回值的可信下限。 +// 本 app 自带 160MB 静态资源,任何合理的"已用空间"都远超 30MB; +// 部分设备 getAppBytes proxy 只返回 base.apk 的 21MB(系统"存储"实际 413MB), +// 低于该阈值即视为不可信,降级到目录扫描。 +const MIN_NATIVE_BYTES = 30 * 1024 * 1024 + +/** + * ★ 2026-08-03 决定性降级:完整扫描 app data 目录 + APK 大小(对齐系统"存储"口径 413MB)。 + * + * 背景:StorageStatsManager(getAppBytes)在部分设备 plus.android proxy 只返回 base.apk(21MB), + * 且 getApkBytes/getDataBytes/getCacheBytes 三部分求和也不可靠(仍降级)。纯目录扫描是唯一 + * 确定性路径。 + * + * data 目录 = /data/user/0/,覆盖 files(含 _doc/_www/databases)、cache、code_cache、 + * shared_prefs、no_backup —— 正是系统"存储"里 APK+data+cache 的 data+cache 部分。 + * 加 apkSize = APK 部分。 + * + * 走 plus.io.resolveLocalFileSystemURL('file://' + 路径) 解析(与 _getRealFileSize 同机制)。 + * 任一环节失败 → 返回 0,由调用方再降级到 4 根扫描。 + * + * @returns {Promise} 总字节,失败返回 0 + */ +async function _scanFullAppUsage() { + // #ifdef APP-PLUS + try { + const main = plus.android.runtimeMainActivity() + if (!main) return 0 + let dataDirPath = null + try { + const dataDir = plus.android.invoke(main, 'getDataDir') + dataDirPath = plus.android.invoke(dataDir, 'getPath') + } catch (e) { + /* fallthrough 到包名拼接 */ + } + if (!dataDirPath) dataDirPath = '/data/user/0/' + main.getPackageName() + const entry = await new Promise((resolve) => { + plus.io.resolveLocalFileSystemURL( + 'file://' + dataDirPath, + (e) => resolve(e), + () => resolve(null) + ) + }) + if (!entry) { + console.warn('[ioPath] _scanFullAppUsage: resolveLocalFileSystemURL failed for', dataDirPath) + return 0 + } + const sub = await _sumDirSizeRecursive(entry) + const apkSize = await getAndroidApkSize() + console.info( + `[ioPath] _scanFullAppUsage: apkSize=${Math.round(apkSize / 1048576)}MB, ` + + `dataDir=${Math.round(sub.bytes / 1048576)}MB, total=${Math.round((apkSize + sub.bytes) / 1048576)}MB` + ) + return apkSize + sub.bytes + } catch (e) { + console.warn('[ioPath] _scanFullAppUsage failed:', e?.message) + return 0 + } + // #endif + // #ifndef APP-PLUS + return 0 + // #endif +} + export async function getSandboxTotalSize() { // #ifdef APP-PLUS const t0 = Date.now() - const tNative = { - start: Date.now() - } try { if (plus.os.name === 'Android') { + // native 快路径(StorageStatsManager = 系统"存储"口径 APK+data+cache,O(1))。 + // ★ 2026-08-03:去掉 liberate 分流——getAppBytes 修成三部分求和后通用; + // 但仍可能被 proxy 返回假值(21MB vs 系统 413MB),用 MIN_NATIVE_BYTES 阈值拦截降级。 const native = await queryAndroidAppBytes() - if (native.supported && typeof native.bytes === 'number') { + if (native.supported && typeof native.bytes === 'number' && native.bytes >= MIN_NATIVE_BYTES) { return native.bytes } + console.warn(`[ioPath] native bytes implausible (${native.bytes})`) + // ★ 2026-08-03:native 不可靠 → 完整 data 目录扫描(对齐系统"存储"413MB,含 databases/cache) + const full = await _scanFullAppUsage() + if (full > 0) return full } + // iOS / 最后降级:APK 大小 + Σ4 沙盒根 + const apkSize = await getAndroidApkSize() const rootTypes = [ plus.io.PRIVATE_DOC, plus.io.PRIVATE_WWW, @@ -622,19 +747,27 @@ export async function getSandboxTotalSize() { const result = await Promise.race([ _sumDirSizeByRootType(rootType), new Promise((_, rej) => - setTimeout(() => rej(new Error('rootType timeout')), 8000) + // ★ 2026-08-03 用户反馈超时,放宽 8s → 25s:native 降级时 _www/业务目录树 + // 在 Android 12 phantom entries 设备上递归扫描可能很慢 + setTimeout(() => rej(new Error('rootType timeout')), 25000) ), ]).catch((e) => { console.warn( - `[ioPath] _statDir failed for rootType=${rootType} (took ${Date.now() - tRoot}ms):`, + `[ioPath] root sum failed for rootType=${rootType} (took ${Date.now() - tRoot}ms):`, e?.message) return 0 }) return result }) ) - const total = sizes.reduce((sum, s) => sum + s, 0) - return total + const sandboxBytes = sizes.reduce((sum, s) => sum + s, 0) + // ★ 2026-08-03 诊断:输出 apkSize + 每根构成,便于定位"为什么是 X MB" + console.info( + `[ioPath] getSandboxTotalSize done (${Date.now() - t0}ms): apkSize=${Math.round(apkSize / 1048576)}MB, ` + + `roots=[${sizes.map((s) => Math.round(s / 1048576) + 'MB').join(', ')}], ` + + `total=${Math.round((apkSize + sandboxBytes) / 1048576)}MB` + ) + return apkSize + sandboxBytes } catch (e) { console.warn('[ioPath] getSandboxTotalSize failed:', e?.message, `(${Date.now() - t0}ms)`) return 0 @@ -698,6 +831,104 @@ async function _isPrivateWwwAvailable() { // #endif } +// ── 4 根扫描(getSandboxTotalSize)递归求和防护常量 ── +// _www / PRIVATE_DOC 是目录树,必须递归才能数全。 +// _statDir 只扫顶层是给 sandbox-tmp / sandbox-residual 这种扁平结构用的,不能用于根扫描。 +const MAX_RECURSIVE_DEPTH = 8 +// 2026-08-03:全 data 目录扫描(_scanFullAppUsage)要覆盖 www(~500) + doc + cache + databases, +// 2000 会截断导致低估;放到 5000(扫描慢设备仍有 20s 兜底) +const MAX_RECURSIVE_FILES = 5000 + +/** + * 判断 entry 是否为真实条目(过滤 Android 12 readEntries 返回的 phantom entries) + * 真实条目 fullPath 是带 '/' 的路径(如 '_doc/xxx'、'_www/static/xxx')或长 file:// URL; + * phantom 条目 fullPath 常为 undefined / 空 / 短垃圾串,其 file()/toURL() 可能返回假 size。 + * 与 scanSandboxResidualAll / clearSandboxResidualGlobal 里的 isReal 判定同一思路, + * 这里做根无关(_www/_documents 等其它根也要用)。 + */ +/** + * 取 entry 的真实路径标识(fullPath 优先,退化用 toURL()) + * 同一底层文件的 phantom 重复条目会返回相同 key,用于按路径去重。 + */ +function _entryKey(entry) { + try { + return (entry && (entry.fullPath || (entry.toURL && entry.toURL()) || '')) || '' + } catch (e) { + return '' + } +} + +// data 目录顶层子目录名(_scanFullAppUsage resolve 到它们;fullPath 可能是短名而非绝对路径) +const DATA_DIR_TOPS = new Set(['files', 'databases', 'cache', 'code_cache', 'shared_prefs', 'no_backup']) + +function _isRealEntry(entry) { + const fp = _entryKey(entry) + if (typeof fp !== 'string' || fp.length === 0) return false + // 真实条目路径含 '/'('_doc/a.png'、'/data/user/0//files'); + // 个别 toURL() 变体是长 URL,无 '/' 也按真实处理 + if (fp.includes('/') || fp.length > 20) return true + // 全目录扫描顶层:data 目录子目录可能只有短名('files'/'databases'/...),放行 + return DATA_DIR_TOPS.has(fp) +} + +/** + * 递归求和目录下所有文件真实字节(getSandboxTotalSize 的 4 根扫描用) + * + * 与 _statDir(只扫顶层)的区别:递归进子目录。liberate 的 _www 是 APK 解压出的 + * 目录树,只扫顶层会把 static/、uni_modules/ 等子目录整个漏掉,值偏小。 + * + * 防护(task #16/#22/#33/#35 踩坑后加的,避免 Android 12 phantom entries 打爆): + * - _listDir 自带 readEntries 上限/超时(MAX_READ_ATTEMPTS=15),单目录不会死循环 + * - MAX_RECURSIVE_DEPTH:递归深度上限(_www/PRIVATE_DOC 正常深度 2-4,8 足够) + * - MAX_RECURSIVE_FILES:文件总数上限(state 按引用共享),超限截断防慢设备撞 8s 兜底 + * - 同层同名 dedup:Android 12 readEntries 会重复返回同名 phantom entries + * + * @param {DirectoryEntry} dirEntry + * @param {number} depth 递归深度(内部用) + * @param {{files:number, bytes:number}} state 全局累计(文件数上限用,内部用) + * @returns {Promise<{bytes:number, count:number}>} + */ +async function _sumDirSizeRecursive(dirEntry, depth = 0, state) { + // #ifdef APP-PLUS + if (!state) state = { files: 0, bytes: 0 } + if (depth > MAX_RECURSIVE_DEPTH) return { bytes: 0, count: 0 } + const entries = await _listDir(dirEntry) + const seenDirs = new Set() + const seenFiles = new Set() + let bytes = 0 + let count = 0 + for (const e of entries) { + if (state.files >= MAX_RECURSIVE_FILES) break + // 过滤 phantom entries(file()/toURL() 可能返回假 size,会把字节数撑大) + if (!_isRealEntry(e)) continue + // 按真实路径去重(而非 name):Android 12 phantom 可能"不同名同 path"或"同名不同 path", + // 只按 name 去重会漏掉同一底层文件的重复条目 → 字节虚高 + const key = _entryKey(e) + if (e.isDirectory) { + if (seenDirs.has(key)) continue + seenDirs.add(key) + // eslint-disable-next-line no-await-in-loop + const sub = await _sumDirSizeRecursive(e, depth + 1, state) + bytes += sub.bytes + count += sub.count + } else if (e.isFile) { + if (seenFiles.has(key)) continue + seenFiles.add(key) + // eslint-disable-next-line no-await-in-loop + const sz = await _getRealFileSize(e) + bytes += sz + count++ + state.files++ + state.bytes += sz + } + } + return { bytes, count } + // #endif + // #ifndef APP-PLUS + return { bytes: 0, count: 0 } + // #endif +} + /** * 取指定 sandbox 根类型对应的 DirectoryEntry,递归求总字节 * 不 memoize(每次按需取,避免 4 个根互相干扰;只在 getSandboxTotalSize 单次调用) @@ -714,10 +945,10 @@ async function _sumDirSizeByRootType(rootType) { const safety = setTimeout(() => { if (!settled) { settled = true - console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} safety timeout 6s`) - reject(new Error('safety timeout 6s')) + console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} safety timeout 20s`) + reject(new Error('safety timeout 20s')) } - }, 6000) + }, 20000) plus.io.requestFileSystem( rootType, async (fs) => { @@ -725,10 +956,16 @@ async function _sumDirSizeByRootType(rootType) { settled = true clearTimeout(safety) try { - const sub = await _statDir(fs.root) + // ★ 2026-08-03 双计修复(345.8MB 虚高根因): + // PRIVATE_DOC 只扫顶层——其业务子目录由 sandbox-tmp / sandbox-residual 分类 + // 在 appUsedBytes 里作为 sandboxOnlyBytes 单独计;这里再递归会与它们双计。 + // PRIVATE_WWW(liberate 解压资源)没有任何分类覆盖,必须递归数全子目录。 + const sub = rootType === plus.io.PRIVATE_DOC + ? await _statDir(fs.root) + : await _sumDirSizeRecursive(fs.root) resolve(sub.bytes) } catch (e) { - console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} _statDir failed:`, e?.message) + console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} sum failed:`, e?.message) reject(e) } },