560 lines
18 KiB
JavaScript
560 lines
18 KiB
JavaScript
// 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/<pkg>/.HBuilder/documents/),
|
||
// 分区存储机制下不可写,会报 "targetSdkVersion设置>=29后在Android10+系统设备
|
||
// 不支持当前路径。请更改为应用运行路径!"。
|
||
//
|
||
// 唯一可写位置:应用沙盒内部存储 = plus.io.PRIVATE_DOC (常量 2),
|
||
// 物理路径 file:///data/user/0/<pkg>/files/apps/<APPID>/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<DirectoryEntry>}
|
||
*/
|
||
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<DirectoryEntry>}
|
||
*/
|
||
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<string>} 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/<...>/<fileName>
|
||
// #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<number>} 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 安装包大小 + 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
|
||
}
|
||
|
||
/**
|
||
* 获取设备级存储信息(HTML5+ 标准 API,Android/iOS 均支持)
|
||
* 不需要任何 native plugin;非 APP-PLUS 平台或失败时返回安全零值
|
||
* 注意:单位为 KB(plus.io 原始返回),调用方按需 * 1024 转字节
|
||
* @returns {Promise<{totalBytes:number, freeBytes:number}>}
|
||
*/
|
||
export function getDeviceStorageInfo() {
|
||
return new Promise((resolve) => {
|
||
// #ifdef APP-PLUS
|
||
try {
|
||
plus.io.getStorageInfo({
|
||
success: (res) => {
|
||
// 调试日志:打印 plus.io.getStorageInfo 的真实返回 shape
|
||
console.log('[ioPath][debug] plus.io.getStorageInfo res =', JSON.stringify(res), 'keys =', res ? Object.keys(res) : null)
|
||
resolve({
|
||
totalBytes: Number(res?.totalSize) || 0,
|
||
freeBytes: Number(res?.availableSize) || 0,
|
||
})
|
||
},
|
||
fail: (e) => {
|
||
console.warn('[ioPath] getStorageInfo failed:', e?.message)
|
||
resolve({ totalBytes: 0, freeBytes: 0 })
|
||
},
|
||
})
|
||
} catch (e) {
|
||
console.warn('[ioPath] getStorageInfo threw:', e?.message)
|
||
resolve({ totalBytes: 0, freeBytes: 0 })
|
||
}
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
resolve({ totalBytes: 0, freeBytes: 0 })
|
||
// #endif
|
||
})
|
||
}
|