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 devRoot = process.env.ANDROID_DEV_ROOT || 'E:\\AiCodeGitea\\ven\\AndroidDev' const jdkHome = fs.existsSync(path.join(process.env.JAVA_HOME || '', 'bin', 'java.exe')) ? process.env.JAVA_HOME : path.join(devRoot, 'jdk17') const sdkHome = fs.existsSync(path.join(process.env.ANDROID_HOME || '', 'platforms')) ? process.env.ANDROID_HOME : path.join(devRoot, 'Sdk') const gradleHome = path.join(devRoot, 'gradle-8.7') const buildTools = path.join(sdkHome, 'build-tools', '34.0.0') const androidJar = path.join(sdkHome, 'platforms', 'android-34', 'android.jar') const java = path.join(jdkHome, 'bin', 'java.exe') const jar = path.join(jdkHome, 'bin', 'jar.exe') const keytool = path.join(jdkHome, 'bin', 'keytool.exe') 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 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(';') } 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 = 'PhoneViewer-0.4.1.apk' function run(command, args, options = {}) { console.log('>', path.basename(command), args.join(' ')) execFileSync(command, args, { stdio: 'inherit', env, ...options }) } function runBat(batFile, args, options = {}) { run('cmd.exe', ['/c', batFile, ...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 ==') run('cmd.exe', ['/c', 'npm run build'], { cwd: root }) 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)}`, '-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 ==') runBat(path.join(buildTools, 'd8.bat'), [ '--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.exe'), ['compile', '-o', resZip, '--dir', resDir]) console.log('== linking base apk ==') const baseApk = path.join(outDir, 'base.apk') run(path.join(buildTools, 'aapt2.exe'), [ 'link', '-o', baseApk, '-I', androidJar, '--manifest', manifest, '-R', resZip, '--min-sdk-version', '26', '--target-sdk-version', '34', '--version-code', '5', '--version-name', '0.4.1', '--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.exe'), ['-p', '-f', '4', unsigned, aligned]) console.log('== signing ==') const keystoreDir = path.join(root, 'android', 'keystore') const keystore = path.join(keystoreDir, 'phoneviewer.jks') const storePass = process.env.PHONEVIEWER_KEYSTORE_PASS const keyPass = process.env.PHONEVIEWER_KEY_PASS ?? storePass if (!storePass) { throw new Error('Set PHONEVIEWER_KEYSTORE_PASS before building the signed APK.') } const alias = 'phoneviewer' 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=PhoneViewer, OU=Android, O=PhoneViewer, L=Beijing, ST=Beijing, C=CN', '-noprompt' ]) } const finalApk = path.join(outDir, apkName) runBat(path.join(buildTools, 'apksigner.bat'), [ 'sign', '--ks', keystore, '--ks-key-alias', alias, '--ks-pass', `pass:${storePass}`, '--key-pass', `pass:${keyPass}`, '--out', finalApk, aligned ]) runBat(path.join(buildTools, 'apksigner.bat'), ['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')