diff --git a/app/src/main/java/org/autojs/autojs/apkbuilder/ApkBuilder.kt b/app/src/main/java/org/autojs/autojs/apkbuilder/ApkBuilder.kt index f585deca..f55ecde8 100644 --- a/app/src/main/java/org/autojs/autojs/apkbuilder/ApkBuilder.kt +++ b/app/src/main/java/org/autojs/autojs/apkbuilder/ApkBuilder.kt @@ -36,14 +36,18 @@ import pxb.android.StringItem import pxb.android.axml.AxmlWriter import zhao.arsceditor.ResDecoder.ARSCDecoder import java.io.BufferedInputStream +import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.CancellationException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.zip.ZipEntry import java.util.zip.ZipFile @@ -59,6 +63,9 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File private var mManifestEditor: ManifestEditor? = null private var mInitVector: String? = null private var mKey: String? = null + private var mCancelSignal: AtomicBoolean? = null + private var mPendingProjectConfigFile: File? = null + private var mPendingProjectConfigJson: String? = null private lateinit var mProjectConfig: ProjectConfig @@ -85,49 +92,157 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File fun setProgressCallback(callback: ProgressCallback?) = also { mProgressCallback = callback } - @Throws(IOException::class) - fun prepare() = also { - mProgressCallback?.let { callback -> GlobalAppContext.post { callback.onPrepare(this) } } - File(buildPath).mkdirs() - mApkPackager.unzip() + fun setCancelSignal(cancelSignal: AtomicBoolean?) = also { + mCancelSignal = cancelSignal + mApkPackager.setCancelSignal(cancelSignal) + } + + // Throw early when cancellation is requested to avoid partial outputs. + // zh-CN: 当收到取消请求时尽早抛出, 以避免产生部分输出. + private fun ensureNotCancelled() { + if (mCancelSignal?.get() == true) { + throw CancellationException("Build aborted") + } + if (Thread.currentThread().isInterrupted) { + throw CancellationException("Build aborted") + } + } + + // Copy streams with cancellation checks to keep abort responsive during large IO. + // zh-CN: 在大 IO 过程中加入取消检查, 保持中止响应. + private fun copyStreamWithCancel(input: InputStream, output: OutputStream, bufferSize: Int = 16 * 1024) { + val buffer = ByteArray(bufferSize) + var len: Int + while (input.read(buffer).also { len = it } > 0) { + ensureNotCancelled() + output.write(buffer, 0, len) + } + } + + private fun notifyStepChanged(step: ProgressStep) { + mProgressCallback?.let { callback -> + GlobalAppContext.post { + when (step) { + ProgressStep.PREPARE -> callback.onPrepare(this) + ProgressStep.BUILD -> callback.onBuild(this) + ProgressStep.SIGN -> callback.onSign(this) + ProgressStep.CLEAN -> callback.onClean(this) + } + } + } + } + + private fun notifyStepProgress(step: ProgressStep, title: String, detail: String?) { + mProgressCallback?.let { callback -> + GlobalAppContext.post { + callback.onStepProgress( + builder = this, + title = title, + detail = detail?.takeIf { it.isNotBlank() }, + ) + } + } } @Throws(IOException::class) - fun setScriptFile(path: String?) = also { + fun prepare(context: Context) = also { + ensureNotCancelled() + notifyStepChanged(ProgressStep.PREPARE) + notifyStepProgress( + ProgressStep.PREPARE, + context.getString(R.string.text_preparing_workspace), + buildPath, + ) + File(buildPath).mkdirs() + notifyStepProgress( + ProgressStep.PREPARE, + context.getString(R.string.text_extracting_template_apk), + buildPath, + ) + mApkPackager.unzip() + ensureNotCancelled() + notifyStepProgress( + ProgressStep.PREPARE, + context.getString(R.string.text_prepare_completed), + buildPath, + ) + } + + @Throws(IOException::class) + fun setScriptFile(context: Context, path: String?) = also { + ensureNotCancelled() path?.let { when { - PFiles.isDir(it) -> copyDir(it, "assets/project/") - else -> replaceFile(it, "assets/project/main.js") + PFiles.isDir(it) -> { + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_copying_project_directory), + it, + ) + copyDir(context, it, "assets/project/") + } + else -> { + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_copying_script_file), + it, + ) + replaceFile(context, it, "assets/project/main.js") + } } + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_source_processing_completed), + it, + ) } } @Throws(IOException::class) @Suppress("SameParameterValue") - private fun copyDir(srcPath: String, relativeDestPath: String) { - copyDir(File(srcPath), relativeDestPath) + private fun copyDir(context: Context, srcPath: String, relativeDestPath: String) { + copyDir(context, File(srcPath), relativeDestPath) } @Throws(IOException::class) - fun copyDir(srcFile: File, relativeDestPath: String) { + fun copyDir(context: Context, srcFile: File, relativeDestPath: String) { + ensureNotCancelled() val destDirFile = File(buildPath, relativeDestPath).apply { mkdir() } + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_copying_directory), + "${srcFile.path} -> ${destDirFile.path}", + ) srcFile.listFiles()?.forEach { srcChildFile -> + ensureNotCancelled() if (srcChildFile.isFile) { if (srcChildFile.name.endsWith(JAVASCRIPT.extensionWithDot)) { - encryptToDir(srcChildFile, destDirFile) + encryptToDir(context, srcChildFile, destDirFile) } else { - srcChildFile.copyTo(File(destDirFile, srcChildFile.name), true) + val destFile = File(destDirFile, srcChildFile.name) + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_copying_file), + "${srcChildFile.path} -> ${destFile.path}", + ) + srcChildFile.copyTo(destFile, true) } } else { if (!mProjectConfig.excludedDirs.contains(srcChildFile)) { - copyDir(srcChildFile, PFiles.join(relativeDestPath, srcChildFile.name + File.separator)) + copyDir(context, srcChildFile, PFiles.join(relativeDestPath, srcChildFile.name + File.separator)) } } } } @Throws(IOException::class) - private fun encrypt(srcFile: File, destFile: File) { + private fun encrypt(context: Context, srcFile: File, destFile: File) { + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_encrypting_script), + "${srcFile.path} -> ${destFile.path}", + ) destFile.outputStream().use { os -> writeHeader(os, JavaScriptFileSource(srcFile).executionMode.toShort()) AdvancedEncryptionStandard(mKey!!.toByteArray(), mInitVector!!) @@ -136,34 +251,93 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } } - private fun encryptToDir(srcFile: File, destDirFile: File) { + private fun encryptToDir(context: Context, srcFile: File, destDirFile: File) { val destFile = File(destDirFile, srcFile.name) - encrypt(srcFile, destFile) + encrypt(context, srcFile, destFile) } @Throws(IOException::class) - fun replaceFile(srcPath: String, relativeDestPath: String) = replaceFile(File(srcPath), relativeDestPath) + fun replaceFile(context: Context, srcPath: String, relativeDestPath: String) = replaceFile(context, File(srcPath), relativeDestPath) @Throws(IOException::class) - fun replaceFile(srcFile: File, relativeDestPath: String) = also { + fun replaceFile(context: Context, srcFile: File, relativeDestPath: String) = also { + ensureNotCancelled() val destFile = File(buildPath, relativeDestPath) if (destFile.name.endsWith(JAVASCRIPT.extensionWithDot)) { - encrypt(srcFile, destFile) + encrypt(context, srcFile, destFile) } else { + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_replacing_file), + "${srcFile.path} -> ${destFile.path}", + ) srcFile.copyTo(destFile, true) } } @Throws(IOException::class) - fun withConfig(config: ProjectConfig) = also { + fun withConfig(context: Context, config: ProjectConfig) = also { + notifyStepChanged(ProgressStep.BUILD) + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_processing), + context.getString(R.string.text_preparing_build_config), + ) config.also { mProjectConfig = it }.run { + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_reading_splash_resources), + mResourcesArscFile.path, + ) retrieveSplashThemeResources(launchConfig) + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_configuring_manifest), + mManifestFile.path, + ) prepareManifestConfiguration(this) + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_configuring_package_name), + packageName, + ) setArscPackageName(packageName) + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_processing), + context.getString(R.string.text_updating_project_config), + ) updateProjectConfig(this) - copyAssetsRecursively("", File(buildPath, "assets")) - copyLibrariesByConfig(this) - setScriptFile(sourcePath) + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_copying_assets_to), + File(buildPath, "assets").path, + ) + copyAssetsRecursively(context, "", File(buildPath, "assets")) + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_processing), + context.getString(R.string.text_copying_native_libraries), + ) + copyLibrariesByConfig(context, this) + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_processing_source), + sourcePath, + ) + setScriptFile(context, sourcePath) + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_processing), + context.getString(R.string.text_applying_binary_resources), + ) } } @@ -203,6 +377,7 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File fun editManifest(): ManifestEditor = ManifestEditorWithAuthorities(FileInputStream(mManifestFile)).also { mManifestEditor = it } private fun updateProjectConfig(config: ProjectConfig) { + ensureNotCancelled() // 这里为什么要有这样的一个方法? ( // 会不会是因为有些配置需要写入到文件中, 这些配置包括自增的版本号, 用户的选择或键入值等等 @@ -222,7 +397,8 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File ProjectConfig.fromProjectDir(config.sourcePath)?.let { sourceProjectConfig -> sourceProjectConfig .setBuildInfo(BuildInfo.generate(sourceProjectConfig.buildInfo.buildNumber + 1)) - File(ProjectConfig.configFileOfDir(config.sourcePath)).writeText(sourceProjectConfig.toJson(true)) + mPendingProjectConfigFile = File(ProjectConfig.configFileOfDir(config.sourcePath)) + mPendingProjectConfigJson = sourceProjectConfig.toJson(true) return@run sourceProjectConfig } } @@ -254,11 +430,46 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } } + // Commit project config changes only after a successful build. + // zh-CN: 仅在构建成功后提交项目配置变更. + fun commitProjectConfigIfNeeded(context: Context) = also { + val pendingFile = mPendingProjectConfigFile + val pendingJson = mPendingProjectConfigJson + if (pendingFile == null || pendingJson == null) { + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_processing), + context.getString(R.string.text_sign_stage_completed), + ) + return@also + } + ensureNotCancelled() + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_writing_project_config), + pendingFile.path, + ) + pendingFile.writeText(pendingJson) + mPendingProjectConfigFile = null + mPendingProjectConfigJson = null + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_processing), + context.getString(R.string.text_sign_stage_completed), + ) + } + @Throws(Exception::class) - fun build() = also { - mProgressCallback?.let { callback -> GlobalAppContext.post { callback.onBuild(this) } } + fun build(context: Context) = also { + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_processing), + context.getString(R.string.text_building_resources), + ) mProjectConfig.iconBitmapGetter?.let { callable -> runCatching { + ensureNotCancelled() val tableBlock = TableBlock.load(mResourcesArscFile) val packageName = "${GlobalAppContext.get().packageName}.inrt" val packageBlock = tableBlock.getOrCreatePackage(0x7f, packageName).also { @@ -273,17 +484,42 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File it.createNewFile() } } + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_writing_app_icon), + file.path, + ) callable.call()?.compress(Bitmap.CompressFormat.PNG, 100, FileOutputStream(file)) }.onFailure { throw RuntimeException(it) } } mManifestEditor?.let { + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_writing_manifest), + mManifestFile.path, + ) it.commit() it.writeTo(FileOutputStream(mManifestFile)) } - mArscPackageName?.let { buildArsc() } + mArscPackageName?.let { + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_writing_resources_arsc), + mResourcesArscFile.path, + ) + buildArsc() + } + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_processing), + context.getString(R.string.text_build_completed), + ) } - private fun copyAssetsRecursively(assetPath: String, targetFile: File) { + private fun copyAssetsRecursively(context: Context, assetPath: String, targetFile: File) { + ensureNotCancelled() if (targetFile.isFile && targetFile.exists()) return val list = mAssetManager.list(assetPath) ?: return if (list.isEmpty()) /* asset is a file */ { @@ -292,9 +528,14 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File return } } + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_copying_asset), + "assets/$assetPath -> ${targetFile.path}", + ) mAssetManager.open(assetPath).use { input -> FileOutputStream(targetFile.absolutePath).use { output -> - input.copyTo(output) + copyStreamWithCancel(input, output) output.flush() } } @@ -302,36 +543,80 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File if (mAssetsDirExcludes.any { assetPath.matches(Regex("$it(/[^/]+)*")) }) { return } + val displayPath = if (assetPath.isEmpty()) "/" else "/$assetPath" + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_preparing_assets_dir), + displayPath, + ) targetFile.delete() targetFile.mkdir() list.forEach { + ensureNotCancelled() val sourcePath = if (assetPath.isEmpty()) it else "$assetPath/$it" - copyAssetsRecursively(sourcePath, File(targetFile, it)) + copyAssetsRecursively(context, sourcePath, File(targetFile, it)) } } } @Throws(Exception::class) - fun sign() = also { - mProgressCallback?.let { callback -> GlobalAppContext.post { callback.onSign(this) } } - val fos = FileOutputStream(outApkFile) - TinySign.sign(File(buildPath), fos) - fos.close() + fun sign(context: Context) = also { + ensureNotCancelled() + notifyStepChanged(ProgressStep.SIGN) + val workspaceDir = File(buildPath) + outApkFile.parentFile?.let { if (!it.exists()) it.mkdirs() } + val unsignedApkFile = outApkFile + val tmpOutputApk = File(outApkFile.parentFile ?: workspaceDir, "${outApkFile.name}.signed.tmp") + if (tmpOutputApk.exists()) { + tmpOutputApk.delete() + } + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_creating_unsigned_apk), + unsignedApkFile.path, + ) + try { + BufferedOutputStream(FileOutputStream(unsignedApkFile, false), 256 * 1024).use { fos -> + TinySign.sign(workspaceDir, fos) + fos.flush() + } + } catch (e: Exception) { + if (tmpOutputApk.exists() && !tmpOutputApk.delete()) { + Log.w(TAG, "Failed to delete temporary signed apk after unsigned apk creation failure: ${tmpOutputApk.path}") + } + if (e.hasNoSpaceLeft()) { + throw IOException("No space left on device while creating unsigned APK: ${unsignedApkFile.path}", e) + } + throw e + } + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_unsigned_apk_created), + unsignedApkFile.path, + ) val defaultKeyStoreFile = File(buildPath, "default_key_store.bks") - val tmpOutputApk = File(buildPath, "temp.apk") + if (mProjectConfig.keyStore == null) { - // Replace FileUtils.copyInputStreamToFile(...). - // zh-CN: 替换 FileUtils.copyInputStreamToFile(...). - defaultKeyStoreFile.parentFile?.let { if (!it.exists()) it.mkdirs() } - GlobalAppContext.get().assets.open("default_key_store.bks").use { input -> - FileOutputStream(defaultKeyStoreFile, false).use { output -> - input.copyTo(output, bufferSize = 16 * 1024) - output.fd.sync() + // Replace FileUtils.copyInputStreamToFile(...). + // zh-CN: 替换 FileUtils.copyInputStreamToFile(...). + ensureNotCancelled() + defaultKeyStoreFile.parentFile?.let { if (!it.exists()) it.mkdirs() } + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_preparing_keystore), + defaultKeyStoreFile.path, + ) + GlobalAppContext.get().assets.open("default_key_store.bks").use { input -> + FileOutputStream(defaultKeyStoreFile, false).use { output -> + copyStreamWithCancel(input, output) + output.fd.sync() + } } } - val signer = ApkSigner(outApkFile, tmpOutputApk).apply { + ensureNotCancelled() + val signer = ApkSigner(unsignedApkFile, tmpOutputApk).apply { useDefaultSignatureVersion = false v1SigningEnabled = "V1" in mProjectConfig.signatureScheme v2SigningEnabled = "V2" in mProjectConfig.signatureScheme @@ -350,24 +635,84 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File alias = it.alias aliasPassword = AESUtils.decrypt(it.aliasPassword) } + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_using_keystore), + keyStoreFile.path, + ) // Re-sign using ApkSigner. // zh-CN: 使用 ApkSigner 重新签名. - if (!signer.signRelease(keyStoreFile, password, alias, aliasPassword)) { - throw java.lang.RuntimeException("Failed to re-sign using ApkSigner") + ensureNotCancelled() + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_processing), + context.getString(R.string.text_re_signing_apk), + ) + try { + if (!signer.signRelease(keyStoreFile, password, alias, aliasPassword)) { + throw RuntimeException("Failed to re-sign using ApkSigner") + } + } catch (e: Exception) { + if (tmpOutputApk.exists() && !tmpOutputApk.delete()) { + Log.w(TAG, "Failed to delete temporary signed apk after re-sign failure: ${tmpOutputApk.path}") + } + if (e.hasNoSpaceLeft()) { + throw IOException("No space left on device while re-signing APK: ${tmpOutputApk.path}", e) + } + throw e } try { - outApkFile.parentFile?.let { if (!it.exists()) it.mkdirs() } - tmpOutputApk.copyTo(outApkFile, overwrite = true) - } catch (e: java.lang.Exception) { - throw java.lang.RuntimeException(e) + ensureNotCancelled() + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_writing_signed_apk), + outApkFile.path, + ) + if (outApkFile.exists() && !outApkFile.delete()) { + throw IOException("Failed to delete unsigned apk before replace: ${outApkFile.path}") + } + if (!tmpOutputApk.renameTo(outApkFile)) { + copyFileWithLargeBuffer(tmpOutputApk, outApkFile) + } + } catch (e: Exception) { + if (e.hasNoSpaceLeft()) { + throw IOException("No space left on device while writing signed APK: ${outApkFile.path}", e) + } + throw RuntimeException(e) + } finally { + if (tmpOutputApk.exists() && !tmpOutputApk.delete()) { + Log.w(TAG, "Failed to delete temporary signed apk: ${tmpOutputApk.path}") + } } + notifyStepProgress( + ProgressStep.SIGN, + context.getString(R.string.text_sign_completed), + outApkFile.path, + ) } - fun cleanWorkspace() = also { - mProgressCallback?.let { callback -> GlobalAppContext.post { callback.onClean(this) } } - delete(File(buildPath)) + fun cleanWorkspace(context: Context) = also { + notifyStepChanged(ProgressStep.CLEAN) + val workspace = File(buildPath) + val totalTargets = countDeleteTargets(workspace).coerceAtLeast(1) + val deletedTargets = intArrayOf(0) + notifyStepProgress( + ProgressStep.CLEAN, + context.getString(R.string.text_cleaning_workspace), + workspace.path, + ) + deleteWithProgress(context, workspace, totalTargets, deletedTargets) + notifyStepProgress( + ProgressStep.CLEAN, + context.getString(R.string.text_clean_completed), + workspace.path, + ) + } + + fun finish() = also { + mProgressCallback?.let { callback -> GlobalAppContext.post { callback.onFinished(this) } } } @Throws(IOException::class) @@ -377,23 +722,86 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File private fun buildArsc() { val oldArsc = File(buildPath, "resources.arsc") val newArsc = File(buildPath, "resources.arsc.new") - val decoder = ARSCDecoder(BufferedInputStream(FileInputStream(oldArsc)), null, false) - decoder.CloneArsc(FileOutputStream(newArsc), mArscPackageName, true) + BufferedInputStream(FileInputStream(oldArsc), 256 * 1024).use { input -> + BufferedOutputStream(FileOutputStream(newArsc, false), 256 * 1024).use { output -> + val decoder = ARSCDecoder(input, null, false) + decoder.CloneArsc(output, mArscPackageName, true) + output.flush() + } + } oldArsc.delete() - newArsc.renameTo(oldArsc) + if (!newArsc.renameTo(oldArsc)) { + copyFileWithLargeBuffer(newArsc, oldArsc) + newArsc.delete() + } } - private fun delete(file: File) { - file.apply { if (isDirectory) listFiles()?.forEach { delete(it) } }.also { it.delete() } + private fun copyFileWithLargeBuffer(source: File, target: File, bufferSize: Int = 256 * 1024) { + FileInputStream(source).use { input -> + FileOutputStream(target, false).use { output -> + val buffer = ByteArray(bufferSize) + var len: Int + while (input.read(buffer).also { len = it } > 0) { + ensureNotCancelled() + output.write(buffer, 0, len) + } + } + } + } + + private fun Throwable.hasNoSpaceLeft(): Boolean { + var current: Throwable? = this + while (current != null) { + val message = current.message.orEmpty() + if (message.contains("ENOSPC", ignoreCase = true) || message.contains("No space left on device", ignoreCase = true)) { + return true + } + current = current.cause + } + return false + } + + private fun countDeleteTargets(file: File): Int { + if (!file.exists()) { + return 0 + } + return if (file.isDirectory) { + 1 + (file.listFiles()?.sumOf(::countDeleteTargets) ?: 0) + } else { + 1 + } + } + + private fun deleteWithProgress(context: Context, file: File, totalTargets: Int, deletedTargets: IntArray) { + ensureNotCancelled() + if (file.isDirectory) { + file.listFiles()?.forEach { child -> + deleteWithProgress(context, child, totalTargets, deletedTargets) + } + } + notifyStepProgress( + ProgressStep.CLEAN, + context.getString(R.string.text_deleting), + file.path, + ) + file.delete() + deletedTargets[0] += 1 + } + + enum class ProgressStep { + PREPARE, + BUILD, + SIGN, + CLEAN, } interface ProgressCallback { - fun onPrepare(builder: ApkBuilder) fun onBuild(builder: ApkBuilder) fun onSign(builder: ApkBuilder) fun onClean(builder: ApkBuilder) - + fun onStepProgress(builder: ApkBuilder, title: String, detail: String?) + fun onFinished(builder: ApkBuilder) } private inner class ManifestEditorWithAuthorities(manifestInputStream: InputStream?) : ManifestEditor(manifestInputStream) { @@ -422,7 +830,8 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } } - private fun copyLibrariesByConfig(config: ProjectConfig) { + private fun copyLibrariesByConfig(context: Context, config: ProjectConfig) { + ensureNotCancelled() // @Hint by SuperMonster003 on Dec 11, 2023. // ! The list contains only abi names not matching the canonical name itself. @@ -434,47 +843,78 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File // Try extracting native libraries from installed plugin APKs if needed. // zh-CN: 如有需要, 尝试从已安装插件 APK 中解压 native 库文件. - ensureAndExtractPluginLibrariesIfNeeded(config, potentialAbiAliasList) + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_processing), + context.getString(R.string.text_resolving_plugin_native_libraries), + ) + ensureAndExtractPluginLibrariesIfNeeded(context, config, potentialAbiAliasList) config.abis.forEach { abiCanonicalName -> - copyLibrariesByAbi(abiCanonicalName, abiCanonicalName) + ensureNotCancelled() + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_copying_libraries_for_abi), + abiCanonicalName, + ) + copyLibrariesByAbi(context, abiCanonicalName, abiCanonicalName) potentialAbiAliasList[abiCanonicalName]?.let { abiAliasName -> - copyLibrariesByAbi(abiAliasName, abiCanonicalName) + copyLibrariesByAbi(context, abiAliasName, abiCanonicalName) } } } private fun ensureAndExtractPluginLibrariesIfNeeded( + context: Context, config: ProjectConfig, potentialAbiAliasList: Map, ) { + ensureNotCancelled() Lib.entries.mapNotNull { if (it.isPlugin && config.libs.contains(it.label)) it.toPluginPair() else null }.forEach { (lib, plugin) -> + ensureNotCancelled() // Select plugin service by variant. // zh-CN: 通过 variant 选择插件服务. val (serviceInfo, selectedVariant) = selectPluginServiceOrThrow( lib = lib, action = plugin.action, ) + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_selected_plugin), + "${lib.label} (${selectedVariant.variant}) from ${serviceInfo.packageName}", + ) Log.i(TAG, "Selected ${lib.label} plugin: variant=${selectedVariant.variant}, pkg=${serviceInfo.packageName}") // Extract libraries from installed plugin APK (variant-aware). // zh-CN: 从已安装插件 APK 中解压 so 文件, 并按变体裁剪. extractLibrariesFromPluginApkOrThrow( + context = context, config = config, requiredLibNames = selectedVariant.libsToInclude, serviceInfo = serviceInfo, potentialAbiAliasList = potentialAbiAliasList, ) + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_extracted_plugin_libraries), + lib.label, + ) // Extract assets (models/labels) from installed plugin APK (variant-aware). // zh-CN: 从已安装插件 APK 中解压 assets 资源 (models/labels), 并按变体裁剪. extractAssetsFromPluginApkOrThrow( + context = context, serviceInfo = serviceInfo, pluginLibVariant = selectedVariant, ) + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_extracted_plugin_assets), + lib.label, + ) } } @@ -482,6 +922,7 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File lib: Lib, action: String, ): Pair { + ensureNotCancelled() val pm = globalContext.packageManager val moduleLabel = lib.label val pluginPair = lib.toPluginPair() @@ -526,6 +967,7 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } private fun queryPluginInfoBlocking(context: Context, serviceInfo: ServiceInfo, pluginPair: Pair): PluginLibVariant { + ensureNotCancelled() // Bind service and call getInfo() synchronously (packaging-time only). // zh-CN: 同步绑定服务并调用 getInfo() (仅打包阶段使用). val latch = CountDownLatch(1) @@ -586,9 +1028,11 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } private fun extractAssetsFromPluginApkOrThrow( + context: Context, serviceInfo: ServiceInfo, pluginLibVariant: PluginLibVariant, ) { + ensureNotCancelled() val pm = globalContext.packageManager val appInfo = getApplicationInfoCompat(pm, serviceInfo.packageName) @@ -603,7 +1047,9 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File // zh-CN: 解压必需前缀. val missingRequired = mutableListOf() requiredPrefixes.forEach { prefix -> + ensureNotCancelled() val ok = extractAssetsByPrefixFromApks( + context = context, apkPaths = apkPaths, assetPrefix = prefix, ) @@ -620,7 +1066,9 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File // Extract optional prefixes (best-effort). // zh-CN: 解压可选前缀 (尽力而为). optionalPrefixes.forEach { prefix -> + ensureNotCancelled() extractAssetsByPrefixFromApks( + context = context, apkPaths = apkPaths, assetPrefix = prefix, ) @@ -628,15 +1076,19 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } private fun extractAssetsByPrefixFromApks( + context: Context, apkPaths: List, assetPrefix: String, ): Boolean { + ensureNotCancelled() var extractedAny = false apkPaths.forEach { apkPath -> + ensureNotCancelled() runCatching { ZipFile(apkPath).use { zip -> val entries = zip.entries() while (entries.hasMoreElements()) { + ensureNotCancelled() val entry: ZipEntry = entries.nextElement() val name = entry.name if (!name.startsWith(assetPrefix)) continue @@ -646,10 +1098,15 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File val outFile = File(buildPath, "assets/$relative").apply { parentFile?.let { parent -> if (!parent.exists()) parent.mkdirs() } } + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_extracting_plugin_asset), + "$apkPath!/$name -> ${outFile.path}", + ) zip.getInputStream(entry).use { input -> FileOutputStream(outFile, false).use { output -> - input.copyTo(output, bufferSize = 16 * 1024) + copyStreamWithCancel(input, output) output.fd.sync() } } @@ -667,11 +1124,13 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } private fun extractLibrariesFromPluginApkOrThrow( + context: Context, config: ProjectConfig, requiredLibNames: List, serviceInfo: ServiceInfo, potentialAbiAliasList: Map, ) { + ensureNotCancelled() val pm = globalContext.packageManager val appInfo = getApplicationInfoCompat(pm, serviceInfo.packageName) @@ -683,13 +1142,16 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File val missingPairs = mutableListOf>() // (abi, soName) config.abis.forEach { abiCanonicalName -> + ensureNotCancelled() val abiCandidates = buildList { add(abiCanonicalName) potentialAbiAliasList[abiCanonicalName]?.let { add(it) } }.distinct() requiredLibNames.forEach { soName -> + ensureNotCancelled() val ok = extractFirstMatchedSoFromApks( + context = context, apkPaths = apkPaths, abiCandidates = abiCandidates, soName = soName, @@ -718,23 +1180,32 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } private fun extractFirstMatchedSoFromApks( + context: Context, apkPaths: List, abiCandidates: List, soName: String, abiDestName: String, ): Boolean { + ensureNotCancelled() apkPaths.forEach { apkPath -> + ensureNotCancelled() runCatching { ZipFile(apkPath).use { zip -> abiCandidates.forEach { abiInApk -> + ensureNotCancelled() val entryName = "lib/$abiInApk/$soName" val entry = zip.getEntry(entryName) ?: return@forEach val outFile = File(buildPath, "lib/$abiDestName/$soName").apply { parentFile?.let { parent -> if (!parent.exists()) parent.mkdirs() } } + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_extracting_plugin_so), + "$apkPath!/$entryName -> ${outFile.path}", + ) zip.getInputStream(entry).use { input -> FileOutputStream(outFile, false).use { output -> - input.copyTo(output, bufferSize = 16 * 1024) + copyStreamWithCancel(input, output) output.fd.sync() } } @@ -760,18 +1231,26 @@ open class ApkBuilder(apkInputStream: InputStream?, private val outApkFile: File } } - private fun copyLibrariesByAbi(abiSrcName: String, abiDestName: String) { + private fun copyLibrariesByAbi(context: Context, abiSrcName: String, abiDestName: String) { + ensureNotCancelled() // @Reference to LZX284 (https://github.com/LZX284) by SuperMonster003 on Dec 11, 2023. // ! http://pr.autojs6.com/187/files#diff-d932ac49867d4610f8eeb21b59306e8e923d016cbca192b254caebd829198856R61 val srcLibDir = File(appApkFile.parent, LIBRARY_DIR).path mLibsIncludes.distinct().forEach { libName -> + ensureNotCancelled() runCatching { - File(srcLibDir, "$abiSrcName/$libName").takeIf { it.exists() }?.copyTo( - File(buildPath, "lib/$abiDestName/$libName"), - overwrite = true - ) + val srcFile = File(srcLibDir, "$abiSrcName/$libName") + val destFile = File(buildPath, "lib/$abiDestName/$libName") + srcFile.takeIf { it.exists() }?.let { + notifyStepProgress( + ProgressStep.BUILD, + context.getString(R.string.text_copying_library), + "${srcFile.path} -> ${destFile.path}", + ) + it.copyTo(destFile, overwrite = true) + } }.onFailure { it.printStackTrace() } } } diff --git a/app/src/main/java/org/autojs/autojs/apkbuilder/ApkPackager.java b/app/src/main/java/org/autojs/autojs/apkbuilder/ApkPackager.java index 3aae00bb..cbbc0232 100644 --- a/app/src/main/java/org/autojs/autojs/apkbuilder/ApkPackager.java +++ b/app/src/main/java/org/autojs/autojs/apkbuilder/ApkPackager.java @@ -4,12 +4,16 @@ import android.text.TextUtils; import org.autojs.autojs.apkbuilder.util.StreamUtils; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -20,8 +24,9 @@ import pxb.android.tinysign.TinySign; */ public class ApkPackager { - private InputStream mApkInputStream; - private String mWorkspacePath; + private final InputStream mApkInputStream; + private final String mWorkspacePath; + private AtomicBoolean mCancelSignal; public ApkPackager(InputStream apkInputStream, String workspacePath) { mApkInputStream = apkInputStream; @@ -33,20 +38,47 @@ public class ApkPackager { mWorkspacePath = workspacePath; } + public ApkPackager setCancelSignal(AtomicBoolean cancelSignal) { + mCancelSignal = cancelSignal; + return this; + } + + // Throw early when cancellation is requested to avoid partial outputs. + // zh-CN: 当收到取消请求时尽早抛出, 以避免产生部分输出. + private void ensureNotCancelled() { + if (mCancelSignal != null && mCancelSignal.get()) { + throw new CancellationException("Build aborted"); + } + if (Thread.currentThread().isInterrupted()) { + throw new CancellationException("Build aborted"); + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") public void unzip() throws IOException { - ZipInputStream zis = new ZipInputStream(mApkInputStream); - for (ZipEntry e = zis.getNextEntry(); e != null; e = zis.getNextEntry()) { - String name = e.getName(); - if (!e.isDirectory() && !TextUtils.isEmpty(name)) { - File file = new File(mWorkspacePath, name); - System.out.println(file); - file.getParentFile().mkdirs(); - FileOutputStream fos = new FileOutputStream(file); - StreamUtils.write(zis, fos); - fos.close(); + // Buffer IO to reduce syscall overhead during APK extraction. + // zh-CN: 使用缓冲 IO 以减少 APK 解压过程中的系统调用开销. + try (ZipInputStream zis = new ZipInputStream(new BufferedInputStream(mApkInputStream, StreamUtils.DEFAULT_BUFFER_SIZE))) { + for (ZipEntry e = zis.getNextEntry(); e != null; e = zis.getNextEntry()) { + ensureNotCancelled(); + String name = e.getName(); + if (!e.isDirectory() && !TextUtils.isEmpty(name)) { + File file = new File(mWorkspacePath, name); + File parentFile = file.getParentFile(); + if (parentFile != null) { + parentFile.mkdirs(); + } + try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file), StreamUtils.DEFAULT_BUFFER_SIZE)) { + byte[] buffer = new byte[StreamUtils.DEFAULT_BUFFER_SIZE]; + int len; + while ((len = zis.read(buffer)) > 0) { + ensureNotCancelled(); + bos.write(buffer, 0, len); + } + } + } } } - zis.close(); } public void repackage(String newApkPath) throws Exception { diff --git a/app/src/main/java/org/autojs/autojs/apkbuilder/util/StreamUtils.java b/app/src/main/java/org/autojs/autojs/apkbuilder/util/StreamUtils.java index ae434822..5b495165 100644 --- a/app/src/main/java/org/autojs/autojs/apkbuilder/util/StreamUtils.java +++ b/app/src/main/java/org/autojs/autojs/apkbuilder/util/StreamUtils.java @@ -10,8 +10,10 @@ import java.io.OutputStream; */ public class StreamUtils { + public static final int DEFAULT_BUFFER_SIZE = 16 * 1024; + public static void write(InputStream inputStream, OutputStream out) throws IOException { - byte[] buffer = new byte[4096]; + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; int len; while ((len = inputStream.read(buffer)) > 0) { out.write(buffer, 0, len); @@ -20,7 +22,7 @@ public class StreamUtils { public static byte[] readAsBytes(InputStream inputStream) throws IOException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - byte[] buffer = new byte[4096]; + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; int len; while ((len = inputStream.read(buffer)) > 0) { outputStream.write(buffer, 0, len); diff --git a/app/src/main/java/org/autojs/autojs/ui/project/BuildActivity.java b/app/src/main/java/org/autojs/autojs/ui/project/BuildActivity.java index 0aa08201..f04a60ad 100644 --- a/app/src/main/java/org/autojs/autojs/ui/project/BuildActivity.java +++ b/app/src/main/java/org/autojs/autojs/ui/project/BuildActivity.java @@ -4,8 +4,10 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.os.SystemClock; import android.text.Editable; import android.text.TextUtils; import android.text.util.Linkify; @@ -24,6 +26,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.util.Pair; import androidx.lifecycle.ViewModelProvider; +import com.afollestad.materialdialogs.DialogAction; import com.afollestad.materialdialogs.MaterialDialog; import com.google.android.flexbox.FlexboxLayout; import com.google.android.material.textfield.TextInputLayout; @@ -35,14 +38,15 @@ import net.dongliu.apk.parser.bean.ApkMeta; import org.autojs.autojs.apkbuilder.ApkBuilder; import org.autojs.autojs.apkbuilder.keystore.KeyStore; import org.autojs.autojs.core.pref.Language; -import org.autojs.autojs.util.DialogUtils; import org.autojs.autojs.external.fileprovider.AppFileProvider; import org.autojs.autojs.model.explorer.Explorers; import org.autojs.autojs.model.script.ScriptFile; +import org.autojs.autojs.pio.PFiles; import org.autojs.autojs.project.ProjectConfig; import org.autojs.autojs.runtime.api.AppUtils; import org.autojs.autojs.runtime.api.AppUtils.Companion.SimpleVersionInfo; import org.autojs.autojs.runtime.api.augment.pinyin.Pinyin; +import org.autojs.autojs.theme.ThemeColorManager; import org.autojs.autojs.ui.BaseActivity; import org.autojs.autojs.ui.common.NotAskAgainDialog; import org.autojs.autojs.ui.error.ErrorDialogActivity; @@ -55,13 +59,17 @@ import org.autojs.autojs.ui.widget.RoundCheckboxWithText; import org.autojs.autojs.util.AndroidUtils; import org.autojs.autojs.util.AndroidUtils.Abi; import org.autojs.autojs.util.BitmapUtils; +import org.autojs.autojs.util.ColorUtils; +import org.autojs.autojs.util.DialogUtils; import org.autojs.autojs.util.EnvironmentUtils; import org.autojs.autojs.util.IntentUtils; import org.autojs.autojs.util.IntentUtils.ToastExceptionHolder; +import org.autojs.autojs.util.StringUtils; import org.autojs.autojs.util.ViewUtils; import org.autojs.autojs.util.WorkingDirectoryUtils; import org.autojs.autojs6.R; import org.autojs.autojs6.databinding.ActivityBuildBinding; +import org.autojs.autojs6.databinding.DialogBuildProgressBinding; import org.jetbrains.annotations.NotNull; import java.io.File; @@ -70,12 +78,14 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; +import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.TreeMap; +import java.util.concurrent.CancellationException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.regex.Pattern; @@ -89,7 +99,7 @@ import static org.autojs.autojs.util.StringUtils.key; * Created by Stardust on Oct 22, 2017. * Modified by SuperMonster003 as of Jan 6, 2026. * - * @noinspection ResultOfMethodCallIgnored + * @noinspection ResultOfMethodCallIgnored, unused */ public class BuildActivity extends BaseActivity implements ApkBuilder.ProgressCallback { @@ -224,6 +234,35 @@ public class BuildActivity extends BaseActivity implements ApkBuilder.ProgressCa private ProjectConfig mProjectConfig; private MaterialDialog mProgressDialog; + private ImageView mProgressPrepareIcon; + private TextView mProgressPrepareText; + private ImageView mProgressBuildIcon; + private TextView mProgressBuildText; + private ImageView mProgressSignIcon; + private TextView mProgressSignText; + private ImageView mProgressCleanIcon; + private TextView mProgressCleanText; + private TextView mProgressPrepareDurationText; + private TextView mProgressBuildDurationText; + private TextView mProgressSignDurationText; + private TextView mProgressCleanDurationText; + private TextView mStateTitleText; + private TextView mStateContentText; + private final EnumMap mStepDurationMs = new EnumMap<>(BuildStep.class); + @Nullable + private BuildStep mCurrentTimingStep; + private long mCurrentTimingStepStartedAtMs; + // Track cancellation state for cooperative build abort. + // zh-CN: 跟踪取消状态, 用于协作式中止构建. + private final AtomicBoolean mBuildCancelled = new AtomicBoolean(false); + @Nullable + private File mBuildWorkspace; + @Nullable + private File mBuildOutputApk; + @Nullable + private File mBuildOutputApkBackup; + @Nullable + private Thread mBuildThread; private String mSource; private boolean mIsDefaultIcon = true; private boolean mIsProjectLevelBuilding; @@ -712,7 +751,7 @@ public class BuildActivity extends BaseActivity implements ApkBuilder.ProgressCa } @Override - protected void onNewIntent(Intent intent) { + protected void onNewIntent(@NonNull Intent intent) { super.onNewIntent(intent); } @@ -910,18 +949,40 @@ public class BuildActivity extends BaseActivity implements ApkBuilder.ProgressCa block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS; } - @SuppressWarnings("ResultOfMethodCallIgnored") @SuppressLint("CheckResult") private void doBuildingApk() { ProjectConfig projectConfig = determineProjectConfig(); File buildPath = new File(getCacheDir(), "build/"); File outApk = new File(mOutputPathView.getText().toString(), String.format("%s_v%s.apk", projectConfig.getName(), projectConfig.getVersionName())); + File outApkBackup = null; + try { + outApkBackup = backupExistingOutputApkIfNeeded(outApk); + } catch (IOException e) { + Log.e(LOG_TAG, "Failed to backup existing output apk", e); + ErrorDialogActivity.showErrorDialog(this, R.string.text_failed_to_build, e.getMessage()); + return; + } + mBuildCancelled.set(false); + mBuildWorkspace = buildPath; + mBuildOutputApk = outApk; + mBuildOutputApkBackup = outApkBackup; showProgressDialog(); - Observable.fromCallable(() -> callApkBuilder(buildPath, outApk, projectConfig)) + Observable.fromCallable(() -> { + mBuildThread = Thread.currentThread(); + try { + return callApkBuilder(buildPath, outApk, projectConfig); + } finally { + mBuildThread = null; + } + }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(apkBuilder -> { + if (mBuildCancelled.get()) { + handleBuildAborted(); + return; + } if (apkBuilder != null) { onBuildSuccessful(outApk); } else { @@ -930,6 +991,34 @@ public class BuildActivity extends BaseActivity implements ApkBuilder.ProgressCa }, this::onBuildFailed); } + @Nullable + private File backupExistingOutputApkIfNeeded(@NonNull File outApk) throws IOException { + if (!outApk.exists()) { + return null; + } + File parentDir = outApk.getAbsoluteFile().getParentFile(); + if (parentDir == null) { + throw new IOException("Invalid output apk path: " + outApk.getPath()); + } + File backupApk = new File(parentDir, outApk.getName() + "." + System.nanoTime() + ".bak"); + if (backupApk.exists() && !backupApk.delete()) { + throw new IOException("Failed to remove stale output apk backup: " + backupApk.getPath()); + } + if (outApk.renameTo(backupApk)) { + return backupApk; + } + if (!PFiles.copy(outApk.getPath(), backupApk.getPath())) { + throw new IOException("Failed to backup existing output apk: " + outApk.getPath()); + } + if (!outApk.delete()) { + if (!backupApk.delete()) { + Log.w(LOG_TAG, "Failed to delete incomplete backup apk after backup cleanup failure: " + backupApk.getPath()); + } + throw new IOException("Failed to remove original output apk after backup copy: " + outApk.getPath()); + } + return backupApk; + } + private ProjectConfig determineProjectConfig() { ArrayList abis = collectCheckedItems(mFlexboxAbisView); ArrayList libs = collectCheckedItems(mFlexboxLibsView); @@ -988,71 +1077,362 @@ public class BuildActivity extends BaseActivity implements ApkBuilder.ProgressCa InputStream templateApk = getAssets().open(TEMPLATE_APK_NAME); return new ApkBuilder(templateApk, outApk, buildPath.getPath()) .setProgressCallback(BuildActivity.this) - .prepare() - .withConfig(projectConfig) - .build() - .sign() - .cleanWorkspace(); + .setCancelSignal(mBuildCancelled) + .prepare(BuildActivity.this) + .withConfig(BuildActivity.this, projectConfig) + .build(BuildActivity.this) + .sign(BuildActivity.this) + .commitProjectConfigIfNeeded(BuildActivity.this) + .cleanWorkspace(BuildActivity.this) + .finish(); + } + + private enum BuildStep { + PREPARE, + BUILD, + SIGN, + CLEAN, + FINISHED, } private void showProgressDialog() { + dismissProgressDialog(); + View contentView = DialogBuildProgressBinding.inflate(getLayoutInflater()).getRoot(); + mProgressPrepareIcon = contentView.findViewById(R.id.icon_prepare); + mProgressPrepareText = contentView.findViewById(R.id.text_prepare); + mProgressBuildIcon = contentView.findViewById(R.id.icon_build); + mProgressBuildText = contentView.findViewById(R.id.text_build); + mProgressSignIcon = contentView.findViewById(R.id.icon_sign); + mProgressSignText = contentView.findViewById(R.id.text_sign); + mProgressCleanIcon = contentView.findViewById(R.id.icon_clean); + mProgressCleanText = contentView.findViewById(R.id.text_clean); + mProgressPrepareDurationText = contentView.findViewById(R.id.text_prepare_duration); + mProgressBuildDurationText = contentView.findViewById(R.id.text_build_duration); + mProgressSignDurationText = contentView.findViewById(R.id.text_sign_duration); + mProgressCleanDurationText = contentView.findViewById(R.id.text_clean_duration); + mStateTitleText = contentView.findViewById(R.id.text_state_title); + mStateContentText = contentView.findViewById(R.id.text_state_content); + resetStepDurations(); + applyThemeColorIcons(mProgressPrepareIcon, mProgressBuildIcon, mProgressSignIcon, mProgressCleanIcon); mProgressDialog = new MaterialDialog.Builder(this) - .progress(true, 100) - .content(R.string.text_in_progress) + .title(R.string.text_building_apk) + .customView(contentView, false) + .neutralText("") + .negativeText("") + .positiveText(R.string.dialog_button_abort) + .positiveColorRes(R.color.dialog_button_caution) + .onPositive((dialog, which) -> requestBuildAbort()) + .autoDismiss(false) .cancelable(false) .show(); + updateProgressDialog(BuildStep.PREPARE); + mStateTitleText.setText(getString(R.string.text_property_colon, getString(R.string.text_processing))); + } + + private void applyThemeColorIcons(ImageView... imageView) { + int backgroundColor = getColor(R.color.window_background); + int adjustedColor = ColorUtils.adjustColorForContrast(backgroundColor, ThemeColorManager.getColorPrimary(), 2.3); + for (ImageView iv : imageView) { + iv.setImageTintList(ColorStateList.valueOf(adjustedColor)); + } + } + + private void updateProgressDialog(BuildStep currentStep) { + if (mProgressDialog == null) { + return; + } + recordStepDuration(currentStep); + setProgressStep(mProgressPrepareIcon, mProgressPrepareText, mProgressPrepareDurationText, BuildStep.PREPARE, currentStep); + setProgressStep(mProgressBuildIcon, mProgressBuildText, mProgressBuildDurationText, BuildStep.BUILD, currentStep); + setProgressStep(mProgressSignIcon, mProgressSignText, mProgressSignDurationText, BuildStep.SIGN, currentStep); + setProgressStep(mProgressCleanIcon, mProgressCleanText, mProgressCleanDurationText, BuildStep.CLEAN, currentStep); + } + + private void setProgressStep(@Nullable ImageView iconView, @Nullable TextView stateView, @Nullable TextView durationView, BuildStep step, BuildStep currentStep) { + if (step.ordinal() < currentStep.ordinal()) { + if (iconView != null) iconView.setImageResource(R.drawable.ic_check_mark); + if (stateView != null) stateView.setText(ensureTextEndsWithDot(stateView.getText())); + if (durationView != null && currentStep.ordinal() - step.ordinal() == 1) { + updateStepDurationText(durationView, step, SystemClock.elapsedRealtime()); + } + } else if (step == currentStep) { + if (iconView != null) iconView.setImageResource(R.drawable.ic_right_arrow); + if (stateView != null) stateView.setText(ensureTextEndsWithHalfEllipsis(stateView.getText())); + } else { + if (iconView != null) iconView.setImageResource(R.drawable.transparent); + if (stateView != null) stateView.setText(ensureTextEndsWithoutDot(stateView.getText())); + } + } + + private void resetStepDurations() { + mStepDurationMs.clear(); + mCurrentTimingStep = null; + mCurrentTimingStepStartedAtMs = 0L; + } + + private void recordStepDuration(@NonNull BuildStep currentStep) { + long now = SystemClock.elapsedRealtime(); + if (mCurrentTimingStep != null && mCurrentTimingStep != currentStep && isTrackedStep(mCurrentTimingStep)) { + mStepDurationMs.put(mCurrentTimingStep, Math.max(0L, now - mCurrentTimingStepStartedAtMs)); + } + if (isTrackedStep(currentStep) && !mStepDurationMs.containsKey(currentStep)) { + if (mCurrentTimingStep != currentStep) { + mCurrentTimingStep = currentStep; + mCurrentTimingStepStartedAtMs = now; + } + } else if (!isTrackedStep(currentStep)) { + mCurrentTimingStep = null; + mCurrentTimingStepStartedAtMs = 0L; + } + } + + private boolean isTrackedStep(@NonNull BuildStep step) { + return step == BuildStep.PREPARE + || step == BuildStep.BUILD + || step == BuildStep.SIGN + || step == BuildStep.CLEAN; + } + + @Nullable + private Long resolveStepDurationMs(@NonNull BuildStep step, long now) { + Long duration = mStepDurationMs.get(step); + if (duration != null) { + return duration; + } + if (step == mCurrentTimingStep) { + return Math.max(0L, now - mCurrentTimingStepStartedAtMs); + } + return null; + } + + private void updateStepDurationText(@NonNull TextView textView, @NonNull BuildStep step, long now) { + Long durationMs = resolveStepDurationMs(step, now); + textView.setText(durationMs == null ? "" : formatStepDuration(durationMs)); + } + + @NonNull + private String formatStepDuration(long durationMs) { + long safeDurationMs = Math.max(0L, durationMs); + long tenths = Math.round(safeDurationMs / 100.0d); + long integerPart = tenths / 10; + long decimalPart = tenths % 10; + // if (decimalPart == 0L) { + // return "[ " + integerPart + " s ]"; + // } + return "[ " + integerPart + "." + decimalPart + " s ]"; + } + + private String ensureTextEndsWithDot(CharSequence text) { + return ensureTextEndsWithoutDot(text) + "."; + } + + private String ensureTextEndsWithHalfEllipsis(CharSequence text) { + return ensureTextEndsWithoutDot(text) + StringUtils.str(R.string.text_half_ellipsis); + } + + private String ensureTextEndsWithoutDot(CharSequence text) { + var tmp = text.toString(); + while (tmp.length() > 0 && tmp.charAt(tmp.length() - 1) == '.') { + tmp = tmp.subSequence(0, tmp.length() - 1).toString(); + } + return tmp; + } + + // Abort build cooperatively and trigger cleanup as early as possible. + // zh-CN: 协作式中止构建, 并尽早触发清理. + private void requestBuildAbort() { + if (!mBuildCancelled.compareAndSet(false, true)) { + return; + } + if (mProgressDialog != null) { + mProgressDialog.setTitle(R.string.text_aborting); + View button = mProgressDialog.getActionButton(DialogAction.POSITIVE); + if (button != null) { + button.setEnabled(false); + } + } + Thread buildThread = mBuildThread; + if (buildThread != null) { + buildThread.interrupt(); + } + cleanupBuildArtifactsAsync(false); + } + + private void cleanupBuildArtifactsAsync(boolean restoreOutputApk) { + File workspace = mBuildWorkspace; + File outApk = mBuildOutputApk; + File outApkBackup = mBuildOutputApkBackup; + if (workspace == null && outApk == null && outApkBackup == null) { + return; + } + Schedulers.io().scheduleDirect(() -> cleanupBuildArtifacts(workspace, outApk, outApkBackup, restoreOutputApk)); + } + + // Best-effort cleanup for workspace and output artifacts. + // zh-CN: 对工作区和输出产物执行尽力清理. + private void cleanupBuildArtifacts(@Nullable File workspace, @Nullable File outApk, @Nullable File outApkBackup, boolean restoreOutputApk) { + if (workspace != null && workspace.exists()) { + PFiles.deleteRecursively(workspace); + } + if (outApk != null && outApk.exists()) { + outApk.delete(); + } + if (restoreOutputApk) { + restoreOutputApkIfNeeded(outApk, outApkBackup); + } + } + + private void restoreOutputApkIfNeeded(@Nullable File outApk, @Nullable File outApkBackup) { + if (outApk == null || outApkBackup == null || !outApkBackup.exists()) { + return; + } + if (outApk.exists() && !outApk.delete()) { + Log.w(LOG_TAG, "Failed to delete output apk before restore: " + outApk.getPath()); + } + if (!outApkBackup.renameTo(outApk)) { + if (!PFiles.copy(outApkBackup.getPath(), outApk.getPath())) { + Log.w(LOG_TAG, "Failed to restore output apk backup: " + outApk.getPath()); + return; + } + if (!outApkBackup.delete()) { + Log.w(LOG_TAG, "Failed to delete output apk backup: " + outApkBackup.getPath()); + } + } + } + + private void discardOutputApkBackup() { + File outApkBackup = mBuildOutputApkBackup; + if (outApkBackup != null && outApkBackup.exists() && !outApkBackup.delete()) { + Log.w(LOG_TAG, "Failed to delete output apk backup: " + outApkBackup.getPath()); + } + } + + private void handleBuildAborted() { + dismissProgressDialog(); + cleanupBuildArtifactsAsync(true); + ViewUtils.showToast(this, getString(R.string.text_operation_aborted)); + finishBuildState(); + } + + private void finishBuildState() { + mBuildWorkspace = null; + mBuildOutputApk = null; + mBuildOutputApkBackup = null; + mBuildThread = null; + mBuildCancelled.set(false); + } + + private void dismissProgressDialog() { + if (mProgressDialog == null) { + return; + } + mProgressDialog.dismiss(); + mProgressDialog = null; + mProgressPrepareIcon = null; + mProgressPrepareText = null; + mProgressBuildIcon = null; + mProgressBuildText = null; + mProgressSignIcon = null; + mProgressSignText = null; + mProgressCleanIcon = null; + mProgressCleanText = null; + mProgressPrepareDurationText = null; + mProgressBuildDurationText = null; + mProgressSignDurationText = null; + mProgressCleanDurationText = null; + mStateTitleText = null; + mStateContentText = null; + resetStepDurations(); } private void onBuildFailed(Throwable error) { - if (mProgressDialog != null) { - mProgressDialog.dismiss(); - mProgressDialog = null; + if (mBuildCancelled.get() || error instanceof CancellationException) { + handleBuildAborted(); + return; } + dismissProgressDialog(); + restoreOutputApkIfNeeded(mBuildOutputApk, mBuildOutputApkBackup); ErrorDialogActivity.showErrorDialog(this, R.string.text_failed_to_build, error.getMessage()); Log.e(LOG_TAG, "Failed to build", error); + finishBuildState(); } private void onBuildSuccessful(File outApk) { + if (mBuildCancelled.get()) { + handleBuildAborted(); + return; + } + discardOutputApkBackup(); Explorers.workspace().refreshAll(); - mProgressDialog.dismiss(); - mProgressDialog = null; - new MaterialDialog.Builder(this) - .title(R.string.text_build_succeeded) - .content(getString(R.string.format_build_succeeded, outApk.getPath())) - .positiveText(R.string.text_install) - .positiveColorRes(R.color.dialog_button_attraction) - .onPositive((dialog, which) -> IntentUtils.installApk( + // dismissProgressDialog(); + if (mProgressDialog != null) { + mProgressDialog.setTitle(R.string.text_build_succeeded); + mStateTitleText.setText(getString(R.string.text_property_colon, getString(R.string.text_built_apk_file_path))); + mStateContentText.setText(outApk.getPath()); + var positiveButton = mProgressDialog.getActionButton(DialogAction.POSITIVE); + positiveButton.setEnabled(true); + positiveButton.setText(getString(R.string.text_install)); + positiveButton.setTextColor(getColor(R.color.dialog_button_attraction)); + positiveButton.setOnClickListener(v -> { + IntentUtils.installApk( BuildActivity.this, outApk.getPath(), AppFileProvider.AUTHORITY, new ToastExceptionHolder(BuildActivity.this) - )) - .negativeText(R.string.text_cancel) - .negativeColorRes(R.color.dialog_button_default) - .neutralText(R.string.dialog_button_file_information) - .neutralColorRes(R.color.dialog_button_hint) - .onNeutral((dialog, which) -> ApkInfoDialogManager.showApkInfoDialog(dialog.getContext(), outApk)) - .show(); + ); + dismissProgressDialog(); + }); + var negativeButton = mProgressDialog.getActionButton(DialogAction.NEGATIVE); + negativeButton.setEnabled(true); + negativeButton.setText(getString(R.string.text_cancel)); + negativeButton.setTextColor(getColor(R.color.dialog_button_default)); + negativeButton.setOnClickListener(v -> { + dismissProgressDialog(); + }); + var neutralButton = mProgressDialog.getActionButton(DialogAction.NEUTRAL); + neutralButton.setEnabled(true); + neutralButton.setText(getString(R.string.dialog_button_file_information)); + neutralButton.setTextColor(getColor(R.color.dialog_button_hint)); + neutralButton.setOnClickListener(v -> { + ApkInfoDialogManager.showApkInfoDialog(this, outApk); + }); + } + finishBuildState(); } @Override public void onPrepare(@NonNull ApkBuilder builder) { - mProgressDialog.setContent(R.string.apk_builder_prepare); + updateProgressDialog(BuildStep.PREPARE); } @Override public void onBuild(@NonNull ApkBuilder builder) { - mProgressDialog.setContent(R.string.apk_builder_build); + updateProgressDialog(BuildStep.BUILD); } @Override public void onSign(@NonNull ApkBuilder builder) { - mProgressDialog.setContent(R.string.apk_builder_package); + updateProgressDialog(BuildStep.SIGN); } @Override public void onClean(@NonNull ApkBuilder builder) { - mProgressDialog.setContent(R.string.apk_builder_clean); + updateProgressDialog(BuildStep.CLEAN); + } + + @Override + public void onStepProgress(@NonNull ApkBuilder builder, @NonNull String title, @Nullable String detail) { + if (mStateTitleText != null) { + mStateTitleText.setText(getString(R.string.text_property_colon, title)); + } + if (!TextUtils.isEmpty(detail) && mStateContentText != null) { + mStateContentText.setText(detail); + } + } + + @Override + public void onFinished(@NotNull ApkBuilder builder) { + updateProgressDialog(BuildStep.FINISHED); } @SuppressWarnings("ResultOfMethodCallIgnored") diff --git a/app/src/main/res/drawable/ic_check_mark.png b/app/src/main/res/drawable/ic_check_mark.png new file mode 100644 index 00000000..adf58b3a Binary files /dev/null and b/app/src/main/res/drawable/ic_check_mark.png differ diff --git a/app/src/main/res/drawable/ic_right_arrow.png b/app/src/main/res/drawable/ic_right_arrow.png new file mode 100644 index 00000000..73233125 Binary files /dev/null and b/app/src/main/res/drawable/ic_right_arrow.png differ diff --git a/app/src/main/res/layout/dialog_build_progress.xml b/app/src/main/res/layout/dialog_build_progress.xml new file mode 100644 index 00000000..2e92e71c --- /dev/null +++ b/app/src/main/res/layout/dialog_build_progress.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index decd989a..ed9b85dd 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -9,11 +9,11 @@ - مبنى... - تنظيف... - التغليف ... + مبنى + تنظيف + التغليف نسخة غير متوافقة من \"APK Builder\".\nتحميله الآن؟ - خطة... + خطة SDK الجهاز حجم الملف مثبت @@ -536,7 +536,7 @@ توثيق AutoJs6 سجل سجل - مركز اضافات AutoJs6 + AutoJs6 الاضافات الاضافات AutoJs6 إعدادات إعدادات @@ -674,7 +674,7 @@ حذف المجلد حذف الخط هل تريد حذف هذه المراجعة نهائيا? - جارٍ الحذف... + جارٍ الحذف الوصف الغاء التحديد الوجهة @@ -1086,7 +1086,7 @@ لإغلاق، اضغط على \"رجوع\" أو \"خفض الصوت\" معاينة سجل العملية - يعالج + جارٍ المعالجة مشروع موقع المشروع وصول وسائل الإعلام المشروع @@ -1358,4 +1358,58 @@ الاضافة \"%1$s\" غير مُفوّضة في مركز الاضافات لم يتم العثور على الاضافة \"%1$s\" ضمن التطبيقات المثبتة طريقة اتصال الاضافة غير مدعومة + %1$s: + العنوان + المحتوى + جارٍ بناء APK + مسار APK المبني + تنظيف + توقيع + بناء + تحضير + جارٍ تحضير مساحة العمل + جارٍ استخراج APK القالب + اكتمل التحضير + جارٍ نسخ مجلد المشروع + جارٍ نسخ ملف السكربت + اكتملت معالجة المصدر + جارٍ نسخ المجلد + جارٍ نسخ الملف + جارٍ تشفير السكربت + جارٍ استبدال الملف + جارٍ تحضير اعدادات البناء + جارٍ قراءة موارد شاشة البدء + جارٍ ضبط manifest + جارٍ ضبط اسم الحزمة + جارٍ نسخ assets الى + جارٍ معالجة المصدر + جارٍ تطبيق الموارد الثنائية + جارٍ نسخ المكتبات الاصلية + جارٍ تحديث اعدادات المشروع + اكتملت مرحلة التوقيع + جارٍ كتابة اعدادات المشروع + جارٍ بناء الموارد + اكتمل البناء + جارٍ كتابة resources.arsc + جارٍ كتابة manifest + جارٍ كتابة ايقونة التطبيق + جارٍ نسخ asset + جارٍ تحضير مجلد assets + جارٍ نسخ المكتبة + جارٍ تحضير keystore + تم انشاء APK غير موقّع + جارٍ انشاء APK غير موقّع + جارٍ استخدام keystore + جارٍ اعادة توقيع APK + جارٍ كتابة APK الموقّع + اكتمل التوقيع + اكتمل التنظيف + جارٍ تنظيف مساحة العمل + جارٍ تحليل مكتبات الاضافة الاصلية + جارٍ نسخ المكتبات لـ ABI + الاضافة المحددة + تم استخراج مكتبات الاضافة + تم استخراج assets الاضافة + جارٍ استخراج asset الاضافة + جارٍ استخراج ملف so للاضافة \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2d29911f..5fbad24f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -4,11 +4,11 @@ - Building... - Cleaning... - Packaging... + Building + Cleaning + Packaging Incompatible version of \"apk builder\".\nDownload it now? - Preparing... + Preparing Device SDK File size Installed @@ -531,7 +531,7 @@ Docs AutoJs6 Log Log - AutoJs6 Plugin Center + AutoJs6 Plugins Plugins AutoJs6 Settings Settings @@ -669,7 +669,7 @@ Delete folder Delete line Delete this revision permanently? - Deleting... + Deleting Description Deselect all Destination @@ -772,7 +772,7 @@ File name File name: %1$s File does not exist - File Path + File path File \"%1$s\" is not an executable script Filename cannot be empty Filename cannot contain the following characters: \\ / : * ? " < > | @@ -1353,4 +1353,58 @@ Plugin \"%1$s\" is not authorized in Plugin Center Plugin \"%1$s\" not found in installed apps Unsupported plugin connection + %1$s: + Title + Content + Building APK + Built APK file path + Clean + Sign + Build + Prepare + Preparing workspace + Extracting template APK + Prepare completed + Copying project directory + Copying script file + Source processing completed + Copying directory + Copying file + Encrypting script + Replacing file + Preparing build config + Reading splash resources + Configuring manifest + Configuring package name + Copying assets to + Processing source + Applying binary resources + Copying native libraries + Updating project config + Sign stage completed + Writing project config + Building resources + Build completed + Writing resources.arsc + Writing manifest + Writing app icon + Copying asset + Preparing assets dir + Copying library + Preparing keystore + Unsigned APK created + Creating unsigned APK + Using keystore + Re-signing APK + Writing signed APK + Sign completed + Clean completed + Cleaning workspace + Resolving plugin native libraries + Copying libraries for ABI + Selected plugin + Extracted plugin libraries + Extracted plugin assets + Extracting plugin asset + Extracting plugin so \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e1b76104..53a7e20f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7,11 +7,11 @@ - Construir... - Limpieza... - Empaquetando... + Construir + Limpieza + Empaquetando Versión incompatible de \"apk builder\".\n¿Descargarlo ahora? - Preparando... + Preparando SDK del dispositivo Tamaño de archivo Instalado @@ -534,7 +534,7 @@ Docs AutoJs6 Registrar Registrar - Centro de plugins de AutoJs6 + AutoJs6 Plugins Plugins AutoJs6 Configuración Configuración @@ -672,7 +672,7 @@ Eliminar carpeta Borrar línea ¿Eliminar esta revision de forma permanente? - Eliminando... + Eliminando Descripción Deseleccionar todo Destino @@ -1356,4 +1356,58 @@ El plugin \"%1$s\" no esta autorizado en el Centro de plugins No se encontro el plugin \"%1$s\" en las apps instaladas Conexion de plugin no compatible + %1$s: + Titulo + Contenido + Compilando APK + Ruta del APK generado + Limpiar + Firmar + Compilar + Preparar + Preparando el espacio de trabajo + Extrayendo APK plantilla + Preparacion completada + Copiando directorio del proyecto + Copiando archivo de script + Procesamiento del codigo fuente completado + Copiando directorio + Copiando archivo + Cifrando script + Reemplazando archivo + Preparando configuracion de compilacion + Leyendo recursos de inicio + Configurando manifest + Configurando nombre de paquete + Copiando assets a + Procesando codigo fuente + Aplicando recursos binarios + Copiando bibliotecas nativas + Actualizando configuracion del proyecto + Etapa de firma completada + Escribiendo configuracion del proyecto + Compilando recursos + Compilacion completada + Escribiendo resources.arsc + Escribiendo manifest + Escribiendo icono de la app + Copiando asset + Preparando dir de assets + Copiando biblioteca + Preparando keystore + APK sin firmar creado + Creando APK sin firmar + Usando keystore + Re-firmando APK + Escribiendo APK firmado + Firma completada + Limpieza completada + Limpiando el espacio de trabajo + Resolviendo bibliotecas nativas del plugin + Copiando bibliotecas para ABI + Plugin seleccionado + Bibliotecas del plugin extraidas + Assets del plugin extraidos + Extrayendo asset del plugin + Extrayendo archivo so del plugin \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 54a61ad3..0c83c364 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -7,11 +7,11 @@ - Construire... - Nettoyage... - Emballage... + Construire + Nettoyage + Emballage Version incompatible de \"apk builder\".\nTéléchargez-la maintenant ? - Préparation... + Préparation SDK de l\'appareil Taille du fichier Installé @@ -534,7 +534,7 @@ Docs AutoJs6 Journal Journal - Centre des plugins AutoJs6 + AutoJs6 Plugins Plugins AutoJs6 Réglages Réglages @@ -672,7 +672,7 @@ Supprimer le dossier Supprimer la ligne Supprimer definitivement cette revision? - Suppression... + Suppression Description Tout deselectionner Destination @@ -1356,4 +1356,58 @@ Le plugin \"%1$s\" n\'est pas autorise dans le Centre des plugins Plugin \"%1$s\" introuvable parmi les applis installees Connexion de plugin non prise en charge + %1$s : + Titre + Contenu + Compilation de l\'APK + Chemin de l\'APK genere + Nettoyer + Signer + Compiler + Preparer + Preparation de l\'espace de travail + Extraction de l\'APK modele + Preparation terminee + Copie du repertoire du projet + Copie du fichier de script + Traitement du code source termine + Copie du repertoire + Copie du fichier + Chiffrement du script + Remplacement du fichier + Preparation de la config de build + Lecture des ressources d\'ecran de demarrage + Configuration du manifest + Configuration du nom de package + Copie des assets vers + Traitement du code source + Application des ressources binaires + Copie des bibliotheques natives + Mise a jour de la config du projet + Etape de signature terminee + Ecriture de la config du projet + Compilation des ressources + Build termine + Ecriture de resources.arsc + Ecriture du manifest + Ecriture de l\'icone de l\'appli + Copie d\'asset + Preparation du dir des assets + Copie de bibliotheque + Preparation du keystore + APK non signe cree + Creation de l\'APK non signe + Utilisation du keystore + Re-signature de l\'APK + Ecriture de l\'APK signe + Signature terminee + Nettoyage termine + Nettoyage de l\'espace de travail + Resolution des bibliotheques natives du plugin + Copie des bibliotheques pour ABI + Plugin selectionne + Bibliotheques du plugin extraites + Assets du plugin extraits + Extraction d\'asset du plugin + Extraction du fichier so du plugin \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b1b770af..c55233ee 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -8,11 +8,11 @@ - 建築... - 洗浄... - パッケージング... + 建築 + 洗浄 + パッケージング apk builder の非互換バージョン.\n今すぐダウンロードしますか? - 準備中... + 準備中 デバイス SDK ファイルサイズ インストール済み @@ -535,7 +535,7 @@ 文書 AutoJs6 ログ ログ - AutoJs6 プラグインセンター + AutoJs6 プラグイン プラグイン AutoJs6 設定 設定 @@ -673,7 +673,7 @@ フォルダーを削除 行削除 この履歴を完全に削除しますか? - 削除中... + 削除中 説明 全て解除 宛先 @@ -1357,4 +1357,58 @@ プラグイン \"%1$s\" はプラグインセンターで許可されていません インストール済みアプリにプラグイン \"%1$s\" が見つかりません サポートされていないプラグイン接続方式です + %1$s: + タイトル + 内容 + APK をビルド中 + 生成された APK のパス + クリーン + 署名 + ビルド + 準備 + ワークスペースを準備中 + テンプレート APK を展開中 + 準備完了 + プロジェクトディレクトリをコピー中 + スクリプトファイルをコピー中 + ソース処理完了 + ディレクトリをコピー中 + ファイルをコピー中 + スクリプトを暗号化中 + ファイルを置換中 + ビルド設定を準備中 + スプラッシュ資源を読み込み中 + manifest を設定中 + パッケージ名を設定中 + assets をコピー中: + ソースを処理中 + バイナリ資源を適用中 + ネイティブライブラリをコピー中 + プロジェクト設定を更新中 + 署名ステージ完了 + プロジェクト設定を書き込み中 + 資源をビルド中 + ビルド完了 + resources.arsc を書き込み中 + manifest を書き込み中 + アプリアイコンを書き込み中 + asset をコピー中 + assets ディレクトリを準備中 + ライブラリをコピー中 + keystore を準備中 + 未署名 APK を作成しました + 未署名 APK を作成中 + keystore を使用中 + APK を再署名中 + 署名済み APK を書き込み中 + 署名完了 + クリーン完了 + ワークスペースをクリーン中 + プラグインのネイティブライブラリを解決中 + ABI 用ライブラリをコピー中 + 選択したプラグイン + プラグインのライブラリを抽出しました + プラグインの資源を抽出しました + プラグイン資源を抽出中 + プラグインの so を抽出中 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 40fbb402..4e2eac2f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -9,11 +9,11 @@ - 건물... - 청소... - 포장... + 건물 + 청소 + 포장 \"APK Builder\" 의 호환되지 않는 버전.\n지금 다운로드 하시겠습니까? - 준비... + 준비 디바이스 SDK 파일 크기 설치됨 @@ -536,7 +536,7 @@ 문서 AutoJs6 통나무 통나무 - AutoJs6 플러그인 센터 + AutoJs6 플러그인 플러그인 AutoJs6 설정 설정 @@ -674,7 +674,7 @@ 폴더 삭제 라인 삭제 이 버전을 영구 삭제할까요? - 삭제 중... + 삭제 중 설명 전체 해제 대상 @@ -1086,7 +1086,7 @@ 창을 닫으려면 \"뒤로\" 또는 \"볼륨 감소\" 버튼을 누르세요 시사 프로세스 로그 - 처리 + 처리 중 프로젝트 프로젝트 위치 프로젝트 미디어 액세스 @@ -1358,4 +1358,58 @@ 플러그인 \"%1$s\" 이 (가) 플러그인 센터에서 권한이 부여되지 않았습니다 설치된 앱에서 플러그인 \"%1$s\" 을 (를) 찾을 수 없습니다 지원되지 않는 플러그인 연결 방식입니다 + %1$s: + 제목 + 내용 + APK 빌드 중 + 생성된 APK 경로 + 정리 + 서명 + 빌드 + 준비 + 작업 공간 준비 중 + 템플릿 APK 추출 중 + 준비 완료 + 프로젝트 디렉터리 복사 중 + 스크립트 파일 복사 중 + 소스 처리 완료 + 디렉터리 복사 중 + 파일 복사 중 + 스크립트 암호화 중 + 파일 교체 중 + 빌드 설정 준비 중 + 스플래시 리소스 읽는 중 + manifest 설정 중 + 패키지 이름 설정 중 + assets 복사 중: + 소스 처리 중 + 바이너리 리소스 적용 중 + 네이티브 라이브러리 복사 중 + 프로젝트 설정 업데이트 중 + 서명 단계 완료 + 프로젝트 설정 기록 중 + 리소스 빌드 중 + 빌드 완료 + resources.arsc 기록 중 + manifest 기록 중 + 앱 아이콘 기록 중 + asset 복사 중 + assets 디렉터리 준비 중 + 라이브러리 복사 중 + keystore 준비 중 + 서명되지 않은 APK가 생성됨 + 서명되지 않은 APK 생성 중 + keystore 사용 중 + APK 재서명 중 + 서명된 APK 기록 중 + 서명 완료 + 정리 완료 + 작업 공간 정리 중 + 플러그인 네이티브 라이브러리 확인 중 + ABI용 라이브러리 복사 중 + 선택된 플러그인 + 플러그인 라이브러리를 추출함 + 플러그인 assets를 추출함 + 플러그인 asset 추출 중 + 플러그인 so 파일 추출 중 \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b21073f7..61129d1f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -7,11 +7,11 @@ - Строительство... - Очистка... - Упаковка... + Строительство + Очистка + Упаковка Несовместимая версия \"apk builder\".\nЗагрузить ее сейчас? - Подготовка... + Подготовка SDK устройства Размер файла Установлено @@ -534,7 +534,7 @@ документы AutoJs6 Журнал Журнал - Центр плагинов AutoJs6 + AutoJs6 Плагины Плагины AutoJs6 Настройки Настройки @@ -672,7 +672,7 @@ Удалить папку Удалить строку Удалить эту ревизию навсегда? - Удаление... + Удаление Описание Снять выделение Назначение @@ -1356,4 +1356,58 @@ Плагин \"%1$s\" не авторизован в Центре плагинов Плагин \"%1$s\" не найден среди установленных приложений Неподдерживаемое подключение плагина + %1$s: + Заголовок + Содержимое + Сборка APK + Путь к собранному APK + Очистить + Подписать + Собрать + Подготовить + Подготовка рабочей области + Извлечение шаблонного APK + Подготовка завершена + Копирование каталога проекта + Копирование файла скрипта + Обработка исходников завершена + Копирование каталога + Копирование файла + Шифрование скрипта + Замена файла + Подготовка конфигурации сборки + Чтение ресурсов заставки + Настройка manifest + Настройка имени пакета + Копирование assets в + Обработка исходников + Применение бинарных ресурсов + Копирование нативных библиотек + Обновление конфигурации проекта + Этап подписи завершен + Запись конфигурации проекта + Сборка ресурсов + Сборка завершена + Запись resources.arsc + Запись manifest + Запись значка приложения + Копирование asset + Подготовка каталога assets + Копирование библиотеки + Подготовка keystore + Неподписанный APK создан + Создание неподписанного APK + Использование keystore + Повторная подпись APK + Запись подписанного APK + Подпись завершена + Очистка завершена + Очистка рабочей области + Разбор нативных библиотек плагина + Копирование библиотек для ABI + Выбранный плагин + Библиотеки плагина извлечены + Assets плагина извлечены + Извлечение asset плагина + Извлечение файла so плагина \ No newline at end of file diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index e5b0c0ad..cd35c113 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -4,11 +4,11 @@ - 構建中... - 清理臨時文件... - 打包中... + 構建中 + 清理臨時文件 + 打包中 打包插件需更新\n是否下載 - 準備文件... + 準備文件 設備 SDK 文件大小 已安裝 @@ -530,7 +530,7 @@ 文檔 AutoJs6 日誌 日誌 - AutoJs6 插件中心 + AutoJs6 插件 插件 AutoJs6 設置 設置 @@ -668,7 +668,7 @@ 刪除文件夾 刪除行 是否永久刪除此版本記錄 - 正在刪除... + 正在刪除 描述 取消全選 目標 @@ -1080,7 +1080,7 @@ 如需關閉窗口, 可按 \"返回鍵\" 或 \"音量減鍵\" 預覽 流程日誌 - 處理中 + 正在處理 項目 項目位置 投影媒體權限 @@ -1352,4 +1352,58 @@ 插件 \"%1$s\" 未在插件中心獲得授權 已安裝應用中未找到插件 \"%1$s\" 不支持的插件連接方式 + %1$s: + 標題 + 內容 + 正在打包應用 + 打包應用文件路徑 + 清理 + 簽名 + 構建 + 準備 + 正在準備工作區 + 正在提取模板 APK + 準備完成 + 正在複製項目目錄 + 正在複製腳本文件 + 源代碼處理完成 + 正在複製目錄 + 正在複製文件 + 正在加密腳本 + 正在替換文件 + 正在準備構建配置 + 正在讀取啓動頁資源 + 正在配置清單文件 + 正在配置包名 + 正在複製資源到 + 正在處理源代碼 + 正在應用二進制資源 + 正在複製原生庫 + 正在更新項目配置 + 簽名階段完成 + 正在寫入項目配置 + 正在構建資源 + 構建完成 + 正在寫入 resources.arsc + 正在寫入清單文件 + 正在寫入應用圖標 + 正在複製資源 + 正在準備資源目錄 + 正在複製庫文件 + 正在準備密鑰庫 + 未簽名 APK 已創建 + 正在創建未簽名 APK + 正在使用密鑰庫 + 正在重新簽名 APK + 正在寫入已簽名 APK + 簽名完成 + 清理完成 + 正在清理工作區 + 正在解析插件原生庫 + 正在複製 ABI 庫文件 + 已選擇插件 + 已提取插件庫文件 + 已提取插件資源 + 正在提取插件資源 + 正在提取插件 so 文件 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ca94520f..8a295570 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -4,11 +4,11 @@ - 構建中... - 清理臨時檔案... - 打包中... + 構建中 + 清理臨時檔案 + 打包中 打包外掛需更新\n是否下載 - 準備檔案... + 準備檔案 裝置 SDK 檔案大小 已安裝 @@ -530,7 +530,7 @@ 文件 AutoJs6 日誌 日誌 - AutoJs6 外掛中心 + AutoJs6 外掛 外掛 AutoJs6 設定 設定 @@ -668,7 +668,7 @@ 刪除資料夾 刪除行 是否永久刪除此版本記錄 - 正在刪除... + 正在刪除 描述 取消全選 目標 @@ -1080,7 +1080,7 @@ 如需關閉視窗, 可按 \"返回鍵\" 或 \"音量減鍵\" 預覽 流程日誌 - 處理中 + 正在處理 專案 專案位置 投影媒體許可權 @@ -1352,4 +1352,58 @@ 外掛 \"%1$s\" 未在外掛中心獲得授權 已安裝應用中未找到外掛 \"%1$s\" 不支援的外掛連線方式 + %1$s: + 標題 + 內容 + 正在打包應用 + 打包應用檔案路徑 + 清理 + 簽名 + 構建 + 準備 + 正在準備工作區 + 正在提取模板 APK + 準備完成 + 正在複製專案目錄 + 正在複製指令碼檔案 + 原始碼處理完成 + 正在複製目錄 + 正在複製檔案 + 正在加密指令碼 + 正在替換檔案 + 正在準備構建配置 + 正在讀取啟動頁資源 + 正在配置清單檔案 + 正在配置包名 + 正在複製資源到 + 正在處理原始碼 + 正在應用二進位制資源 + 正在複製原生庫 + 正在更新專案配置 + 簽名階段完成 + 正在寫入專案配置 + 正在構建資源 + 構建完成 + 正在寫入 resources.arsc + 正在寫入清單檔案 + 正在寫入應用圖示 + 正在複製資源 + 正在準備資源目錄 + 正在複製庫檔案 + 正在準備金鑰庫 + 未簽名 APK 已建立 + 正在建立未簽名 APK + 正在使用金鑰庫 + 正在重新簽名 APK + 正在寫入已簽名 APK + 簽名完成 + 清理完成 + 正在清理工作區 + 正在解析外掛原生庫 + 正在複製 ABI 庫檔案 + 已選擇外掛 + 已提取外掛庫檔案 + 已提取外掛資源 + 正在提取外掛資源 + 正在提取外掛 so 檔案 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0f49fa78..552d173e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -4,11 +4,11 @@ - 构建中... - 清理临时文件... - 打包中... + 构建中 + 清理临时文件 + 打包中 打包插件需更新\n是否下载 - 准备文件... + 准备文件 设备 SDK 文件大小 已安装 @@ -531,7 +531,7 @@ 文档 AutoJs6 日志 日志 - AutoJs6 插件中心 + AutoJs6 插件 插件 AutoJs6 设置 设置 @@ -669,7 +669,7 @@ 删除文件夹 删除行 是否永久删除此版本记录 - 正在删除... + 正在删除 描述 取消全选 目标 @@ -1081,7 +1081,7 @@ 如需关闭窗口, 可按 \"返回键\" 或 \"音量减键\" 预览 流程日志 - 处理中 + 正在处理 项目 项目位置 投影媒体权限 @@ -1353,4 +1353,58 @@ 插件 \"%1$s\" 未在插件中心获得授权 已安装应用中未找到插件 \"%1$s\" 不支持的插件连接方式 + %1$s: + 标题 + 内容 + 正在打包应用 + 打包应用文件路径 + 清理 + 签名 + 构建 + 准备 + 正在准备工作区 + 正在提取模板 APK + 准备完成 + 正在复制项目目录 + 正在复制脚本文件 + 源代码处理完成 + 正在复制目录 + 正在复制文件 + 正在加密脚本 + 正在替换文件 + 正在准备构建配置 + 正在读取启动页资源 + 正在配置清单文件 + 正在配置包名 + 正在复制资源到 + 正在处理源代码 + 正在应用二进制资源 + 正在复制原生库 + 正在更新项目配置 + 签名阶段完成 + 正在写入项目配置 + 正在构建资源 + 构建完成 + 正在写入 resources.arsc + 正在写入清单文件 + 正在写入应用图标 + 正在复制资源 + 正在准备资源目录 + 正在复制库文件 + 正在准备密钥库 + 未签名 APK 已创建 + 正在创建未签名 APK + 正在使用密钥库 + 正在重新签名 APK + 正在写入已签名 APK + 签名完成 + 清理完成 + 正在清理工作区 + 正在解析插件原生库 + 正在复制 ABI 库文件 + 已选择插件 + 已提取插件库文件 + 已提取插件资源 + 正在提取插件资源 + 正在提取插件 so 文件 \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index aaa113b6..20b3a763 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -69,5 +69,9 @@ 16sp 48dp 40dp + 24dp + 12dp + 16sp + 12dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1fe848c3..7f949f53 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -277,11 +277,11 @@ - Building... - Cleaning... - Packaging... + Building + Cleaning + Packaging Incompatible version of \"apk builder\".\nDownload it now? - Preparing... + Preparing Device SDK File size Installed @@ -807,7 +807,7 @@ Docs AutoJs6 Log Log - AutoJs6 Plugin Center + AutoJs6 Plugins Plugins AutoJs6 Settings Settings @@ -945,7 +945,7 @@ Delete folder Delete line Delete this revision permanently? - Deleting... + Deleting Description Deselect all Destination @@ -1048,7 +1048,7 @@ File name File name: %1$s File does not exist - File Path + File path File \"%1$s\" is not an executable script Filename cannot be empty Filename cannot contain the following characters: \\ / : * ? " < > | @@ -1629,4 +1629,58 @@ Plugin \"%1$s\" is not authorized in Plugin Center Plugin \"%1$s\" not found in installed apps Unsupported plugin connection + %1$s: + Title + Content + Building APK + Built APK file path + Clean + Sign + Build + Prepare + Preparing workspace + Extracting template APK + Prepare completed + Copying project directory + Copying script file + Source processing completed + Copying directory + Copying file + Encrypting script + Replacing file + Preparing build config + Reading splash resources + Configuring manifest + Configuring package name + Copying assets to + Processing source + Applying binary resources + Copying native libraries + Updating project config + Sign stage completed + Writing project config + Building resources + Build completed + Writing resources.arsc + Writing manifest + Writing app icon + Copying asset + Preparing assets dir + Copying library + Preparing keystore + Unsigned APK created + Creating unsigned APK + Using keystore + Re-signing APK + Writing signed APK + Sign completed + Clean completed + Cleaning workspace + Resolving plugin native libraries + Copying libraries for ABI + Selected plugin + Extracted plugin libraries + Extracted plugin assets + Extracting plugin asset + Extracting plugin so diff --git a/version.properties b/version.properties index b772f28b..35a85917 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -#Sun Mar 01 17:58:13 CST 2026 -BUILD_TIME=1772359093087 +#Sat Mar 07 12:33:49 CST 2026 +BUILD_TIME=1772858029128 COMPILE_SDK_VERSION=36 IMAGE_QUANT_CMAKE_VERSION=3.22.1 IMAGE_QUANT_NDK_VERSION=26.1.10909125 @@ -27,6 +27,6 @@ RAPID_OCR_OPENCV_MOBILE_LABEL_VERSION=13 RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3 TARGET_SDK_VERSION=36 TARGET_SDK_VERSION_INRT=29 -VERSION_BUILD=3761 -VERSION_NAME=6.7.0 Alpha22 +VERSION_BUILD=3778 +VERSION_NAME=6.7.0 Alpha23 VSCODE_EXT_REQUIRED_VERSION=1.0.13