import groovy.json.JsonSlurper import java.io.File import java.net.HttpURLConnection import java.net.URI import java.util.Properties import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.GradleException import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project private data class UploadFlavor( val name: String, val displayName: String, val applicationId: String, val buglyAppId: String, val buglyAppKey: String, ) extra["executeCommand"] = { it: Array -> runCommand(*it) } private val uploadFlavors = listOf( UploadFlavor( name = "dev", displayName = "Dev", applicationId = "cn.seazonmotor.carcontroller.dev", buglyAppId = "da9c962a26", buglyAppKey = "114678da-f681-4367-8b47-543eabfb81d9", ), UploadFlavor( name = "prod", displayName = "Prod", applicationId = "cn.seazonmotor.carcontroller", buglyAppId = "fb20a85073", buglyAppKey = "ef3964db-a750-40f2-aa4f-abe009b62062", ), UploadFlavor( name = "rel", displayName = "Rel", applicationId = "cn.seazonmotor.carcontroller", buglyAppId = "310a1566ba", buglyAppKey = "e9cbd9ef-0f82-4ee1-a3f3-ffbfec22dbe2", ), ) private fun Project.runCommand(vararg command: String): String { println("cmd:" + command.joinToString(" ")) val process = ProcessBuilder(*command) .directory(rootDir) .redirectErrorStream(false) .start() val output = process.inputStream.bufferedReader().use { it.readText() } val error = process.errorStream.bufferedReader().use { it.readText() } val exitCode = process.waitFor() if (exitCode != 0) { throw GradleException("Command failed (${command.joinToString(" ")}): ${error.trim()}") } return output.trim() } private fun Project.optionalCommand(vararg command: String): String = runCatching { runCommand(*command) }.getOrDefault("") private fun String.uppercaseFirstChar(): String = replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } private fun Project.gitCommitCount(): String = runCommand("git", "rev-list", "--count", "HEAD") private fun Project.gitShortHash(): String = runCommand("git", "rev-parse", "--short", "HEAD") private fun Project.gitBranchName(): String = optionalCommand("git", "branch", "--show-current") .ifBlank { "local" } .uppercaseFirstChar() private fun Project.releaseMessage(): String { val versionFile = rootProject.file(".version") return if (versionFile.exists()) { versionFile.readText().trim() } else { optionalCommand("git", "log", "-1", "--pretty=%s") } } private fun Project.prop(name: String): String? = providers.gradleProperty(name) .orElse(providers.environmentVariable(name.uppercase())) .orNull ?.takeIf { it.isNotBlank() } private fun Project.uploadVersionName(): String = extensions .getByType(VersionCatalogsExtension::class.java) .named("libs") .findVersion("versionName") .get() .requiredVersion private fun Project.uploadVersion(): String = "${uploadVersionName()}.${gitShortHash()}" private fun Project.outputDirName(): String = "${uploadVersionName()}.${gitCommitCount()}" private fun Project.apkFileName(commitCount: String, version: String, suffix: String = ""): String = "XeaZonApp_release_C${commitCount}_V${version}${suffix}.apk" private fun File.appendNameSuffix(suffix: String): File { val appendedName = if (extension.isBlank()) { "$name$suffix" } else { "${nameWithoutExtension}$suffix.$extension" } return resolveSibling(appendedName) } private fun Project.sdkDir(): File { val localProperties = Properties() val localPropertiesFile = rootProject.file("local.properties") if (localPropertiesFile.exists()) { localPropertiesFile.inputStream().use(localProperties::load) } val sdkDir = prop("androidSdkDir") ?: localProperties.getProperty("sdk.dir") ?: System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") ?: throw GradleException("Android SDK not found. Set sdk.dir in local.properties or ANDROID_HOME.") return file(sdkDir) } private fun Project.latestBuildTool(toolName: String): File { val buildTools = sdkDir().resolve("build-tools") val fileNames = listOf(toolName, "$toolName.bat", "$toolName.exe") return buildTools .listFiles() .orEmpty() .filter { it.isDirectory } .sortedBy { it.name } .asReversed() .flatMap { buildToolDir -> fileNames.map { buildToolDir.resolve(it) } } .firstOrNull { it.isFile } ?: throw GradleException("Cannot find $toolName in $buildTools.") } private fun Project.buglyJar(): File = file( prop("buglyUploadJar") ?: System.getenv("BUGLY_UPLOAD_JAR") ?: "C:/Users/lenovo/buglyqq-upload-symbol-v3.3.5/buglyqq-upload-symbol.jar" ) private fun Project.protectCommand(): String = prop("apkProtectCommand") ?: System.getenv("APK_PROTECT_COMMAND") ?: "apkprotect" private fun Project.pgyerApiKey(): String = prop("pgyerApiKey") ?: System.getenv("PGYER_API_KEY") ?: "51311612d5d047d1466d9d450914cdba" private data class ReleaseSigningConfig( val storeFile: File, val storePassword: String, val keyAlias: String, val keyPassword: String, ) private fun Any.callGetter(name: String): Any? = javaClass.methods.firstOrNull { it.name == name && it.parameterCount == 0 }?.invoke(this) private fun Project.releaseSigningConfig(): ReleaseSigningConfig { val android = extensions.getByName("android") val signingConfigs = android.callGetter("getSigningConfigs") as? NamedDomainObjectContainer<*> ?: throw GradleException("Cannot read android.signingConfigs.") val release = signingConfigs.findByName("release") ?: throw GradleException("Missing android.signingConfigs.release.") val storeFile = release.callGetter("getStoreFile") as? File ?: throw GradleException("Missing android.signingConfigs.release.storeFile.") val storePassword = release.callGetter("getStorePassword")?.toString() ?: throw GradleException("Missing android.signingConfigs.release.storePassword.") val keyAlias = release.callGetter("getKeyAlias")?.toString() ?: throw GradleException("Missing android.signingConfigs.release.keyAlias.") val keyPassword = release.callGetter("getKeyPassword")?.toString() ?: throw GradleException("Missing android.signingConfigs.release.keyPassword.") return ReleaseSigningConfig(storeFile, storePassword, keyAlias, keyPassword) } private fun String.toJsonMap(): Map<*, *> = JsonSlurper().parseText(this) as? Map<*, *> ?: throw GradleException("Unexpected JSON response: $this") private fun Map<*, *>.mapValue(key: String): Map<*, *> = this[key] as? Map<*, *> ?: throw GradleException("Missing JSON object: $key") private fun Map<*, *>.stringValue(key: String): String = this[key]?.toString() ?: throw GradleException("Missing JSON value: $key") private fun httpConnection(url: String): HttpURLConnection = URI(url).toURL().openConnection() as HttpURLConnection private fun HttpURLConnection.readResponse(): String { val stream = if (responseCode in 200..299) { inputStream } else { errorStream ?: inputStream } val response = stream.bufferedReader().use { it.readText() } if (responseCode !in 200..299) { throw GradleException("HTTP $responseCode: $response") } return response } private fun postForm(url: String, params: Map): String { val boundary = "----GradleBoundary${System.currentTimeMillis()}" val connection = httpConnection(url) connection.requestMethod = "POST" connection.doOutput = true connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") connection.outputStream.bufferedWriter().use { writer -> params.forEach { (name, value) -> writer.append("--$boundary\r\n") writer.append("Content-Disposition: form-data; name=\"$name\"\r\n\r\n") writer.append(value) writer.append("\r\n") } writer.append("--$boundary--\r\n") } return connection.readResponse() } private fun uploadFile(url: String, fields: Map, file: File): String { val boundary = "----GradleBoundary${System.currentTimeMillis()}" val connection = httpConnection(url) connection.requestMethod = "POST" connection.doOutput = true connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") connection.outputStream.use { output -> fun write(value: String) = output.write(value.toByteArray()) fields.forEach { (name, value) -> write("--$boundary\r\n") write("Content-Disposition: form-data; name=\"$name\"\r\n\r\n") write(value) write("\r\n") } write("--$boundary\r\n") write("Content-Disposition: form-data; name=\"file\"; filename=\"${file.name}\"\r\n") write("Content-Type: application/vnd.android.package-archive\r\n\r\n") file.inputStream().use { it.copyTo(output) } write("\r\n--$boundary--\r\n") } return connection.readResponse() } private fun Project.copyReleaseArtifacts(flavor: UploadFlavor, outputDirName: String, commitCount: String, version: String) { val apkSourceDir = layout.buildDirectory.dir("outputs/apk/${flavor.name}/release").get().asFile val sourceApk = apkSourceDir .listFiles { file -> file.isFile && file.extension == "apk" } .orEmpty() .maxByOrNull { it.lastModified() } ?: throw GradleException("No release APK found in ${apkSourceDir.absolutePath}") val apkDestinationDir = layout.projectDirectory .dir("../outputs/$outputDirName/${flavor.name}/apk") .asFile apkDestinationDir.mkdirs() sourceApk.copyTo(apkDestinationDir.resolve(apkFileName(commitCount, version)), overwrite = true) val mappingSourceDir = layout.buildDirectory.dir("outputs/mapping/${flavor.name}Release").get().asFile if (mappingSourceDir.exists()) { val mappingDestinationDir = layout.projectDirectory .dir("../outputs/$outputDirName/${flavor.name}/mapping") .asFile mappingDestinationDir.deleteRecursively() mappingSourceDir.copyRecursively(mappingDestinationDir, overwrite = true) } } private fun Project.copyRoomSchemas(outputDirName: String) { val roomSchemas = layout.buildDirectory.dir("room/schemas").get().asFile if (roomSchemas.exists()) { val destination = layout.projectDirectory.dir("../outputs/$outputDirName/room").asFile destination.deleteRecursively() roomSchemas.copyRecursively(destination, overwrite = true) } } private fun Project.uploadMapping(flavor: UploadFlavor, version: String, outputDirName: String) { val jar = buglyJar() if (!jar.exists()) { throw GradleException("Bugly upload jar not found: ${jar.absolutePath}") } val mapping = layout.projectDirectory .dir("../outputs/$outputDirName/${flavor.name}/mapping") .file("mapping.txt") .asFile if (!mapping.exists()) { throw GradleException("Mapping file not found: ${mapping.absolutePath}") } runCommand( "java", "-jar", jar.absolutePath, "-appid", flavor.buglyAppId, "-appkey", flavor.buglyAppKey, "-bundleid", flavor.applicationId, "-version", version, "-platform", "Android", "-inputMapping", mapping.absolutePath ).also { println(it) } } private fun Project.confuseApk(apk: File, output: File) { runCommand( protectCommand(), "-akey", "p-kfE2ILvswYeZOI", "-skey", "9ns0LqiDF8waaQpwbi0xuYHp-UH8M-Sz", "-i", apk.absolutePath, "-o", output.absolutePath, "-type", "free" ).also { println(it) } if (!output.exists()) { throw GradleException("Protect apk failed: ${output.absolutePath}") } } private fun Project.zipalignApk(input: File, output: File) { output.parentFile.mkdirs() runCommand(latestBuildTool("zipalign").absolutePath, "-f", "-p", "4", input.absolutePath, output.absolutePath) } private fun Project.signApk(input: File, output: File) { val signingConfig = releaseSigningConfig() val keystore = signingConfig.storeFile if (!keystore.isFile) { throw GradleException("Keystore not found: ${keystore.absolutePath}") } output.parentFile.mkdirs() runCommand( latestBuildTool("apksigner").absolutePath, "sign", "--ks", keystore.absolutePath, "--ks-pass", "pass:${signingConfig.storePassword}", "--ks-key-alias", signingConfig.keyAlias, "--key-pass", "pass:${signingConfig.keyPassword}", "--out", output.absolutePath, input.absolutePath ) } private fun Project.uploadApk(apk: File, shortcut: String, message: String, commitCount: String) { if (!apk.exists()) { throw GradleException("APK not found: ${apk.absolutePath}") } val apiKey = pgyerApiKey() val token = postForm( "https://api.pgyer.com/apiv2/app/getCOSToken", mapOf( "_api_key" to apiKey, "buildType" to "android", "buildInstallType" to "2", "buildPassword" to "seazon", "buildUpdateDescription" to message, "buildInstallDate" to "2", "buildChannelShortcut" to shortcut, ) ).toJsonMap() val data = token.mapValue("data") val params = data.mapValue("params") uploadFile( data.stringValue("endpoint"), params.keys.associate { it.toString() to params.stringValue(it.toString()) }, apk ) while (true) { val info = URI( "https://api.pgyer.com/apiv2/app/buildInfo?&_api_key=$apiKey&buildKey=${params.stringValue("key")}" ).toURL().readText().toJsonMap() if (info["code"].toString() == "0" || info["code"].toString() == "0.0") { println("已修复,请在以下版本中验证") println("下载地址:https://www.pgyer.com/$shortcut") println("验证版本: ${uploadVersionName()}($commitCount)") println("密码: seazon") break } println(info) Thread.sleep(3000) } } private fun Project.uploadTaskName(flavor: UploadFlavor): String = "upload${flavor.name.uppercaseFirstChar()}" private fun Project.assembleReleaseTaskName(flavor: UploadFlavor): String = "assemble${flavor.name.uppercaseFirstChar()}Release" private fun Project.uploadShortcut(flavor: UploadFlavor, branch: String): String = "XeazonApp${flavor.displayName}$branch" private fun Project.uploadFlavor( flavor: UploadFlavor, commitCount: String, version: String, outputDirName: String, message: String, branch: String, ) { copyReleaseArtifacts(flavor, outputDirName, commitCount, version) val apkDir = layout.projectDirectory .dir("../outputs/$outputDirName/${flavor.name}/apk") .asFile val sourceApk = apkDir.resolve(apkFileName(commitCount, version)) val protectedApk = sourceApk.appendNameSuffix("_protect") val alignedApk = protectedApk.appendNameSuffix("_aligned") val signedApk = alignedApk.appendNameSuffix("_signed") uploadMapping(flavor, version, outputDirName) confuseApk(sourceApk, protectedApk) zipalignApk(protectedApk, alignedApk) signApk(alignedApk, signedApk) uploadApk(signedApk, uploadShortcut(flavor, branch), message, commitCount) } tasks.matching { it.name.startsWith("lintVital") }.configureEach { onlyIf { val uploadTaskNames = uploadFlavors.map { uploadTaskName(it) }.toSet() + "apkAssemble" gradle.taskGraph.allTasks.none { it.project == project && it.name in uploadTaskNames } } } val uploadFlavorTasks = uploadFlavors.map { flavor -> tasks.register(uploadTaskName(flavor)) { group = "upload" description = "Copy ${flavor.name} APK/mapping, upload mapping, protect/align/sign APK, and upload APK." dependsOn(assembleReleaseTaskName(flavor)) doLast { val commitCount = gitCommitCount() val version = uploadVersion() val outputDirName = outputDirName() val message = releaseMessage() val branch = gitBranchName() uploadFlavor(flavor, commitCount, version, outputDirName, message, branch) } } } tasks.register("uploadAll") { group = "upload" description = "Upload all configured flavors." dependsOn(uploadFlavorTasks) doLast { val commitCount = gitCommitCount() val outputDirName = outputDirName() val branch = gitBranchName() copyRoomSchemas(outputDirName) println("已修复,请在以下版本中验证") uploadFlavors.forEach { flavor -> println("${flavor.displayName}:https://www.pgyer.com/${uploadShortcut(flavor, branch)}") } println("验证版本: ${uploadVersionName()}($commitCount)") println("密码: seazon") } }