648 lines
22 KiB
JavaScript
648 lines
22 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 安装包大小 + 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
|
||
})
|
||
}
|