2026-04-29 00:36:51 +08:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-04-29 15:46:39 +08:00
|
|
|
* xuqm.appKey=your-app-key
|
2026-04-29 00:36:51 +08:00
|
|
|
* xuqm.apiToken=your-api-token
|
2026-04-29 15:46:39 +08:00
|
|
|
* 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
|
2026-04-29 00:36:51 +08:00
|
|
|
* ---
|
|
|
|
|
*
|
|
|
|
|
* 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 req = HttpRequest.newBuilder(URI.create(url))
|
|
|
|
|
.header("Authorization", "Bearer $token")
|
|
|
|
|
.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, Any>): 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 req = HttpRequest.newBuilder(URI.create(url))
|
|
|
|
|
.header("Authorization", "Bearer $token")
|
|
|
|
|
.header("Content-Type", "multipart/form-data; boundary=$boundary")
|
|
|
|
|
.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()
|
|
|
|
|
|
2026-04-29 15:46:39 +08:00
|
|
|
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]: "
|
|
|
|
|
)?.trim().orEmpty()
|
|
|
|
|
return when (answer) {
|
|
|
|
|
"2" -> "NOW"
|
|
|
|
|
"3" -> "SCHEDULED"
|
|
|
|
|
"4" -> "AUTO_REVIEW"
|
|
|
|
|
else -> "MANUAL"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 00:36:51 +08:00
|
|
|
// ── 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")
|
2026-04-29 15:46:39 +08:00
|
|
|
val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing")
|
2026-04-29 00:36:51 +08:00
|
|
|
val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
|
|
|
|
|
val storeTargets = cfg.getProperty("xuqm.storeTargets", "") // e.g. "HUAWEI,MI,OPPO"
|
|
|
|
|
val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", "false").toBoolean()
|
2026-04-29 15:46:39 +08:00
|
|
|
val publishImmediately = cfg.getProperty("xuqm.publishImmediately", "false").toBoolean()
|
|
|
|
|
var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "")
|
2026-04-29 00:36:51 +08:00
|
|
|
val webhookUrl = cfg.getProperty("xuqm.webhookUrl", "")
|
2026-04-29 15:46:39 +08:00
|
|
|
var publishMode = cfg.getProperty("xuqm.publishMode", "").trim().uppercase()
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-29 00:36:51 +08:00
|
|
|
|
|
|
|
|
// ── 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 ?: ""
|
2026-04-29 15:46:39 +08:00
|
|
|
println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId, appKey: $appKey")
|
2026-04-29 00:36:51 +08:00
|
|
|
|
|
|
|
|
// ── 2. Check server latest ─────────────────────────────────────────
|
2026-04-29 15:46:39 +08:00
|
|
|
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appKey&platform=ANDROID", apiToken)
|
2026-04-29 00:36:51 +08:00
|
|
|
// 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")
|
|
|
|
|
|
|
|
|
|
if (versionCode <= serverVersionCode) {
|
|
|
|
|
throw GradleException(
|
|
|
|
|
"[xuqm] Local versionCode ($versionCode) must be greater than server ($serverVersionCode). " +
|
|
|
|
|
"Please bump versionCode in build.gradle.kts before releasing."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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}")
|
|
|
|
|
|
|
|
|
|
// ── 4. Upload to update service ───────────────────────────────────
|
|
|
|
|
val parts = mutableMapOf<String, Any>(
|
2026-04-29 15:46:39 +08:00
|
|
|
"appId" to appKey,
|
2026-04-29 00:36:51 +08:00
|
|
|
"platform" to "ANDROID",
|
|
|
|
|
"versionName" to versionName,
|
|
|
|
|
"versionCode" to versionCode,
|
|
|
|
|
"forceUpdate" to "false",
|
|
|
|
|
"autoPublishAfterReview" to autoPublish.toString(),
|
|
|
|
|
"apkFile" to apkFile,
|
|
|
|
|
)
|
|
|
|
|
if (applicationId.isNotBlank()) parts["packageName"] = applicationId
|
|
|
|
|
if (storeTargets.isNotBlank()) parts["storeSubmitTargets"] = "[\"${storeTargets.split(",").joinToString("\",\"")}\"]"
|
|
|
|
|
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
|
|
|
|
|
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
|
2026-04-29 15:46:39 +08:00
|
|
|
if (publishMode == "NOW") parts["publishImmediately"] = "true"
|
2026-04-29 00:36:51 +08:00
|
|
|
|
|
|
|
|
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 (storeTargets.isNotBlank()) {
|
|
|
|
|
println("[xuqm] Triggering store submission: $storeTargets ...")
|
|
|
|
|
val storeBody = """{"storeTypes":[${storeTargets.split(",").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()})")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:46:39 +08:00
|
|
|
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)" }}")
|
2026-04-29 00:36:51 +08:00
|
|
|
} else if (autoPublish) {
|
|
|
|
|
println("[xuqm] Will auto-publish after all store reviews pass.")
|
2026-04-29 15:46:39 +08:00
|
|
|
} 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) {
|
2026-04-29 00:36:51 +08:00
|
|
|
println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|