// frontend/utils/ioPath.js // Plus.io 路径抽象层 — 集中处理 Android 10+ 分区存储适配 // // 背景:Android 10+ targetSdkVersion >= 29 时,plus.io.resolveLocalFileSystemURL // 传入字符串 '_doc/' 在新版 HBuilderX 中被解析到 plus.io.PUBLIC_DOCUMENTS // (外部存储 /storage/emulated/0/Android/data//.HBuilder/documents/), // 分区存储机制下不可写,会报 "targetSdkVersion设置>=29后在Android10+系统设备 // 不支持当前路径。请更改为应用运行路径!"。 // // 唯一可写位置:应用沙盒内部存储 = plus.io.PRIVATE_DOC (常量 2), // 物理路径 file:///data/user/0//files/apps//doc // // 已知坑:plus.nativeObj.Bitmap.save 即使接受 '_doc/xxx' 字符串,在某些 Android 10+ // 设备上回调成功但实际未写入(size=0 / target=null)。因此 bitmap.save 等原生 API // 一律用 entry.toLocalURL() 拿 file:// 绝对路径,而不是 '_doc/xxx'。 // // 使用规范: // 1. 拿到根 DirectoryEntry 用 getSandboxRootDir() // 2. 逐级 getDirectory 创建子目录(不要用字符串拼接路径) // 3. 拿 file:// 绝对路径用 getSandboxFileUri(subdirParts, fileName) // 4. 调试日志里用 SANDBOX_DISPLAY_PREFIX 给人看,所有 plus.io 调用都通过本模块的函数 /** * Android 10+ 唯一可写的应用沙盒 doc 根常量 * 等同于 plus.io.PRIVATE_DOC,但避免魔法数字,集中解释出处 */ export const SANDBOX_ROOT = 2 // plus.io.PRIVATE_DOC /** * 把业务逻辑用的"子目录相对路径"解析为"按 / 切分的段" * 用于逐级 getDirectory * @param {string} relativePath - 如 'preload' 或 'preload/guest' * @returns {string[]} */ export function splitRelativePath(relativePath) { return String(relativePath || '').split('/').filter(Boolean) } /** * 异步获取沙盒 doc 根 DirectoryEntry(单例缓存) * 调用方拿到 root 后用 getDirectory 逐级创建业务子目录 */ let _rootDirPromise = null export function getSandboxRootDir() { // #ifdef APP-PLUS if (_rootDirPromise) return _rootDirPromise _rootDirPromise = new Promise((resolve, reject) => { plus.io.requestFileSystem( SANDBOX_ROOT, (fs) => resolve(fs.root), (err) => { _rootDirPromise = null // 失败重置缓存,允许重试 reject(err) } ) }) return _rootDirPromise // #endif // #ifndef APP-PLUS // H5/小程序:返回伪 root,通过 uni.getFileSystemManager 路径字符串操作 // 不缓存(各端语义不同,缓存反而可能错) return Promise.reject(new Error('sandbox dir only available on APP-PLUS')) // #endif } /** * 在沙盒根下逐级创建子目录,返回最深一层的 DirectoryEntry * @param {string[]} parts - 路径段数组,来自 splitRelativePath * @returns {Promise} */ export async function ensureSandboxSubdir(parts) { // #ifdef APP-PLUS if (!Array.isArray(parts) || parts.length === 0) { throw new Error('ensureSandboxSubdir: parts must be non-empty array') } const root = await getSandboxRootDir() return new Promise((resolve, reject) => { let currentEntry = root const step = (idx) => { if (idx >= parts.length) return resolve(currentEntry) currentEntry.getDirectory( parts[idx], { create: true, exclusive: false }, (dirEntry) => { currentEntry = dirEntry step(idx + 1) }, (err) => reject(err) ) } step(0) }) // #endif // #ifndef APP-PLUS // H5/小程序路径由各端 fs.mkdirSync 处理,这里返回 null 让调用方自行降级 return Promise.resolve(null) // #endif } /** * 在沙盒子目录下创建/获取一个文件,返回 FileEntry * 拿到 FileEntry 后: * - 给 bitmap.save 等原生 API 用 entry.toLocalURL() 拿 file:// 绝对路径(更可靠,避免 _doc/ 字符串在 Android 10+ 写失败) * - 给 uni.uploadFile 用 entry.toLocalURL() 或 entry.fullPath(uni 在 app 端会识别) * * @param {string[]} subdirParts - 子目录段数组(不含文件名) * @param {string} fileName - 文件名(不含路径) * @param {boolean} create - true=不存在则创建,false=仅获取 * @returns {Promise} */ export async function getSandboxFile(subdirParts, fileName, create = true) { // #ifdef APP-PLUS const dir = await ensureSandboxSubdir(subdirParts) return new Promise((resolve, reject) => { dir.getFile( fileName, { create, exclusive: false }, (entry) => resolve(entry), (err) => reject(err) ) }) // #endif // #ifndef APP-PLUS return Promise.resolve(null) // #endif } /** * 一次性"确保子目录存在 + 创建/获取文件 + 拿到可用的 file:// URI" * 给 bitmap.save / uni.uploadFile / uni.getImageInfo 等需要真实路径的 API 用 * * @param {string[]} subdirParts - 子目录段数组 * @param {string} fileName - 文件名 * @returns {Promise} file:// 形式的绝对路径 */ export async function getSandboxFileUri(subdirParts, fileName) { // #ifdef APP-PLUS const entry = await getSandboxFile(subdirParts, fileName, true) return entry.toLocalURL() // file:///data/user/0/.../doc/<...>/ // #endif // #ifndef APP-PLUS const parts = [...subdirParts, fileName] return Promise.resolve(parts.join('/')) // #endif } /** * 日志/追踪用的展示前缀 — 不是文件路径,只是给人看的"[sandbox doc] xxx"格式 */ export const SANDBOX_DISPLAY_PREFIX = '[sandbox:doc] ' /** * 删除沙盒根下指定子目录(含其中所有文件、子目录) * 目录不存在不报错(视为成功) * * 调用场景: * - 上传成功后清理 bitmap.save 临时文件 * - 用户登出时清理所有业务临时目录(隐私) * * @param {string[]} subdirParts - 子目录段数组(从沙盒根算起) * @returns {Promise<{deleted: boolean, reason?: string}>} */ export async function clearSandboxSubdir(subdirParts) { // #ifdef APP-PLUS if (!Array.isArray(subdirParts) || subdirParts.length === 0) { return { deleted: false, reason: 'invalid subdirParts' } } try { const root = await getSandboxRootDir() return await new Promise((resolve) => { // 逐级 resolve,只创建不创建(create: false) const step = (idx, currentEntry) => { if (idx >= subdirParts.length) { // 到了目标目录,递归删除 currentEntry.removeRecursively( () => resolve({ deleted: true }), (err) => resolve({ deleted: false, reason: err?.message || 'removeRecursively failed' }) ) return } currentEntry.getDirectory( subdirParts[idx], { create: false }, (nextEntry) => step(idx + 1, nextEntry), (err) => resolve({ deleted: false, reason: 'not_found' }) // 路径不存在 = ok ) } step(0, root) }) } catch (e) { return { deleted: false, reason: e?.message || 'exception' } } // #endif // #ifndef APP-PLUS return Promise.resolve({ deleted: false, reason: 'app-only' }) // #endif } /** * 删除沙盒根下"所有业务子目录"(登出时全清用) * 白名单(preload/share/image)保留,其它全删 */ const PROTECTED_SUBDIRS = new Set(['preload', 'share', 'image']) export async function clearAllSandboxTmpDirs() { // #ifdef APP-PLUS try { const root = await getSandboxRootDir() const entries = await new Promise((resolve) => { const reader = root.createReader() const collected = [] const readAll = () => { reader.readEntries( (es) => { if (es.length === 0) return resolve(collected) collected.push(...es) readAll() }, () => resolve(collected) ) } readAll() }) const results = [] for (const e of entries) { if (!e.isDirectory) continue if (PROTECTED_SUBDIRS.has(e.name)) continue // 逐个清掉(忽略失败) // eslint-disable-next-line no-await-in-loop const r = await new Promise((resolve) => { e.removeRecursively( () => resolve({ name: e.name, deleted: true }), (err) => resolve({ name: e.name, deleted: false, reason: err?.message }) ) }) results.push(r) } return { cleared: results.filter((r) => r.deleted).length, results } } catch (e) { return { cleared: 0, reason: e?.message || 'exception', results: [] } } // #endif // #ifndef APP-PLUS return Promise.resolve({ cleared: 0, results: [], reason: 'app-only' }) // #endif } /** * 清理沙盒根下"所有业务子目录的 tmp/ 目录"(启动时清理历史残留) * 直接 removeRecursively 删整个 tmp/ 目录(下次上传 getSandboxFileUri 会自动重建) * * 调用场景:App.vue#onLaunch,用户升级新包后第一次启动时清理旧版本产生的临时文件 * * 之所以用 removeRecursively 而非 f.remove(): * plus.io.FileEntry.remove() 在某些 HBuilderX + Android 10+ 组合下会静默失败 * (回调 ok 但文件还在),removeRecursively 对目录操作更可靠。 * * @returns {Promise<{scanned: number, deleted: number, dirs: string[]}>} */ export async function clearAllSandboxTmpFiles() { // #ifdef APP-PLUS try { const root = await getSandboxRootDir() // 1. 拿到所有业务子目录 const topEntries = await new Promise((resolve) => { const reader = root.createReader() const collected = [] const readAll = () => { reader.readEntries( (es) => { if (es.length === 0) return resolve(collected) collected.push(...es) readAll() }, () => resolve(collected) ) } readAll() }) let scanned = 0 let deleted = 0 const cleanedDirs = [] for (const top of topEntries) { if (!top.isDirectory) continue if (PROTECTED_SUBDIRS.has(top.name)) continue // 2. 拿到每个业务子目录下的 tmp/ 子目录(不存在则跳过) // eslint-disable-next-line no-await-in-loop const tmpEntry = await new Promise((resolve) => { top.getDirectory( 'tmp', { create: false }, (entry) => resolve(entry), () => resolve(null) ) }) if (!tmpEntry) continue scanned++ // 3. 整个 tmp/ 目录递归删除(下次上传 getSandboxFileUri 会自动重建) // eslint-disable-next-line no-await-in-loop const ok = await new Promise((resolve) => { tmpEntry.removeRecursively( () => resolve(true), () => resolve(false) ) }) if (ok) { deleted++ cleanedDirs.push(`${top.name}/tmp`) } } console.log('[ioPath] clearAllSandboxTmpFiles:', { scanned, deleted, dirs: cleanedDirs }) return { scanned, deleted, dirs: cleanedDirs } } catch (e) { console.warn('[ioPath] clearAllSandboxTmpFiles failed:', e?.message) return { scanned: 0, deleted: 0, dirs: [], error: e?.message } } // #endif // #ifndef APP-PLUS return Promise.resolve({ scanned: 0, deleted: 0, dirs: [] }) // #endif } /** * 通过 plus.android 读取 APK 安装包大小(Android 专用,绕开 sandbox 限制) * 在 iOS / H5 / 小程序上返回 0(调用方应改用 sandbox 大小) * @returns {Promise} APK 字节数 */ async function getAndroidApkSize() { // #ifdef APP-PLUS try { // 仅 Android if (typeof plus === 'undefined' || !plus.android) return 0 const main = plus.android.runtimeMainActivity() if (!main) return 0 const Context = plus.android.importClass('android.content.Context') const FLAG_PKG_DATA = Context.PM_FLAGS || 0 // 不一定存在 const pm = main.getPackageManager() const pkg = main.getPackageName() // getPackageSizeInfo 是异步回调,这里改用 sourceDir 同步读 const appInfo = pm.getApplicationInfo(pkg, 0) const sourceDir = appInfo.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 } /** * 计算"app 已用空间" = Android APK 安装包大小 + sandbox 运行时数据大小 * 在 iOS / H5 上只算 sandbox(iOS bundle 不易从 js 取大小,按 sandbox 估算) * 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes */ export async function getSandboxTotalSize() { // #ifdef APP-PLUS try { // Android: APK 大小 + sandbox doc 目录运行时数据 const apkSize = await getAndroidApkSize() let sandboxBytes = 0 try { const root = await getSandboxRootDir() sandboxBytes = await _sumDirSize(root) } catch (e) { console.warn('[ioPath] _sumDirSize failed:', e?.message) } return apkSize + sandboxBytes } catch (e) { console.warn('[ioPath] getSandboxTotalSize failed:', e?.message) return 0 } // #endif // #ifndef APP-PLUS return 0 // #endif } /** * 扫描 sandbox doc 下"所有业务子目录的 tmp/ 文件总字节数"(不包含白名单 preload/share/image) * 用于 sandboxTmpHandler.computeSize * * 与 getSandboxTotalSize 的区别: * - getSandboxTotalSize: 沙盒 doc 下所有文件(含 preload/share/image 白名单) * - scanSandboxTmpFiles: 只统计 tmp/ 子目录(用于清理项;与 clearAllSandboxTmpFiles 一致) */ export async function scanSandboxTmpFiles() { // #ifdef APP-PLUS try { const root = await getSandboxRootDir() const topEntries = await _listDir(root) let total = 0 for (const top of topEntries) { if (!top.isDirectory) continue if (PROTECTED_SUBDIRS.has(top.name)) continue const tmpEntry = await new Promise((resolve) => { top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null)) }) if (!tmpEntry) continue total += await _sumDirSize(tmpEntry) } return total } catch (e) { console.warn('[ioPath] scanSandboxTmpFiles failed:', e?.message) return 0 } // #endif // #ifndef APP-PLUS return 0 // #endif } /** * 计数 sandbox doc 下 tmp/ 子目录的文件总数(reviewer P0修正:导出供 sandboxTmpHandler 使用) * 与 scanSandboxTmpFiles 范围一致(仅 tmp/,不含白名单 preload/share/image) */ export async function countSandboxTmpFiles() { // #ifdef APP-PLUS try { const root = await getSandboxRootDir() const topEntries = await _listDir(root) let count = 0 const walk = async (dir) => { const entries = await _listDir(dir) for (const e of entries) { if (e.isFile) count++ else if (e.isDirectory) await walk(e) } } for (const top of topEntries) { if (!top.isDirectory) continue if (PROTECTED_SUBDIRS.has(top.name)) continue const tmpEntry = await new Promise((resolve) => { top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null)) }) if (!tmpEntry) continue await walk(tmpEntry) } return count } catch (e) { console.warn('[ioPath] countSandboxTmpFiles failed:', e?.message) return 0 } // #endif // #ifndef APP-PLUS return 0 // #endif } /** * 递归求和目录下所有文件大小(bytes) */ async function _sumDirSize(dirEntry) { // #ifdef APP-PLUS let total = 0 let entries try { entries = await _listDir(dirEntry) } catch (e) { // 单个目录读失败 → 跳过整个目录(不影响兄弟目录) console.warn('[ioPath] _sumDirSize read failed for a dir, skipping:', e?.message) return total } for (const entry of entries) { if (entry.isDirectory) { total += await _sumDirSize(entry) } else if (entry.isFile) { try { const file = await new Promise((res, rej) => entry.file(res, rej)) total += (file.size || 0) } catch (e) { /* skip individual file */ } } } return total // #endif // #ifndef APP-PLUS return 0 // #endif } /** * 列出目录所有 entry(DirectoryEntry / FileEntry) */ async function _listDir(dirEntry) { // #ifdef APP-PLUS return new Promise((resolve, reject) => { const reader = dirEntry.createReader() const collected = [] const readAll = () => { reader.readEntries( (es) => { if (es.length === 0) return resolve(collected) collected.push(...es) readAll() }, (err) => reject(err) ) } readAll() }) // #endif // #ifndef APP-PLUS return [] // #endif }