// 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 // 走 Context.getApplicationInfo()(App.vue#setPermissions 验证过可工作) // 不走 PackageManager.getApplicationInfo(pkg, 0): // plus.android proxy 对 PackageManager 的 getApplicationInfo 重载方法暴露不稳定, // 会报 "pm.getApplicationInfo is not a function"。 // Context.getApplicationInfo() 返回当前应用的 ApplicationInfo,正是我们想要的。 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 } /** * 计算"app 已用空间" = Android APK 安装包大小 + 4 个 sandbox 根目录下所有文件总字节 * 在 iOS / H5 上只算 sandbox(iOS .app bundle 沙盒外不可访问,按 sandbox 估算) * 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes * * 4 个 sandbox 根: * PRIVATE_DOC (2) _doc 私有文档(用户数据/缓存)— 主用,绝大多数占用在此 * PRIVATE_WWW (1) _www 私有资源(仅 manifest.json 设 runmode=liberate 时才有内容) * PUBLIC_DOCUMENTS (3) _documents 公共文档(多 5+ App 共享) * PUBLIC_DOWNLOADS (4) _downloads 公共下载(多 5+ App 共享) * * PRIVATE_WWW 行为说明: * - 非 liberate 模式:沙盒根不存在 → requestFileSystem 两个回调都不触发 → 2s 超时降级为 0 * - liberate 模式:首次启动资源从 APK 解压到 _www → 可正常遍历求和 * (注:liberate 模式下 _www 内容是 APK 资源的解压副本,理论上是双倍计数,但用户明确要求保留 4 个根遍历) * * 单个根失败/超时 → 0(不影响其他根) */ export async function getSandboxTotalSize() { // #ifdef APP-PLUS try { // Android: APK 大小 + 4 个沙盒根目录运行时数据 const apkSize = await getAndroidApkSize() const rootTypes = [ plus.io.PRIVATE_DOC, plus.io.PRIVATE_WWW, plus.io.PUBLIC_DOCUMENTS, plus.io.PUBLIC_DOWNLOADS, ] // 并行遍历 4 个沙盒根(互不依赖),单根失败/超时 → 0 // 2s 超时:防御性,PRIVATE_WWW 在非 liberate 模式下挂死靠它兜底 const sizes = await Promise.all( rootTypes.map(async (rootType) => Promise.race([ _sumDirSizeByRootType(rootType), new Promise((_, rej) => setTimeout(() => rej(new Error('rootType timeout')), 2000) ), ]).catch((e) => { console.warn(`[ioPath] _sumDirSize failed for rootType=${rootType}:`, e?.message) return 0 }) ) ) const sandboxBytes = sizes.reduce((sum, s) => sum + s, 0) return apkSize + sandboxBytes } catch (e) { console.warn('[ioPath] getSandboxTotalSize failed:', e?.message) return 0 } // #endif // #ifndef APP-PLUS return 0 // #endif } /** * 取指定 sandbox 根类型对应的 DirectoryEntry,递归求总字节 * 不 memoize(每次按需取,避免 4 个根互相干扰;只在 getSandboxTotalSize 单次调用) */ function _sumDirSizeByRootType(rootType) { // #ifdef APP-PLUS return new Promise((resolve, reject) => { plus.io.requestFileSystem( rootType, async (fs) => { try { resolve(await _sumDirSize(fs.root)) } catch (e) { reject(e) } }, (e) => reject(e) ) }) // #endif // #ifndef APP-PLUS return Promise.resolve(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 } /** * 获取设备级存储信息(Native.js:Android 走 StatFs,iOS 走 NSFileManager) * 不需要任何 native plugin;非 APP-PLUS 平台或失败时返回安全零值 * 注:与项目已有 getAndroidApkSize / useShare.js 的 Native.js 模式 1:1 对齐 * @returns {Promise<{totalBytes:number, freeBytes:number}>} */ export function getDeviceStorageInfo() { return new Promise((resolve) => { // #ifdef APP-PLUS try { if (typeof plus === 'undefined') { return resolve({ totalBytes: 0, freeBytes: 0 }) } if (plus.os.name === 'Android') { // Android:用 StatFs 查 /data 分区(即系统"内部存储") // 不需要任何权限;Android 10+ 分区存储不影响 StatFs(StatFs 查的是卷级元数据) const Environment = plus.android.importClass('android.os.Environment') const StatFs = plus.android.importClass('android.os.StatFs') const dataDir = Environment.getDataDirectory() const stat = new StatFs(dataDir.getPath()) // getBlockSizeLong 是 API 18+,project minSdkVersion=21 保证可用 // plus.android 对 *Long 变体代理有时不稳定 → parseFloat 兜底 const blockSize = parseFloat(plus.android.invoke(stat, 'getBlockSizeLong')) const totalBlocks = parseFloat(plus.android.invoke(stat, 'getBlockCountLong')) const freeBlocks = parseFloat(plus.android.invoke(stat, 'getAvailableBlocksLong')) if (blockSize > 0 && totalBlocks > 0) { resolve({ totalBytes: blockSize * totalBlocks, freeBytes: blockSize * freeBlocks, }) } else { console.warn('[ioPath] getDeviceStorageInfo (Android) got non-positive values') resolve({ totalBytes: 0, freeBytes: 0 }) } } else if (plus.os.name === 'iOS') { // iOS:用 NSFileManager.attributesOfFileSystem 查系统卷 // NSFileSystemSize / NSFileSystemFreeSize 是 public API(参照 useShare.js 调用风格) const fm = plus.ios.invoke('NSFileManager', 'defaultManager') if (!fm) { console.warn('[ioPath] getDeviceStorageInfo (iOS) fm is null') return resolve({ totalBytes: 0, freeBytes: 0 }) } const attrs = plus.ios.invoke(fm, 'attributesOfFileSystemForPath:error:', '/') if (attrs) { const total = parseFloat(attrs.plusGetAttribute('NSFileSystemSize')) || 0 const free = parseFloat(attrs.plusGetAttribute('NSFileSystemFreeSize')) || 0 // 必须 deleteObject,否则 NSObject proxy 会泄露(参照 useShare.js 清理模式) plus.ios.deleteObject(attrs) plus.ios.deleteObject(fm) resolve({ totalBytes: total, freeBytes: free }) } else { plus.ios.deleteObject(fm) console.warn('[ioPath] getDeviceStorageInfo (iOS) attrs is null') resolve({ totalBytes: 0, freeBytes: 0 }) } } else { // Harmony / 其它平台:暂不支持,返回 0 resolve({ totalBytes: 0, freeBytes: 0 }) } } catch (e) { console.warn('[ioPath] getDeviceStorageInfo failed:', e?.message) resolve({ totalBytes: 0, freeBytes: 0 }) } // #endif // #ifndef APP-PLUS resolve({ totalBytes: 0, freeBytes: 0 }) // #endif }) }