test:鸿蒙工程包导出增加产出自检与单元测试
手写 ZIP 编码器的典型 bug 都是静默的 —— 包能生成、看着正常, 但某些机器上 解不开或解出畸形路径。开发期已真实踩过两次(中央目录偏移漏算文件数据长度、 localOffset 记成数据区起点), 两次都只有靠 unzip -t 才发现, 因此加两层保障。 一、脚本内产出自检 (每次导出都跑) 新增 verifyZipFile(), 把写好的 zip 重新解析一遍, 任一项不符直接抛错、不输出 「完成」。刻意用独立解析实现, 不复用写入端逻辑, 避免自证其说: - EOCD / 中央目录: 签名、条目数、偏移与长度边界一致 - localOffset: 每条必须真的指向本地文件头, 且文件名与中央目录一致 - CRC: 逐条解压后重算 CRC32 比对 - 条目名: 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8 - 内容断言: 必需文件齐全; node_modules/build/.preview 等排除项不得混入 二、单元测试 tests/pack-harmony-project.test.js (16 条) 正向用例写夹具目录打包再独立解析还原, 校验内容/目录/中文名/零字节/空目录/ 可压缩与不可压缩内容; 反向用例故意篡改 zip 断言自检能抓到 —— 反向用例一旦 失效自检就形同虚设, 故必须保持通过。 过程中自检抓到自身一条误判: 排除校验原先用子串匹配, 把合法源码 entry/src/ohosTest/ets/test/Ability.test.ets 当成 .test 构建产物。已改为按 路径分段精确匹配, 并加了一条反向用例锁定。 同时修掉两个隐患: stats 是模块级累加器(同进程重复调用会翻倍), main() 原先 无条件执行(require 脚本就会跑一遍打包), 现加 require.main 守卫并导出内部 实现供测试使用。 已验证: 本测试文件 16/16 通过, npm test 全量 17 个测试文件通过, 实打包 2.99MiB 包自检通过且 unzip -t 交叉验证无错误。
This commit is contained in:
378
tests/pack-harmony-project.test.js
Normal file
378
tests/pack-harmony-project.test.js
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* scripts/pack-harmony-project.js 单元测试
|
||||
*
|
||||
* 跑法:
|
||||
* node --test tests/pack-harmony-project.test.js
|
||||
*
|
||||
* 为什么这个脚本需要测试:
|
||||
* 它**手写了 ZIP 编码格式**(没有用系统命令 / 第三方库), 而这类代码的典型 bug
|
||||
* 都是静默的 —— 包能生成、看着正常, 但某些机器上解不开或解出畸形路径。
|
||||
* 历史上已经踩过两次:
|
||||
* 1. 漏算文件数据长度, 中央目录偏移偏小 -> "N extra bytes at beginning"
|
||||
* 2. localOffset 记成了数据区起点而非本地头起点 -> "bad zipfile offset"
|
||||
* 两者都不会抛异常, 只有真正解压/解析才会暴露。
|
||||
*
|
||||
* 所以测试分两类:
|
||||
* - 正向: 写出来的 zip 结构合法、内容可还原
|
||||
* - **反向**: 故意损坏 zip, 断言 verifyZipFile 真的能抓到
|
||||
* (否则自检可能形同虚设, 永远返回 true)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const fsp = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const {
|
||||
crc32,
|
||||
toDosDateTime,
|
||||
zipDirectory,
|
||||
verifyZipFile,
|
||||
shouldSkipDir,
|
||||
shouldSkipFile,
|
||||
EXCLUDE_DIRS,
|
||||
EXCLUDE_FILES,
|
||||
} = require('../scripts/pack-harmony-project');
|
||||
|
||||
/** 建临时目录, 测试结束自动清理 */
|
||||
function tmpDir(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-harmony-test-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** 建一个夹具目录, 内含各种体积/编码的文件 */
|
||||
function makeFixture(root) {
|
||||
fs.mkdirSync(path.join(root, 'AppScope'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'a/b/c'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'empty-dir'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'AppScope/app.json5'), '{"app":{"bundleName":"x"}}');
|
||||
fs.writeFileSync(path.join(root, 'a/b/c/deep.txt'), 'deep content');
|
||||
// 可压缩内容 (应走 deflate)
|
||||
fs.writeFileSync(path.join(root, 'a/repeatable.txt'), 'abc'.repeat(2000));
|
||||
// 不可压缩内容 (应退回 stored)
|
||||
fs.writeFileSync(path.join(root, 'a/random.bin'), require('node:crypto').randomBytes(4096));
|
||||
// 中文文件名
|
||||
fs.writeFileSync(path.join(root, 'a/中文名.txt'), '中文内容');
|
||||
// 零字节文件
|
||||
fs.writeFileSync(path.join(root, 'a/zero.txt'), '');
|
||||
return root;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* 独立 zip 读取器 (刻意不复用写入端实现, 避免自证其说)
|
||||
* 只实现测试需要的最小功能: 按条目名取回内容
|
||||
* ------------------------------------------------------------------------- */
|
||||
function readZipEntries(zipPath) {
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) {
|
||||
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
assert.ok(eocd !== -1, '应能找到 EOCD');
|
||||
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
const out = new Map();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
assert.equal(buf.readUInt32LE(p), 0x02014b50, `第 ${i} 条中央目录签名应正确`);
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const crc = buf.readUInt32LE(p + 16);
|
||||
const compSize = buf.readUInt32LE(p + 20);
|
||||
const uncompSize = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||
|
||||
if (!name.endsWith('/')) {
|
||||
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + lNameLen + lExtraLen;
|
||||
const raw = buf.subarray(start, start + compSize);
|
||||
const data = method === 0 ? raw : zlib.inflateRawSync(raw);
|
||||
assert.equal(data.length, uncompSize, `${name} 解压长度应匹配`);
|
||||
assert.equal(crc32(data), crc, `${name} CRC 应匹配`);
|
||||
out.set(name, data);
|
||||
} else {
|
||||
out.set(name, null); // 目录条目
|
||||
}
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ============================ 纯函数 ============================ */
|
||||
|
||||
test('crc32: 标准校验向量', () => {
|
||||
// CRC-32/ISO-HDLC 的标准 check value
|
||||
assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926);
|
||||
assert.equal(crc32(Buffer.from('')), 0);
|
||||
assert.equal(crc32(Buffer.from('a')), 0xe8b7be43);
|
||||
});
|
||||
|
||||
test('toDosDateTime: 字段不溢出且能反解', () => {
|
||||
const d = new Date(2026, 9, 17, 14, 30, 20);
|
||||
const { time, date } = toDosDateTime(d);
|
||||
assert.equal((date >> 9) & 0x7f, 2026 - 1980, '年份');
|
||||
assert.equal((date >> 5) & 0xf, 10, '月份');
|
||||
assert.equal(date & 0x1f, 17, '日');
|
||||
assert.equal((time >> 11) & 0x1f, 14, '时');
|
||||
assert.equal((time >> 5) & 0x3f, 30, '分');
|
||||
});
|
||||
|
||||
/* ======================= 排除规则 (回归锁定) ======================= */
|
||||
|
||||
test('shouldSkipDir: node_modules/oh_modules/build 等一律排除', () => {
|
||||
const opts = { withNodeModules: false, withCerts: true };
|
||||
for (const d of ['node_modules', 'oh_modules', 'build', '.preview', '.idea', '.hvigor', '.cxx', '.test', '.git']) {
|
||||
assert.equal(shouldSkipDir(d, opts, false), true, `${d} 应被排除`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldSkipDir: --with-node-modules 只放开根级 node_modules', () => {
|
||||
const opts = { withNodeModules: true, withCerts: true };
|
||||
assert.equal(shouldSkipDir('node_modules', opts, true), false, '根级 node_modules 应保留');
|
||||
assert.equal(shouldSkipDir('node_modules', opts, false), true, '非根级 node_modules 仍应排除');
|
||||
assert.equal(shouldSkipDir('oh_modules', opts, true), true, 'oh_modules 始终排除');
|
||||
});
|
||||
|
||||
test('shouldSkipDir: cert-profile 受 --no-certs 控制', () => {
|
||||
assert.equal(shouldSkipDir('cert-profile', { withCerts: true }, false), false);
|
||||
assert.equal(shouldSkipDir('cert-profile', { withCerts: false }, false), true);
|
||||
});
|
||||
|
||||
test('shouldSkipFile: local.properties / 编辑器临时文件排除', () => {
|
||||
for (const f of ['local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore', 'build.log']) {
|
||||
assert.equal(shouldSkipFile(f), true, `${f} 应被排除`);
|
||||
}
|
||||
// 回归锁定: 这些是**源码**, 不能误伤
|
||||
for (const f of ['module.json5', 'app.json', 'Ability.test.ets', 'List.test.js', 'oh-package.json5']) {
|
||||
assert.equal(shouldSkipFile(f), false, `${f} 不应被排除`);
|
||||
}
|
||||
});
|
||||
|
||||
/* ========================= zip 写入正确性 ========================= */
|
||||
|
||||
test('zipDirectory: 往返后内容与目录结构完全一致', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
|
||||
const res = await zipDirectory(fixture, zipPath);
|
||||
assert.equal(res.files, 6, '夹具应有 6 个文件 (app.json5/deep.txt/repeatable.txt/random.bin/中文名.txt/zero.txt)');
|
||||
|
||||
const entries = readZipEntries(zipPath);
|
||||
const prefix = 'mp-harmony/';
|
||||
const get = (rel) => entries.get(prefix + rel);
|
||||
|
||||
assert.equal(get('AppScope/app.json5').toString(), '{"app":{"bundleName":"x"}}');
|
||||
assert.equal(get('a/b/c/deep.txt').toString(), 'deep content');
|
||||
assert.equal(get('a/repeatable.txt').toString(), 'abc'.repeat(2000));
|
||||
assert.equal(get('a/中文名.txt').toString(), '中文内容');
|
||||
assert.equal(get('a/zero.txt').length, 0, '零字节文件应存在且为空');
|
||||
assert.ok(get('a/random.bin').equals(fs.readFileSync(path.join(fixture, 'a/random.bin'))));
|
||||
|
||||
// 目录条目: 空目录也要保留
|
||||
assert.ok(entries.has(prefix + 'empty-dir/'), '空目录条目应保留');
|
||||
assert.ok(entries.has(prefix + 'a/b/c/'), '嵌套目录条目应保留');
|
||||
});
|
||||
|
||||
test('zipDirectory: 条目名全部用正斜杠且置 UTF-8 标志位', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const flags = buf.readUInt16LE(p + 8);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||
|
||||
assert.ok(flags & 0x0800, `第 ${i} 条 ${name} 应置 UTF-8 标志位`);
|
||||
assert.ok(!name.includes('\\'), `第 ${i} 条 ${name} 不应含反斜杠`);
|
||||
assert.ok(!name.startsWith('/'), `第 ${i} 条 ${name} 不应是绝对路径`);
|
||||
assert.ok(!name.includes('//'), `第 ${i} 条 ${name} 不应有连续斜杠`);
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
});
|
||||
|
||||
test('verifyZipFile: 合法的包应通过', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
|
||||
assert.ok(res.files > 0);
|
||||
assert.equal(res.entries, res.files + res.dirs);
|
||||
});
|
||||
|
||||
/* ==================== 反向测试: 自检必须能抓到损坏 ==================== */
|
||||
|
||||
test('verifyZipFile: 数据区被篡改应报 CRC 错误', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
// 找一个文件条目的数据区并改掉一个字节
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const compSize = buf.readUInt32LE(p + 20);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||
if (!name.endsWith('/') && compSize > 10) {
|
||||
const start = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
|
||||
buf[start + 2] ^= 0xff; // 翻转数据区中的一个字节
|
||||
break;
|
||||
}
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
fs.writeFileSync(zipPath, buf);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/CRC|解压/,
|
||||
'数据被篡改时 verifyZipFile 必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: central directory 偏移错误应被拒绝 (回归: 曾漏算数据长度)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
const realOffset = buf.readUInt32LE(eocd + 16);
|
||||
buf.writeUInt32LE(realOffset + 1000, eocd + 16); // 偏移挪 1000 字节
|
||||
fs.writeFileSync(zipPath, buf);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/中央目录/,
|
||||
'中央目录偏移错误时 verifyZipFile 必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: localOffset 指向数据区而非本地头应被拒绝 (回归: 曾记成数据起点)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
// 把第一条**文件**记录的 localOffset 加 30 (= 跳到数据区)
|
||||
for (let i = 0; i < buf.readUInt16LE(eocd + 10); i++) {
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||
if (!name.endsWith('/')) {
|
||||
buf.writeUInt32LE(buf.readUInt32LE(p + 42) + 30, p + 42);
|
||||
break;
|
||||
}
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
fs.writeFileSync(zipPath, buf);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/localOffset|中央目录签名/,
|
||||
'localOffset 错误时 verifyZipFile 必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: 混入应排除目录应被拒绝 (回归: 曾用子串匹配误伤 ohosTest/ets/test/)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
// 手工塞一个不该存在的目录
|
||||
fs.mkdirSync(path.join(fixture, 'oh_modules/foo'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'oh_modules/foo/index.js'), 'x');
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/应排除/,
|
||||
'混入 oh_modules 时 verifyZipFile 必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: 合法源码文件不应被误判为应排除 (回归锁定)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
// 这些路径含 .test / test 字样, 但是**源码**, 必须放行
|
||||
fs.mkdirSync(path.join(fixture, 'entry/src/ohosTest/ets/test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'entry/src/ohosTest/ets/test/Ability.test.ets'), 'x');
|
||||
fs.mkdirSync(path.join(fixture, 'entry/src/test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'entry/src/test/List.test.ets'), 'x');
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
// 不应抛错 (夹具缺必需文件, 用 skipRequired 跳过该断言)
|
||||
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
|
||||
assert.ok(res.files > 0);
|
||||
});
|
||||
|
||||
test('verifyZipFile: --no-certs 时出现 cert-profile 应被拒绝', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
fs.mkdirSync(path.join(fixture, 'cert-profile/qq'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'cert-profile/qq/qq.p12'), 'FAKE');
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/nocerts|cert-profile/i,
|
||||
'--no-certs 下混入证书必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
/* ============================ 导出骨架 ============================ */
|
||||
|
||||
test('copyDir: 排除规则在真实复制中生效', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const src = path.join(root, 'src');
|
||||
const dest = path.join(root, 'dest');
|
||||
|
||||
fs.mkdirSync(path.join(src, 'keep'), { recursive: true });
|
||||
fs.mkdirSync(path.join(src, 'node_modules/pkg'), { recursive: true });
|
||||
fs.mkdirSync(path.join(src, 'build'), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, 'keep/a.txt'), 'a');
|
||||
fs.writeFileSync(path.join(src, 'node_modules/pkg/i.js'), 'i');
|
||||
fs.writeFileSync(path.join(src, 'build/out.txt'), 'o');
|
||||
fs.writeFileSync(path.join(src, 'local.properties'), 'sdk.dir=x');
|
||||
|
||||
const { copyDir } = require('../scripts/pack-harmony-project');
|
||||
await copyDir(src, dest, { withNodeModules: false, withCerts: true });
|
||||
|
||||
assert.ok(fs.existsSync(path.join(dest, 'keep/a.txt')), '正常文件应保留');
|
||||
assert.ok(!fs.existsSync(path.join(dest, 'node_modules')), 'node_modules 应排除');
|
||||
assert.ok(!fs.existsSync(path.join(dest, 'build')), 'build 应排除');
|
||||
assert.ok(!fs.existsSync(path.join(dest, 'local.properties')), 'local.properties 应排除');
|
||||
});
|
||||
Reference in New Issue
Block a user