/** * XuqmGroup Update Service — Android Gradle Release Task * * Copy this file to your app module directory and apply it: * apply(from = "xuqm_release.gradle.kts") * * Then run: * ./gradlew xuqmRelease * * Config: xuqm.properties in the project root (or module root) * --- * xuqm.serverUrl=https://update.dev.xuqinmin.com * xuqm.appKey=your-app-key * xuqm.apiToken=your-api-token * xuqm.storeTargets=HUAWEI,MI,OPPO # optional * xuqm.autoPublishAfterReview=false * xuqm.publishImmediately=false # optional: publish update-service record immediately * xuqm.scheduledPublishAt= # optional ISO datetime * xuqm.webhookUrl= # optional webhook for review status changes * --- * * The task: * 1. Reads versionName / versionCode from the android extension * 2. Checks the latest version on the update server * 3. Aborts if the local versionCode is not greater than the server's * 4. Assembles the release APK (assembleRelease) * 5. Uploads the APK + metadata to the update service as a DRAFT * 6. Optionally triggers server-side store submission */ import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.tasks.TaskAction import java.io.File import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.util.Properties import java.util.UUID // ── Config loading ──────────────────────────────────────────────────────── fun loadXuqmConfig(projectDir: File): Properties { val props = Properties() listOf( File(projectDir, "xuqm.properties"), File(projectDir.parentFile, "xuqm.properties"), ).firstOrNull { it.exists() }?.inputStream()?.use(props::load) ?: throw GradleException("xuqm.properties not found in module or project root") return props } // ── HTTP helper ─────────────────────────────────────────────────────────── fun httpGet(url: String, token: String): String { val client = HttpClient.newHttpClient() val builder = HttpRequest.newBuilder(URI.create(url)) if (token.isNotBlank()) { builder.header("Authorization", "Bearer $token") } val req = builder.GET().build() return client.send(req, HttpResponse.BodyHandlers.ofString()).body() } /** * Multipart POST — minimal implementation without external dependencies. * Returns response body string. */ fun httpMultipartPost(url: String, token: String, parts: Map): String { val boundary = UUID.randomUUID().toString() val baos = java.io.ByteArrayOutputStream() fun writeln(s: String) = baos.write(("$s\r\n").toByteArray()) for ((name, value) in parts) { writeln("--$boundary") when (value) { is File -> { writeln("Content-Disposition: form-data; name=\"$name\"; filename=\"${value.name}\"") writeln("Content-Type: application/octet-stream") writeln("") baos.write(value.readBytes()) writeln("") } else -> { writeln("Content-Disposition: form-data; name=\"$name\"") writeln("") writeln(value.toString()) } } } writeln("--$boundary--") val body = baos.toByteArray() val client = HttpClient.newHttpClient() val builder = HttpRequest.newBuilder(URI.create(url)) .header("Content-Type", "multipart/form-data; boundary=$boundary") if (token.isNotBlank()) { builder.header("Authorization", "Bearer $token") } val req = builder.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build() return client.send(req, HttpResponse.BodyHandlers.ofString()).body() } fun parseJson(json: String, key: String): String? = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"").find(json)?.groupValues?.get(1) fun parseJsonInt(json: String, key: String): Int? = Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull() fun parseJsonBool(json: String, key: String, default: Boolean = false): Boolean = Regex("\"$key\"\\s*:\\s*(true|false)").find(json)?.groupValues?.get(1)?.toBooleanStrictOrNull() ?: default fun parseCsv(value: String?): List = value?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() fun promptLine(message: String): String? { val console = System.console() ?: return null print(message) return console.readLine() } fun promptYesNo(message: String, default: Boolean = false): Boolean { val suffix = if (default) " [Y/n]: " else " [y/N]: " val answer = promptLine(message + suffix)?.trim().orEmpty() return when { answer.isBlank() -> default answer.equals("y", ignoreCase = true) || answer.equals("yes", ignoreCase = true) -> true answer.equals("n", ignoreCase = true) || answer.equals("no", ignoreCase = true) -> false else -> default } } fun promptReleaseMode(): String { val answer = promptLine( "Publish mode? [1=manual, 2=publish now, 3=scheduled, 4=auto after review, 5=dry-run]: " )?.trim().orEmpty() return when (answer) { "2" -> "NOW" "3" -> "SCHEDULED" "4" -> "AUTO_REVIEW" "5" -> "DRY_RUN" else -> "MANUAL" } } data class ReleaseDefaults( val enabled: Boolean = true, val defaultStoreTargets: List = emptyList(), val defaultPublishMode: String = "MANUAL", val defaultPublishImmediately: Boolean = false, val defaultScheduledPublishAt: String = "", val defaultAutoPublishAfterReview: Boolean = false, val defaultWebhookUrl: String = "", val defaultForceUpdate: Boolean = false, val defaultGrayEnabled: Boolean = false, val defaultGrayPercent: Int = 0, val defaultPackageName: String = "", val defaultAppStoreUrl: String = "", val defaultMarketUrl: String = "", ) fun fetchReleaseDefaults(tenantUrl: String, appKey: String): ReleaseDefaults? { if (tenantUrl.isBlank()) return null val url = "$tenantUrl/api/sdk/config?appId=$appKey&platform=ANDROID" val json = runCatching { httpGet(url, "") }.getOrNull() ?: return null if (!parseJsonBool(json, "updateEnabled", false)) return ReleaseDefaults(enabled = false) return ReleaseDefaults( enabled = true, defaultStoreTargets = parseCsv(parseJson(json, "updateDefaultStoreTargets")), defaultPublishMode = parseJson(json, "updateDefaultPublishMode") ?: "MANUAL", defaultPublishImmediately = parseJsonBool(json, "updateDefaultPublishImmediately"), defaultScheduledPublishAt = parseJson(json, "updateDefaultScheduledPublishAt").orEmpty(), defaultAutoPublishAfterReview = parseJsonBool(json, "updateDefaultAutoPublishAfterReview"), defaultWebhookUrl = parseJson(json, "updateDefaultWebhookUrl").orEmpty(), defaultForceUpdate = parseJsonBool(json, "updateDefaultForceUpdate"), defaultGrayEnabled = parseJsonBool(json, "updateDefaultGrayEnabled"), defaultGrayPercent = parseJsonInt(json, "updateDefaultGrayPercent") ?: 0, defaultPackageName = parseJson(json, "updateDefaultPackageName").orEmpty(), defaultAppStoreUrl = parseJson(json, "updateDefaultAppStoreUrl").orEmpty(), defaultMarketUrl = parseJson(json, "updateDefaultMarketUrl").orEmpty(), ) } // ── Task registration ───────────────────────────────────────────────────── tasks.register("xuqmRelease") { group = "xuqm" description = "Build release APK and upload to XuqmGroup Update Service" // Ensure assembleRelease runs first dependsOn("assembleRelease") doLast { val cfg = loadXuqmConfig(projectDir) val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing") val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing") val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing") val tenantUrl = cfg.getProperty("xuqm.tenantUrl", "").trim() val dryRunMode = cfg.getProperty("xuqm.dryRun", "false").toBoolean() val tenantDefaults = fetchReleaseDefaults(tenantUrl, appKey) val updateEnabled = tenantDefaults?.enabled ?: true val defaultStoreTargets = tenantDefaults?.defaultStoreTargets?.joinToString(",").orEmpty() val defaultPublishMode = tenantDefaults?.defaultPublishMode ?: "MANUAL" val defaultPublishImmediately = tenantDefaults?.defaultPublishImmediately ?: false val defaultScheduledAt = tenantDefaults?.defaultScheduledPublishAt.orEmpty() val defaultAutoPublishAfterReview = tenantDefaults?.defaultAutoPublishAfterReview ?: false val defaultWebhookUrl = tenantDefaults?.defaultWebhookUrl.orEmpty() val defaultForceUpdate = tenantDefaults?.defaultForceUpdate ?: false val defaultGrayEnabled = tenantDefaults?.defaultGrayEnabled ?: false val defaultGrayPercent = tenantDefaults?.defaultGrayPercent ?: 0 var storeTargets = cfg.getProperty("xuqm.storeTargets", defaultStoreTargets) var autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", defaultAutoPublishAfterReview.toString()).toBoolean() var publishImmediately = cfg.getProperty("xuqm.publishImmediately", defaultPublishImmediately.toString()).toBoolean() var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", defaultScheduledAt) var webhookUrl = cfg.getProperty("xuqm.webhookUrl", defaultWebhookUrl) var publishMode = cfg.getProperty("xuqm.publishMode", defaultPublishMode).trim().uppercase() val forceUpdate = cfg.getProperty("xuqm.forceUpdate", defaultForceUpdate.toString()).toBoolean() val grayEnabled = cfg.getProperty("xuqm.grayEnabled", defaultGrayEnabled.toString()).toBoolean() val grayPercent = cfg.getProperty("xuqm.grayPercent", defaultGrayPercent.toString()).toIntOrNull()?.coerceIn(1, 100) ?: defaultGrayPercent val allowVersionMismatch = cfg.getProperty("xuqm.allowVersionMismatch", "false").toBoolean() var dryRun = dryRunMode || publishMode == "DRY_RUN" || !updateEnabled if (publishMode.isBlank() && !publishImmediately && scheduledAt.isBlank() && !autoPublish && System.console() != null) { publishMode = promptReleaseMode() if (publishMode == "SCHEDULED" && scheduledAt.isBlank()) { scheduledAt = promptLine("Scheduled publish time (ISO datetime): ")?.trim().orEmpty() } } if (publishMode == "DRY_RUN") { dryRun = true println("[xuqm] Dry-run mode enabled: release will stop after validation and packaging.") } if (scheduledAt.isNotBlank()) { publishImmediately = false autoPublish = false } if (publishMode == "NOW") { publishImmediately = true autoPublish = false } else if (publishMode == "AUTO_REVIEW") { autoPublish = true publishImmediately = false } // ── 1. Read local version ────────────────────────────────────────── val android = project.extensions.findByName("android") ?: throw GradleException("This task must run in an Android module") val defaultConfig = android::class.java.getMethod("getDefaultConfig").invoke(android) val versionName = defaultConfig::class.java.getMethod("getVersionName").invoke(defaultConfig) as? String ?: throw GradleException("versionName not set") val versionCode = (defaultConfig::class.java.getMethod("getVersionCode").invoke(defaultConfig) as? Int) ?: throw GradleException("versionCode not set") val applicationId = defaultConfig::class.java.getMethod("getApplicationId").invoke(defaultConfig) as? String ?: "" println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId, appKey: $appKey") // ── 2. Check server latest ───────────────────────────────────────── val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appKey&platform=ANDROID", apiToken) // Find highest published versionCode val serverVersionCode = Regex("\"versionCode\"\\s*:\\s*(\\d+)").findAll(listResp) .mapNotNull { it.groupValues[1].toIntOrNull() } .maxOrNull() ?: 0 println("[xuqm] Server latest versionCode: $serverVersionCode") var releaseVersionName = versionName var releaseVersionCode = versionCode if (releaseVersionCode <= serverVersionCode) { if (System.console() != null && !allowVersionMismatch) { println("[xuqm] Local version is not greater than server latest. Please enter corrected release version info.") val inputVersionName = promptLine("Release versionName [$versionName]: ")?.trim().orEmpty() val inputVersionCode = promptLine("Release versionCode [$versionCode]: ")?.trim().orEmpty() if (inputVersionName.isNotBlank()) releaseVersionName = inputVersionName if (inputVersionCode.isNotBlank()) releaseVersionCode = inputVersionCode.toInt() } if (releaseVersionCode <= serverVersionCode) { throw GradleException( "[xuqm] Release versionCode ($releaseVersionCode) must be greater than server ($serverVersionCode). " + "Bump versionCode in build.gradle.kts or pass xuqm.allowVersionMismatch=true if you deliberately override." ) } } if (releaseVersionName != versionName || releaseVersionCode != versionCode) { println("[xuqm] Using release override: $releaseVersionName ($releaseVersionCode)") } if (storeTargets.isBlank() && System.console() != null && !dryRun) { val inputTargets = promptLine("Store targets (comma separated, blank for none): ")?.trim().orEmpty() if (inputTargets.isNotBlank()) { storeTargets = inputTargets } } val storeTargetList = parseCsv(storeTargets) // ── 3. Locate APK ───────────────────────────────────────────────── val apkDir = File(buildDir, "outputs/apk/release") val apkFile = apkDir.listFiles { f -> f.extension == "apk" }?.firstOrNull() ?: throw GradleException("No APK found in ${apkDir.absolutePath}. Did assembleRelease succeed?") println("[xuqm] APK: ${apkFile.absolutePath}") if (dryRun) { println("[xuqm] Dry-run summary:") println(" updateEnabled=$updateEnabled") println(" releaseVersion=$releaseVersionName ($releaseVersionCode)") println(" publishMode=$publishMode") println(" publishImmediately=$publishImmediately") println(" autoPublishAfterReview=$autoPublish") println(" scheduledPublishAt=${scheduledAt.ifBlank { "-" }}") println(" webhookUrl=${webhookUrl.ifBlank { "-" }}") println(" forceUpdate=$forceUpdate") println(" grayEnabled=$grayEnabled, grayPercent=$grayPercent") println(" storeTargets=${storeTargetList.joinToString(",").ifBlank { "-" }}") println(" packageName=${applicationId.ifBlank { "-" }}") println("[xuqm] Dry-run completed. No upload performed.") return@doLast } // ── 4. Upload to update service ─────────────────────────────────── val parts = mutableMapOf( "appId" to appKey, "platform" to "ANDROID", "versionName" to releaseVersionName, "versionCode" to releaseVersionCode, "forceUpdate" to forceUpdate.toString(), "autoPublishAfterReview" to autoPublish.toString(), "apkFile" to apkFile, ) if (applicationId.isNotBlank()) parts["packageName"] = applicationId if (storeTargetList.isNotEmpty()) parts["storeSubmitTargets"] = "[\"${storeTargetList.joinToString("\",\"")}\"]" if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl if (publishMode == "NOW") parts["publishImmediately"] = "true" if (publishMode == "AUTO_REVIEW") parts["autoPublishAfterReview"] = "true" println("[xuqm] Uploading APK...") val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts) val versionId = parseJson(uploadResp, "id") ?: throw GradleException("[xuqm] Upload failed:\n$uploadResp") println("[xuqm] Uploaded, version ID: $versionId") // ── 5. Trigger server-side store submission ──────────────────────── if (storeTargetList.isNotEmpty()) { println("[xuqm] Triggering store submission: ${storeTargetList.joinToString(",")} ...") val storeBody = """{"storeTypes":[${storeTargetList.joinToString(",") { "\"$it\"" }}]}""" val client = HttpClient.newHttpClient() val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/store/app/$versionId/execute-submit")) .header("Authorization", "Bearer $apiToken") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(storeBody)) .build() val storeResp = client.send(req, HttpResponse.BodyHandlers.ofString()) println("[xuqm] Store submission triggered (HTTP ${storeResp.statusCode()})") } if (grayEnabled && (publishImmediately || publishMode == "NOW")) { val client = HttpClient.newHttpClient() val grayBody = """{"enabled":true,"percent":$grayPercent}""" val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/gray")) .header("Authorization", "Bearer $apiToken") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(grayBody)) .build() val grayResp = client.send(req, HttpResponse.BodyHandlers.ofString()) println("[xuqm] Gray release configured (HTTP ${grayResp.statusCode()})") } if (publishImmediately || publishMode == "NOW") { println("[xuqm] Published immediately in update service.") } else if (publishMode == "SCHEDULED" || scheduledAt.isNotBlank()) { println("[xuqm] Will auto-publish at: ${scheduledAt.ifBlank { "(use update service scheduled publish config)" }}") } else if (autoPublish) { println("[xuqm] Will auto-publish after all store reviews pass.") } else if (System.console() != null && promptYesNo("[xuqm] Publish now?", false)) { val client = HttpClient.newHttpClient() val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/publish")) .header("Authorization", "Bearer $apiToken") .POST(HttpRequest.BodyPublishers.noBody()) .build() val publishResp = client.send(req, HttpResponse.BodyHandlers.ofString()) println("[xuqm] Publish HTTP ${publishResp.statusCode()}") } println("[xuqm] Done. Version is in DRAFT state.") if (!publishImmediately && publishMode != "NOW" && scheduledAt.isBlank() && !autoPublish) { println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish") } } }