2026-10-26 17:44:44 +08:00
|
|
|
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()
|
2026-10-26 19:38:42 +08:00
|
|
|
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}`)
|
2026-10-26 17:44:44 +08:00
|
|
|
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')
|
|
|
|
|
|
2026-10-26 19:38:42 +08:00
|
|
|
const appVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version
|
2026-10-26 20:28:30 +08:00
|
|
|
const versionCode = Number(process.env.APP_VERSION_CODE || 8)
|
2026-10-26 19:38:42 +08:00
|
|
|
|
2026-10-26 17:44:44 +08:00
|
|
|
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 || ''
|
2026-10-26 19:38:42 +08:00
|
|
|
].join(path.delimiter)
|
2026-10-26 17:44:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-26 19:38:42 +08:00
|
|
|
console.log(`JDK : ${jdkHome}`)
|
|
|
|
|
console.log(`Android SDK: ${sdkHome} (build-tools ${buildToolsVersion}, platform ${platformId})`)
|
|
|
|
|
console.log(`Gradle lib : ${gradleHome}`)
|
|
|
|
|
|
2026-10-26 17:44:44 +08:00
|
|
|
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')
|
2026-10-26 19:38:42 +08:00
|
|
|
const apkName = `EasyView-${appVersion}.apk`
|
2026-10-26 17:44:44 +08:00
|
|
|
|
|
|
|
|
function run(command, args, options = {}) {
|
|
|
|
|
console.log('>', path.basename(command), args.join(' '))
|
|
|
|
|
execFileSync(command, args, { stdio: 'inherit', env, ...options })
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 19:38:42 +08:00
|
|
|
// 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)
|
2026-10-26 17:44:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 ==')
|
2026-10-26 19:38:42 +08:00
|
|
|
if (isWin) run('cmd.exe', ['/c', 'npm run build'], { cwd: root })
|
|
|
|
|
else run(process.env.NPM_BIN || 'npm', ['run', 'build'], { cwd: root, shell: false })
|
2026-10-26 17:44:44 +08:00
|
|
|
|
|
|
|
|
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',
|
2026-10-26 19:38:42 +08:00
|
|
|
[androidJar, path.join(gradleHome, 'lib', kotlinStdlib)].join(path.delimiter),
|
2026-10-26 17:44:44 +08:00
|
|
|
'-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 ==')
|
2026-10-26 19:38:42 +08:00
|
|
|
runTool('d8', [
|
2026-10-26 17:44:44 +08:00
|
|
|
'--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')
|
2026-10-26 19:38:42 +08:00
|
|
|
run(path.join(buildTools, `aapt2${exeSuffix}`), ['compile', '-o', resZip, '--dir', resDir])
|
2026-10-26 17:44:44 +08:00
|
|
|
|
|
|
|
|
console.log('== linking base apk ==')
|
|
|
|
|
const baseApk = path.join(outDir, 'base.apk')
|
2026-10-26 19:38:42 +08:00
|
|
|
run(path.join(buildTools, `aapt2${exeSuffix}`), [
|
2026-10-26 17:44:44 +08:00
|
|
|
'link',
|
|
|
|
|
'-o',
|
|
|
|
|
baseApk,
|
|
|
|
|
'-I',
|
|
|
|
|
androidJar,
|
|
|
|
|
'--manifest',
|
|
|
|
|
manifest,
|
|
|
|
|
'-R',
|
|
|
|
|
resZip,
|
|
|
|
|
'--min-sdk-version',
|
|
|
|
|
'26',
|
|
|
|
|
'--target-sdk-version',
|
|
|
|
|
'34',
|
|
|
|
|
'--version-code',
|
2026-10-26 19:38:42 +08:00
|
|
|
String(versionCode),
|
2026-10-26 17:44:44 +08:00
|
|
|
'--version-name',
|
2026-10-26 19:38:42 +08:00
|
|
|
appVersion,
|
2026-10-26 17:44:44 +08:00
|
|
|
'--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')
|
2026-10-26 19:38:42 +08:00
|
|
|
run(path.join(buildTools, `zipalign${exeSuffix}`), ['-p', '-f', '4', unsigned, aligned])
|
2026-10-26 17:44:44 +08:00
|
|
|
|
|
|
|
|
console.log('== signing ==')
|
|
|
|
|
const keystoreDir = path.join(root, 'android', 'keystore')
|
2026-10-26 19:38:42 +08:00
|
|
|
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
|
2026-10-26 17:44:44 +08:00
|
|
|
if (!storePass) {
|
2026-10-26 19:38:42 +08:00
|
|
|
throw new Error('Set EASYVIEW_KEYSTORE_PASS before building the signed APK.')
|
2026-10-26 17:44:44 +08:00
|
|
|
}
|
2026-10-26 19:38:42 +08:00
|
|
|
const alias = 'easyview'
|
2026-10-26 17:44:44 +08:00
|
|
|
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',
|
2026-10-26 19:38:42 +08:00
|
|
|
'CN=EasyView, OU=Android, O=EasyView, L=Beijing, ST=Beijing, C=CN',
|
2026-10-26 17:44:44 +08:00
|
|
|
'-noprompt'
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
const finalApk = path.join(outDir, apkName)
|
2026-10-26 19:38:42 +08:00
|
|
|
runTool('apksigner', [
|
2026-10-26 17:44:44 +08:00
|
|
|
'sign',
|
|
|
|
|
'--ks',
|
|
|
|
|
keystore,
|
|
|
|
|
'--ks-key-alias',
|
|
|
|
|
alias,
|
|
|
|
|
'--ks-pass',
|
|
|
|
|
`pass:${storePass}`,
|
|
|
|
|
'--key-pass',
|
|
|
|
|
`pass:${keyPass}`,
|
|
|
|
|
'--out',
|
|
|
|
|
finalApk,
|
|
|
|
|
aligned
|
|
|
|
|
])
|
2026-10-26 19:38:42 +08:00
|
|
|
runTool('apksigner', ['verify', '--verbose', '--print-certs', finalApk])
|
2026-10-26 17:44:44 +08:00
|
|
|
|
|
|
|
|
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')
|