/** * XuqmGroup Update Service — HarmonyOS hvigorw Release Task * * HarmonyOS projects use hvigorw (Huawei's Gradle wrapper). * This script registers a task compatible with both hvigorw and standard Gradle. * * Copy to your HarmonyOS module directory and apply: * apply(from = "xuqm_release.gradle.kts") // in build.gradle.kts * * Run: * ./hvigorw xuqmRelease --mode project * * Config: xuqm.properties in the module or project root * --- * xuqm.serverUrl=https://update.dev.xuqinmin.com * xuqm.appKey=your-app-key * xuqm.apiToken=your-api-token * xuqm.autoPublishAfterReview=false * xuqm.publishImmediately=false * xuqm.scheduledPublishAt= # optional ISO datetime * xuqm.webhookUrl= # optional * xuqm.marketUrl= # Harmony app market URL * --- * * Version is read from AppScope/app.json5 (standard HarmonyOS project layout). */ import org.gradle.api.GradleException 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 import java.util.regex.Pattern // ── Config ──────────────────────────────────────────────────────────────── fun loadXuqmCfg(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") return props } // ── Version from app.json5 ─────────────────────────────────────────────── data class HarmonyVersion(val name: String, val code: Int) fun readHarmonyVersion(projectDir: File): HarmonyVersion { val json5 = listOf( File(projectDir, "AppScope/app.json5"), File(projectDir.parentFile, "AppScope/app.json5"), ).firstOrNull { it.exists() } ?: throw GradleException("AppScope/app.json5 not found") val content = json5.readText() val name = Regex(""""versionName"\s*:\s*"([^"]+)"""").find(content)?.groupValues?.get(1) ?: throw GradleException("versionName not found in app.json5") val code = Regex(""""versionCode"\s*:\s*(\d+)""").find(content)?.groupValues?.get(1)?.toInt() ?: throw GradleException("versionCode not found in app.json5") return HarmonyVersion(name, code) } fun readHarmonyBundleName(projectDir: File): String { val json5 = listOf( File(projectDir, "AppScope/app.json5"), File(projectDir.parentFile, "AppScope/app.json5"), ).firstOrNull { it.exists() } ?: throw GradleException("AppScope/app.json5 not found") val content = json5.readText() return Regex(""""bundleName"\s*:\s*"([^"]+)"""").find(content)?.groupValues?.get(1) ?: throw GradleException("bundleName not found in app.json5") } // ── HTTP helpers ────────────────────────────────────────────────────────── 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() } 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 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(baos.toByteArray())) .build() return client.send(req, HttpResponse.BodyHandlers.ofString()).body() } fun parseJson(json: String, key: String) = Regex(""""$key"\s*:\s*"([^"]+)"""").find(json)?.groupValues?.get(1) fun parseJsonBool(json: String, key: String, default: Boolean = false): Boolean = Regex(""""$key"\s*:\s*(true|false)""").find(json)?.groupValues?.get(1)?.toBooleanStrictOrNull() ?: default fun parseJsonInt(json: String, key: String, default: Int = 0): Int = Regex(""""$key"\s*:\s*(\d+)""").find(json)?.groupValues?.get(1)?.toIntOrNull() ?: 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 answer = promptLine(message + if (default) " [Y/n]: " else " [y/N]: ")?.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 } } 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 body = runCatching { val uri = URI("$tenantUrl/api/sdk/config?appId=$appKey&platform=HARMONY") val req = HttpRequest.newBuilder(uri).GET().build() HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body() }.getOrNull() ?: return null if (!parseJsonBool(body, "updateEnabled", false)) return ReleaseDefaults(enabled = false) return ReleaseDefaults( enabled = true, defaultStoreTargets = parseCsv(parseJson(body, "updateDefaultStoreTargets")), defaultPublishMode = parseJson(body, "updateDefaultPublishMode") ?: "MANUAL", defaultPublishImmediately = parseJsonBool(body, "updateDefaultPublishImmediately"), defaultScheduledPublishAt = parseJson(body, "updateDefaultScheduledPublishAt").orEmpty(), defaultAutoPublishAfterReview = parseJsonBool(body, "updateDefaultAutoPublishAfterReview"), defaultWebhookUrl = parseJson(body, "updateDefaultWebhookUrl").orEmpty(), defaultForceUpdate = parseJsonBool(body, "updateDefaultForceUpdate"), defaultGrayEnabled = parseJsonBool(body, "updateDefaultGrayEnabled"), defaultGrayPercent = parseJsonInt(body, "updateDefaultGrayPercent"), defaultPackageName = parseJson(body, "updateDefaultPackageName").orEmpty(), defaultAppStoreUrl = parseJson(body, "updateDefaultAppStoreUrl").orEmpty(), defaultMarketUrl = parseJson(body, "updateDefaultMarketUrl").orEmpty(), ) } // ── Task ────────────────────────────────────────────────────────────────── tasks.register("xuqmRelease") { group = "xuqm" description = "Build HAP and upload to XuqmGroup Update Service" // hvigorw uses 'assembleApp' by default; adjust if your task is named differently dependsOn(tasks.findByName("assembleApp") ?: tasks.findByName("default") ?: return@register) doLast { val cfg = loadXuqmCfg(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 tenantDefaults = fetchReleaseDefaults(tenantUrl, appKey) val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", tenantDefaults?.defaultAutoPublishAfterReview?.toString() ?: "false").toBoolean() var publishImmediately = cfg.getProperty("xuqm.publishImmediately", tenantDefaults?.defaultPublishImmediately?.toString() ?: "false").toBoolean() var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", tenantDefaults?.defaultScheduledPublishAt ?: "") var webhookUrl = cfg.getProperty("xuqm.webhookUrl", tenantDefaults?.defaultWebhookUrl ?: "") var marketUrl = cfg.getProperty("xuqm.marketUrl", tenantDefaults?.defaultMarketUrl ?: "") var publishMode = cfg.getProperty("xuqm.publishMode", tenantDefaults?.defaultPublishMode ?: "").trim().uppercase() var dryRun = cfg.getProperty("xuqm.dryRun", "false").toBoolean() || publishMode == "DRY_RUN" || tenantDefaults?.enabled == false val allowVersionMismatch = cfg.getProperty("xuqm.allowVersionMismatch", "false").toBoolean() 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.") } if (!scheduledAt.isBlank()) { publishImmediately = false } if (publishMode == "NOW") { publishImmediately = true autoPublish = false } else if (publishMode == "AUTO_REVIEW") { autoPublish = true publishImmediately = false } // ── 1. Read local version ────────────────────────────────────────── var (versionName, versionCode) = readHarmonyVersion(projectDir) val bundleName = readHarmonyBundleName(projectDir) println("[xuqm] Local version: $versionName ($versionCode), appKey: $appKey") // ── 2. Check server ──────────────────────────────────────────────── val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appKey&platform=HARMONY", apiToken) val serverCode = Regex(""""versionCode"\s*:\s*(\d+)""").findAll(listResp) .mapNotNull { it.groupValues[1].toIntOrNull() }.maxOrNull() ?: 0 println("[xuqm] Server latest versionCode: $serverCode") if (versionCode <= serverCode) { if (!allowVersionMismatch && System.console() != null) { 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()) versionName = inputVersionName if (inputVersionCode.isNotBlank()) versionCode = inputVersionCode.toInt() } if (versionCode <= serverCode) { throw GradleException( "[xuqm] Release versionCode ($versionCode) must be greater than server ($serverCode). " + "Bump versionCode in AppScope/app.json5 or pass xuqm.allowVersionMismatch=true if you deliberately override." ) } } // ── 3. Locate HAP ────────────────────────────────────────────────── // ── 4. Upload release metadata to update service ────────────────── if (marketUrl.isBlank()) { throw GradleException("xuqm.marketUrl missing for Harmony release") } if (dryRun) { println("[xuqm] Dry-run summary:") println(" updateEnabled=${tenantDefaults?.enabled ?: true}") println(" releaseVersion=$versionName ($versionCode)") println(" publishMode=${publishMode.ifBlank { "MANUAL" }}") println(" publishImmediately=$publishImmediately") println(" autoPublishAfterReview=$autoPublish") println(" scheduledAt=${scheduledAt.ifBlank { "-" }}") println(" webhookUrl=${webhookUrl.ifBlank { "-" }}") println(" marketUrl=${marketUrl.ifBlank { "-" }}") println("[xuqm] Dry-run completed. No upload performed.") return@doLast } val parts = mutableMapOf( "appId" to appKey, "platform" to "HARMONY", "versionName" to versionName, "versionCode" to versionCode, "forceUpdate" to "false", "autoPublishAfterReview" to autoPublish.toString(), "packageName" to bundleName, "marketUrl" to marketUrl, ) if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl if (publishMode == "NOW") parts["publishImmediately"] = "true" println("[xuqm] Uploading Harmony release metadata...") 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") if (publishMode == "NOW") publishImmediately = true 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 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.") if (!publishImmediately && publishMode != "NOW" && scheduledAt.isBlank() && !autoPublish) { println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish") } } }