import fs from 'node:fs' import path from 'node:path' import { execFileSync } from 'node:child_process' import AdmZip from 'adm-zip' const root = process.cwd() const isWin = process.platform === 'win32' const exeSuffix = isWin ? '.exe' : '' const devRoot = process.env.ANDROID_DEV_ROOT || (isWin ? 'E:\\AiCodeGitea\\ven\\AndroidDev' : path.resolve(root, '..', 'ven')) const isFile = (p) => { try { return fs.statSync(p).isFile() } catch { return false } } const isDir = (p) => { try { return fs.statSync(p).isDirectory() } catch { return false } } const jdkHome = (() => { const candidates = [ process.env.JAVA_HOME, path.join(devRoot, 'jdk21'), path.join(devRoot, 'jdk17'), path.join(devRoot, 'AndroidDev', 'jdk17') ].filter(Boolean) const found = candidates.find((dir) => isFile(path.join(dir, 'bin', `java${exeSuffix}`))) if (!found) throw new Error('JDK not found: set JAVA_HOME (workspace: source ../ven/*-env.sh)') return found })() const sdkHome = (() => { const candidates = [ process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT, path.join(devRoot, 'android-sdk'), path.join(devRoot, 'Sdk') ].filter(Boolean) const found = candidates.find((dir) => isDir(path.join(dir, 'platforms'))) if (!found) throw new Error('Android SDK not found: set ANDROID_HOME') return found })() function findGradleHome() { const explicit = [process.env.GRADLE_HOME, path.join(devRoot, 'gradle-8.7')].filter(Boolean) const found = explicit.find((dir) => isFile(path.join(dir, 'lib', 'kotlin-stdlib-1.9.23.jar'))) if (found) return found const distRoot = path.join(devRoot, 'gradle-dist') const candidates = isDir(distRoot) ? fs .readdirSync(distRoot) .filter((name) => name.startsWith('gradle-')) .sort() .reverse() .map((name) => path.join(distRoot, name)) : [] const hit = candidates.find((dir) => isDir(path.join(dir, 'lib'))) if (!hit) throw new Error(`Gradle distribution with Kotlin compiler not found under ${distRoot}`) return hit } const gradleHome = findGradleHome() const buildToolsVersion = process.env.BUILD_TOOLS_VERSION || fs .readdirSync(path.join(sdkHome, 'build-tools')) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) .pop() const buildTools = path.join(sdkHome, 'build-tools', buildToolsVersion) const platformId = process.env.COMPILE_SDK || (isDir(path.join(sdkHome, 'platforms', 'android-34')) ? 'android-34' : fs .readdirSync(path.join(sdkHome, 'platforms')) .filter((name) => name.startsWith('android-')) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) .pop()) const androidJar = path.join(sdkHome, 'platforms', platformId, 'android.jar') const java = path.join(jdkHome, 'bin', `java${exeSuffix}`) const jar = path.join(jdkHome, 'bin', `jar${exeSuffix}`) const keytool = path.join(jdkHome, 'bin', `keytool${exeSuffix}`) const kotlincJars = path.join(gradleHome, 'lib', '*') const kotlinStdlib = fs .readdirSync(path.join(gradleHome, 'lib')) .filter((name) => /^kotlin-stdlib-[\d.]+\.jar$/.test(name)) .sort() .pop() if (!kotlinStdlib) throw new Error('kotlin-stdlib not found in Gradle lib') const appVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version const versionCode = Number(process.env.APP_VERSION_CODE || 8) const env = { ...process.env, JAVA_HOME: jdkHome, ANDROID_HOME: sdkHome, ANDROID_SDK_ROOT: sdkHome, PATH: [ path.join(jdkHome, 'bin'), buildTools, path.join(sdkHome, 'platform-tools'), process.env.PATH || '' ].join(path.delimiter) } console.log(`JDK : ${jdkHome}`) console.log(`Android SDK: ${sdkHome} (build-tools ${buildToolsVersion}, platform ${platformId})`) console.log(`Gradle lib : ${gradleHome}`) const appDir = path.join(root, 'android', 'app', 'src', 'main') const manifest = path.join(appDir, 'AndroidManifest.xml') const resDir = path.join(appDir, 'res') const assetsDir = path.join(appDir, 'assets') const wwwDir = path.join(assetsDir, 'www') const sourceDir = path.join(appDir, 'java') const outDir = path.join(root, 'android', 'build') const classesDir = path.join(outDir, 'classes') const dexDir = path.join(outDir, 'dex') const apkName = `EasyView-${appVersion}.apk` function run(command, args, options = {}) { console.log('>', path.basename(command), args.join(' ')) execFileSync(command, args, { stdio: 'inherit', env, ...options }) } // Android build-tools ship .bat wrappers on Windows and plain scripts elsewhere. function runTool(name, args, options = {}) { const tool = path.join(buildTools, `${name}${isWin ? '.bat' : ''}`) return isWin ? run('cmd.exe', ['/c', tool, ...args], options) : run(tool, args, options) } fs.rmSync(outDir, { recursive: true, force: true }) fs.mkdirSync(outDir, { recursive: true }) fs.mkdirSync(classesDir, { recursive: true }) fs.mkdirSync(dexDir, { recursive: true }) console.log('== building web assets ==') if (isWin) run('cmd.exe', ['/c', 'npm run build'], { cwd: root }) else run(process.env.NPM_BIN || 'npm', ['run', 'build'], { cwd: root, shell: false }) console.log('== copying web assets into Android assets ==') fs.rmSync(wwwDir, { recursive: true, force: true }) fs.mkdirSync(wwwDir, { recursive: true }) fs.cpSync(path.join(root, 'dist'), wwwDir, { recursive: true }) for (const name of fs.readdirSync(wwwDir)) { if (name === 'sw.js' || name.startsWith('workbox-')) { fs.rmSync(path.join(wwwDir, name), { force: true }) } } console.log('== compiling Kotlin ==') const kotlinFiles = [] function walk(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name) if (entry.isDirectory()) walk(full) else if (entry.name.endsWith('.kt')) kotlinFiles.push(full) } } walk(sourceDir) run(java, [ '-cp', kotlincJars, 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', '-no-stdlib', '-no-reflect', '-classpath', [androidJar, path.join(gradleHome, 'lib', kotlinStdlib)].join(path.delimiter), '-jvm-target', '17', '-d', classesDir, ...kotlinFiles ]) console.log('== creating classes.jar ==') const classesJar = path.join(outDir, 'classes.jar') run(jar, ['cf', classesJar, '-C', classesDir, '.']) console.log('== dexing with d8 ==') runTool('d8', [ '--release', '--lib', androidJar, '--min-api', '26', '--output', dexDir, classesJar, path.join(gradleHome, 'lib', kotlinStdlib) ]) console.log('== compiling resources ==') const resZip = path.join(outDir, 'resources.zip') run(path.join(buildTools, `aapt2${exeSuffix}`), ['compile', '-o', resZip, '--dir', resDir]) console.log('== linking base apk ==') const baseApk = path.join(outDir, 'base.apk') run(path.join(buildTools, `aapt2${exeSuffix}`), [ 'link', '-o', baseApk, '-I', androidJar, '--manifest', manifest, '-R', resZip, '--min-sdk-version', '26', '--target-sdk-version', '34', '--version-code', String(versionCode), '--version-name', appVersion, '--auto-add-overlay' ]) console.log('== adding assets and classes.dex ==') const unsigned = path.join(outDir, 'unsigned.apk') fs.rmSync(unsigned, { force: true }) const zip = new AdmZip(baseApk) function addDirectoryToZip(directory, prefix) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const full = path.join(directory, entry.name) const name = `${prefix}/${entry.name}` if (entry.isDirectory()) addDirectoryToZip(full, name) else zip.addFile(name, fs.readFileSync(full)) } } addDirectoryToZip(wwwDir, 'assets/www') for (const dex of fs.readdirSync(dexDir).filter((name) => name.endsWith('.dex'))) { zip.addFile(dex, fs.readFileSync(path.join(dexDir, dex))) } zip.writeZip(unsigned) console.log('== zipalign ==') const aligned = path.join(outDir, 'aligned.apk') run(path.join(buildTools, `zipalign${exeSuffix}`), ['-p', '-f', '4', unsigned, aligned]) console.log('== signing ==') const keystoreDir = path.join(root, 'android', 'keystore') const keystore = path.join(keystoreDir, 'easyview.jks') const storePass = process.env.EASYVIEW_KEYSTORE_PASS ?? process.env.PHONEVIEWER_KEYSTORE_PASS const keyPass = process.env.EASYVIEW_KEY_PASS ?? process.env.PHONEVIEWER_KEY_PASS ?? storePass if (!storePass) { throw new Error('Set EASYVIEW_KEYSTORE_PASS before building the signed APK.') } const alias = 'easyview' if (!fs.existsSync(keystore)) { fs.mkdirSync(keystoreDir, { recursive: true }) run(keytool, [ '-genkeypair', '-v', '-keystore', keystore, '-storetype', 'JKS', '-alias', alias, '-keyalg', 'RSA', '-keysize', '2048', '-validity', '10000', '-storepass', storePass, '-keypass', keyPass, '-dname', 'CN=EasyView, OU=Android, O=EasyView, L=Beijing, ST=Beijing, C=CN', '-noprompt' ]) } const finalApk = path.join(outDir, apkName) runTool('apksigner', [ 'sign', '--ks', keystore, '--ks-key-alias', alias, '--ks-pass', `pass:${storePass}`, '--key-pass', `pass:${keyPass}`, '--out', finalApk, aligned ]) runTool('apksigner', ['verify', '--verbose', '--print-certs', finalApk]) const rootApk = path.join(root, apkName) fs.copyFileSync(finalApk, rootApk) console.log('') console.log('APK ready:', rootApk) console.log('APK size:', (fs.statSync(rootApk).size / 1024 / 1024).toFixed(2), 'MB')