mirror of
https://github.com/cocos/cocos-engine.git
synced 2026-09-08 16:48:15 +08:00
412 lines
18 KiB
TypeScript
412 lines
18 KiB
TypeScript
import * as fs from 'fs-extra';
|
||
import * as ps from 'path';
|
||
import { CocosParams, NativePackTool } from "../base/default";
|
||
import { cchelper, Paths } from "../utils";
|
||
import * as URL from 'url';
|
||
import { spawn, spawnSync } from 'child_process';
|
||
|
||
export interface IOrientation {
|
||
landscapeLeft: boolean;
|
||
landscapeRight: boolean;
|
||
portrait: boolean;
|
||
upsideDown: boolean;
|
||
}
|
||
|
||
export interface IAndroidParams {
|
||
packageName: string;
|
||
sdkPath: string;
|
||
ndkPath: string;
|
||
androidInstant: boolean,
|
||
remoteUrl?: string;
|
||
apiLevel: number;
|
||
appABIs: string[];
|
||
keystorePassword: string;
|
||
keystoreAlias: string;
|
||
keystoreAliasPassword: string;
|
||
keystorePath: string;
|
||
|
||
orientation: IOrientation;
|
||
appBundle: boolean;
|
||
}
|
||
|
||
export class AndroidPackTool extends NativePackTool {
|
||
params!: CocosParams<IAndroidParams>;
|
||
|
||
protected async copyPlatformTemplate() {
|
||
// 原生工程不重复拷贝 TODO 复用前需要做版本检测
|
||
if (!fs.existsSync(this.paths.nativePrjDir)) {
|
||
// 拷贝 lite 仓库的 templates/android/build 文件到构建输出目录
|
||
await fs.copy(ps.join(this.paths.nativeTemplateDirInCocos, this.params.platform, 'build'), this.paths.nativePrjDir, { overwrite: false });
|
||
}
|
||
// 原生工程不重复拷贝 TODO 复用前需要做版本检测
|
||
if (!fs.existsSync(this.paths.platformTemplateDirInPrj)) {
|
||
// 拷贝 lite 仓库的 templates/android/template 文件到构建输出目录
|
||
await fs.copy(ps.join(this.paths.nativeTemplateDirInCocos, this.params.platform, 'template'), this.paths.platformTemplateDirInPrj, { overwrite: false });
|
||
this.writeEngineVersion();
|
||
} else {
|
||
this.validateNativeDir();
|
||
}
|
||
}
|
||
|
||
protected validatePlatformDirectory(missing: string[]): void {
|
||
const srcDir = ps.join(this.paths.nativeTemplateDirInCocos, this.params.platform, 'template');
|
||
const dstDir = this.paths.platformTemplateDirInPrj;
|
||
this.validateDirectory(srcDir, dstDir, missing);
|
||
}
|
||
|
||
async create() {
|
||
await this.copyCommonTemplate();
|
||
await this.copyPlatformTemplate();
|
||
await this.generateCMakeConfig();
|
||
await this.excuteCocosTemplateTask();
|
||
|
||
await this.setOrientation();
|
||
await this.encrypteScripts();
|
||
await this.updateAndroidGradleValues();
|
||
await this.configAndroidInstant();
|
||
return true;
|
||
}
|
||
|
||
async make() {
|
||
const options = this.params.platformParams;
|
||
|
||
const projDir: string = this.paths.nativePrjDir;
|
||
if (!fs.existsSync(projDir)) {
|
||
throw new Error(`dir ${projDir} not exits`);
|
||
}
|
||
let gradle = 'gradlew';
|
||
if (process.platform === 'win32') {
|
||
gradle += '.bat';
|
||
} else {
|
||
gradle = './' + gradle;
|
||
}
|
||
|
||
let buildMode = '';
|
||
const outputMode = this.params.debug ? 'Debug' : 'Release';
|
||
|
||
// compile android
|
||
buildMode = `${this.params.projectName}:assemble${outputMode}`;
|
||
// await cchelper.runCmd(gradle, [buildMode /* "--quiet",*/ /*"--build-cache", "--project-cache-dir", nativePrjDir */], false, projDir);
|
||
|
||
// pushd
|
||
const originDir = process.cwd();
|
||
try {
|
||
process.chdir(projDir);
|
||
await cchelper.runCmd(gradle, [buildMode], false, projDir);
|
||
} catch (e) {
|
||
throw e;
|
||
} finally {
|
||
// popd
|
||
process.chdir(originDir);
|
||
}
|
||
|
||
|
||
// compile android-instant
|
||
if (options.androidInstant) {
|
||
buildMode = `instantapp:assemble${outputMode}`;
|
||
// await cchelper.runCmd(gradle, [buildMode, /*"--quiet",*/ /*"--build-cache", "--project-cache-dir", nativePrjDir*/], false, projDir);
|
||
await cchelper.runCmd(gradle, [buildMode], false, projDir);
|
||
}
|
||
|
||
// compile google app bundle
|
||
if (options.appBundle) {
|
||
if (options.androidInstant) {
|
||
buildMode = `bundle${outputMode}`;
|
||
} else {
|
||
buildMode = `${this.params.projectName}:bundle${outputMode}`;
|
||
}
|
||
// await cchelper.runCmd(gradle, [buildMode, /*"--quiet",*/ /*"--build-cache", "--project-cache-dir", nativePrjDir*/], false, projDir);
|
||
await cchelper.runCmd(gradle, [buildMode], false, projDir);
|
||
}
|
||
return await this.copyToDist();
|
||
}
|
||
|
||
protected async setOrientation() {
|
||
const cfg = this.params.platformParams.orientation;
|
||
const manifestPath = cchelper.join(this.paths.platformTemplateDirInPrj, 'app/AndroidManifest.xml');
|
||
const instantManifestPath = cchelper.join(this.paths.platformTemplateDirInPrj, 'instantapp/AndroidManifest.xml');
|
||
if (fs.existsSync(manifestPath) && fs.existsSync(instantManifestPath)) {
|
||
const pattern = /android:screenOrientation="[^"]*"/;
|
||
let replaceString = 'android:screenOrientation="unspecified"';
|
||
|
||
if (cfg.landscapeRight && cfg.landscapeLeft && (cfg.portrait || cfg.upsideDown)) {
|
||
replaceString = 'android:screenOrientation="fullSensor"';
|
||
} else if ((cfg.landscapeRight || cfg.landscapeLeft) && (cfg.portrait || cfg.upsideDown)) {
|
||
replaceString = 'android:screenOrientation="unspecified"';
|
||
} else if (cfg.landscapeRight && !cfg.landscapeLeft) {
|
||
replaceString = 'android:screenOrientation="landscape"';
|
||
} else if (!cfg.landscapeRight && cfg.landscapeLeft) {
|
||
replaceString = 'android:screenOrientation="reverseLandscape"';
|
||
} else if (cfg.landscapeRight && cfg.landscapeLeft) {
|
||
replaceString = 'android:screenOrientation="sensorLandscape"';
|
||
} else if (cfg.portrait && !cfg.upsideDown) {
|
||
replaceString = 'android:screenOrientation="portrait"';
|
||
} else if (!cfg.portrait && cfg.upsideDown) {
|
||
const oriValue = 'reversePortrait';
|
||
replaceString = `android:screenOrientation="${oriValue}"`;
|
||
} else if (cfg.portrait && cfg.upsideDown) {
|
||
const oriValue = 'sensorPortrait';
|
||
replaceString = `android:screenOrientation="${oriValue}"`;
|
||
}
|
||
|
||
let content = await fs.readFile(manifestPath, 'utf8');
|
||
content = content.replace(pattern, replaceString);
|
||
let instantContent = await fs.readFile(instantManifestPath, 'utf8');
|
||
instantContent = instantContent.replace(pattern, replaceString);
|
||
await fs.writeFile(manifestPath, content);
|
||
await fs.writeFile(instantManifestPath, instantContent);
|
||
}
|
||
}
|
||
|
||
protected async updateAndroidGradleValues() {
|
||
const options = this.params.platformParams;
|
||
// android-studio gradle.properties
|
||
console.log(`update settings.properties`);
|
||
await cchelper.replaceInFile([
|
||
{ reg: '^rootProject\\.name.*', text: `rootProject.name = "${this.params.projectName}"` },
|
||
{ reg: ':CocosGame', text: `:${this.params.projectName}` }
|
||
], ps.join(this.paths.nativePrjDir, 'settings.gradle'));
|
||
|
||
console.log(`update gradle.properties`);
|
||
const gradlePropertyPath = cchelper.join(this.paths.nativePrjDir, 'gradle.properties');
|
||
if (fs.existsSync(gradlePropertyPath)) {
|
||
let keystorePath = options.keystorePath;
|
||
if (process.platform === 'win32') {
|
||
keystorePath = cchelper.fixPath(keystorePath);
|
||
}
|
||
let apiLevel = options.apiLevel;
|
||
if (!apiLevel) {
|
||
apiLevel = 27;
|
||
}
|
||
console.log(`AndroidAPI level ${apiLevel}`);
|
||
let content = fs.readFileSync(gradlePropertyPath, 'utf-8');
|
||
if (keystorePath) {
|
||
content = content.replace(/.*RELEASE_STORE_FILE=.*/, `RELEASE_STORE_FILE=${keystorePath}`);
|
||
content = content.replace(/.*RELEASE_STORE_PASSWORD=.*/, `RELEASE_STORE_PASSWORD=${options.keystorePassword}`);
|
||
content = content.replace(/.*RELEASE_KEY_ALIAS=.*/, `RELEASE_KEY_ALIAS=${options.keystoreAlias}`);
|
||
content = content.replace(/.*RELEASE_KEY_PASSWORD=.*/, `RELEASE_KEY_PASSWORD=${options.keystoreAliasPassword}`);
|
||
} else {
|
||
content = content.replace(/.*RELEASE_STORE_FILE=.*/, `# RELEASE_STORE_FILE=${keystorePath}`);
|
||
content = content.replace(/.*RELEASE_STORE_PASSWORD=.*/, `# RELEASE_STORE_PASSWORD=${options.keystorePassword}`);
|
||
content = content.replace(/.*RELEASE_KEY_ALIAS=.*/, `# RELEASE_KEY_ALIAS=${options.keystoreAlias}`);
|
||
content = content.replace(/.*RELEASE_KEY_PASSWORD=.*/, `# RELEASE_KEY_PASSWORD=${options.keystoreAliasPassword}`);
|
||
}
|
||
|
||
const compileSDKVersion = this.parseVersion(content, 'PROP_COMPILE_SDK_VERSION', 27);
|
||
const minimalSDKVersion = this.parseVersion(content, 'PROP_MIN_SDK_VERSION', 21);
|
||
|
||
content = content.replace(/PROP_TARGET_SDK_VERSION=.*/, `PROP_TARGET_SDK_VERSION=${apiLevel}`);
|
||
content = content.replace(/PROP_COMPILE_SDK_VERSION=.*/, `PROP_COMPILE_SDK_VERSION=${Math.max(apiLevel, compileSDKVersion, 27)}`);
|
||
content = content.replace(/PROP_MIN_SDK_VERSION=.*/, `PROP_MIN_SDK_VERSION=${Math.min(apiLevel, minimalSDKVersion)}`);
|
||
content = content.replace(/PROP_APP_NAME=.*/, `PROP_APP_NAME=${this.params.projectName}`);
|
||
content = content.replace(/PROP_ENABLE_INSTANT_APP=.*/, `PROP_ENABLE_INSTANT_APP=${options.androidInstant ? "true" : "false"}`);
|
||
content = content.replace(/PROP_ENABLE_INPUTSDK=.*/, `PROP_ENABLE_INPUTSDK=${options.inputSDK ? "true" : "false"}`);
|
||
content = content.replace(/PROP_IS_DEBUG=.*/, `PROP_IS_DEBUG=${this.params.debug ? "true" : "false"}`);
|
||
|
||
content = content.replace(/RES_PATH=.*/, `RES_PATH=${cchelper.fixPath(this.paths.buildDir)}`);
|
||
content = content.replace(/COCOS_ENGINE_PATH=.*/, `COCOS_ENGINE_PATH=${cchelper.fixPath(Paths.nativeRoot)}`);
|
||
content = content.replace(/APPLICATION_ID=.*/, `APPLICATION_ID=${options.packageName}`);
|
||
content = content.replace(/NATIVE_DIR=.*/, `NATIVE_DIR=${cchelper.fixPath(this.paths.platformTemplateDirInPrj)}`);
|
||
|
||
|
||
if (process.platform === 'win32') {
|
||
options.ndkPath = options.ndkPath.replace(/\\/g, '\\\\');
|
||
}
|
||
|
||
content = content.replace(/PROP_NDK_PATH=.*/, `PROP_NDK_PATH=${options.ndkPath}`);
|
||
|
||
const abis = (options.appABIs && options.appABIs.length > 0) ? options.appABIs.join(':') : 'armeabi-v7a';
|
||
// todo:新的template里面有个注释也是这个字段,所以要加个g
|
||
content = content.replace(/PROP_APP_ABI=.*/g, `PROP_APP_ABI=${abis}`);
|
||
fs.writeFileSync(gradlePropertyPath, content);
|
||
|
||
// generate local.properties
|
||
content = '';
|
||
content += `sdk.dir=${options.sdkPath}`;
|
||
// windows 需要使用的这样的格式 e\:\\aa\\bb\\cc
|
||
if (process.platform === 'win32') {
|
||
content = content.replace(/\\/g, '\\\\');
|
||
content = content.replace(/:/g, '\\:');
|
||
}
|
||
|
||
fs.writeFileSync(cchelper.join(ps.dirname(gradlePropertyPath), 'local.properties'), content, {encoding: 'utf8'});
|
||
} else {
|
||
console.log(`warning: ${gradlePropertyPath} not found!`);
|
||
}
|
||
}
|
||
|
||
protected async configAndroidInstant() {
|
||
if (!this.params.platformParams.androidInstant) {
|
||
console.log('android instant not configured');
|
||
return;
|
||
}
|
||
const url = this.params.platformParams.remoteUrl;
|
||
if (!url) {
|
||
return;
|
||
}
|
||
const manifestPath = cchelper.join(this.paths.platformTemplateDirInPrj, 'instantapp/AndroidManifest.xml');
|
||
if (!fs.existsSync(manifestPath)) {
|
||
throw new Error(`${manifestPath} not found`);
|
||
}
|
||
const urlInfo = URL.parse(url);
|
||
if (!urlInfo.host) {
|
||
throw new Error(`parse url ${url} fail`);
|
||
}
|
||
let manifest = fs.readFileSync(manifestPath, 'utf8');
|
||
manifest = manifest.replace(/<category\s*android:name="android.intent.category.DEFAULT"\s*\/>/, (str) => {
|
||
let newStr = '<category android:name="android.intent.category.DEFAULT" />';
|
||
newStr += `\n <data android:host="${urlInfo.host}" android:pathPattern="${urlInfo.path}" android:scheme="https"/>`
|
||
+ `\n <data android:scheme="http"/>`;
|
||
return newStr;
|
||
});
|
||
|
||
fs.writeFileSync(manifestPath, manifest, 'utf8');
|
||
}
|
||
|
||
/**
|
||
* 到对应目录拷贝文件到工程发布目录
|
||
*/
|
||
async copyToDist(): Promise<boolean> {
|
||
const options = this.params.platformParams;
|
||
|
||
const suffix = this.params.debug ? 'debug' : 'release';
|
||
const destDir: string = ps.join(this.paths.buildDir, 'publish', suffix);
|
||
fs.ensureDirSync(destDir);
|
||
let apkName = `${this.params.projectName}-${suffix}.apk`;
|
||
let apkPath = ps.join(this.paths.nativePrjDir, `build/${this.params.projectName}/outputs/apk/${suffix}/${apkName}`);
|
||
if (!fs.existsSync(apkPath)) {
|
||
throw new Error(`apk not found at ${apkPath}`);
|
||
}
|
||
fs.copyFileSync(apkPath, ps.join(destDir, apkName));
|
||
if (options.androidInstant) {
|
||
apkName = `instantapp-${suffix}.apk`;
|
||
apkPath = ps.join(this.paths.nativePrjDir, `build/instantapp/outputs/apk/${suffix}/${apkName}`);
|
||
if (!fs.existsSync(apkPath)) {
|
||
throw new Error(`instant apk not found at ${apkPath}`);
|
||
}
|
||
fs.copyFileSync(apkPath, ps.join(destDir, apkName));
|
||
}
|
||
|
||
if (options.appBundle) {
|
||
apkName = `${this.params.projectName}-${suffix}.aab`;
|
||
apkPath = ps.join(this.paths.nativePrjDir, `build/${this.params.projectName}/outputs/bundle/${suffix}/${apkName}`);
|
||
if (!fs.existsSync(apkPath)) {
|
||
throw new Error(`instant apk not found at ${apkPath}`);
|
||
}
|
||
fs.copyFileSync(apkPath, ps.join(destDir, apkName));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ---------------------------- run ------------------------- //
|
||
|
||
async run() {
|
||
if (await this.install()) {
|
||
return await this.startApp();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
getAdbPath() {
|
||
return ps.join(
|
||
this.params.platformParams.sdkPath,
|
||
`platform-tools/adb${process.platform === 'win32' ? '.exe' : ''}`);
|
||
}
|
||
|
||
getApkPath() {
|
||
const suffix = this.params.debug ? 'debug' : 'release';
|
||
const apkName = `${this.params.projectName}-${suffix}.apk`;
|
||
return ps.join(
|
||
this.paths.nativePrjDir,
|
||
`build/${this.params.projectName}/outputs/apk/${suffix}/${apkName}`);
|
||
}
|
||
|
||
async install(): Promise<boolean> {
|
||
const apkPath = this.getApkPath();
|
||
const adbPath = this.getAdbPath();
|
||
|
||
if (!fs.existsSync(apkPath)) {
|
||
throw new Error(`can not find apk at ${apkPath}`);
|
||
}
|
||
|
||
if (!fs.existsSync(adbPath)) {
|
||
throw new Error(`can not find adb at ${adbPath}`);
|
||
}
|
||
|
||
if (!this.checkConnectedDevices(adbPath)) {
|
||
throw new Error(`can not find any connected devices, please connect you device or start an Android emulator`);
|
||
}
|
||
|
||
if (await this.checkApkInstalled(adbPath)) {
|
||
await cchelper.runCmd(
|
||
adbPath, ['uninstall', this.params.platformParams.packageName], false);
|
||
}
|
||
|
||
await cchelper.runCmd(adbPath, ['install', '-r', apkPath], false);
|
||
return true;
|
||
}
|
||
|
||
checkConnectedDevices(adbPath: string): boolean {
|
||
const cp = spawnSync(adbPath, ['devices'], { shell: true, env: process.env, cwd: process.cwd() });
|
||
if (cp.stderr && cp.stderr.length > 0) {
|
||
console.log(`[adb devices] stderr: ${cp.stderr.toString('utf8')}`);
|
||
}
|
||
if (cp.error) {
|
||
console.log(`[adb devices] error: ${cp.error}`);
|
||
}
|
||
if (cp.output.length > 1) {
|
||
for (const chunk of cp.output) {
|
||
if (chunk) {
|
||
const chunkAny: any = chunk;
|
||
const chuckStr: string = typeof chunk === 'string' ? chunk : (chunkAny.buffer && chunkAny.buffer instanceof ArrayBuffer ? chunkAny.toString() : chunkAny.toString());
|
||
const lines = chuckStr.split('\n');
|
||
for (let line of lines) {
|
||
if (/^[0-9a-fA-F]+\s+\w+/.test(line)) {
|
||
return true; // device connected
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async checkApkInstalled(adbPath: string) {
|
||
const ret: string = await new Promise((resolve, reject) => {
|
||
const cp = spawn(
|
||
adbPath,
|
||
[
|
||
'shell pm list packages | grep',
|
||
this.params.platformParams.packageName,
|
||
],
|
||
{
|
||
shell: true,
|
||
env: process.env,
|
||
cwd: process.cwd(),
|
||
});
|
||
cp.stdout.on(`data`, (chunk) => {
|
||
resolve(chunk.toString());
|
||
});
|
||
cp.stderr.on(`data`, (chunk) => {
|
||
resolve('');
|
||
});
|
||
cp.on('close', (code, signal) => {
|
||
resolve('');
|
||
});
|
||
});
|
||
return ret.includes(this.params.platformParams.packageName);
|
||
}
|
||
|
||
async startApp(): Promise<boolean> {
|
||
const adbPath = this.getAdbPath();
|
||
await cchelper.runCmd(
|
||
adbPath,
|
||
[
|
||
'shell', 'am', 'start', '-n',
|
||
`${this.params.platformParams.packageName}/com.cocos.game.AppActivity`,
|
||
],
|
||
false);
|
||
return true;
|
||
}
|
||
}
|