diff --git a/.gitignore b/.gitignore index 10dfc90..a811e79 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ *.iml .idea/ *.log +.hvigor/ diff --git a/build-profile.json5 b/build-profile.json5 index 607cdd4..dcae8b8 100644 --- a/build-profile.json5 +++ b/build-profile.json5 @@ -1,6 +1,20 @@ { "app": { - "signingConfigs": [], + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "/Users/xuqinmin/.ohos/config/default_XuqmGroup-HarmonySDK_xvtPiZRWbWuJ1guqtszanEOmoD8f2kda5ume6x7cDEg=.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001BAEFFFD913AED357759CBA7F92502E226B07F444BBB101F06934FC8D4A84C1B5561BC0253532FCC", + "profile": "/Users/xuqinmin/.ohos/config/default_XuqmGroup-HarmonySDK_xvtPiZRWbWuJ1guqtszanEOmoD8f2kda5ume6x7cDEg=.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "/Users/xuqinmin/.ohos/config/default_XuqmGroup-HarmonySDK_xvtPiZRWbWuJ1guqtszanEOmoD8f2kda5ume6x7cDEg=.p12", + "storePassword": "0000001B822DC06852C049293C0D7A3A5ED4C040F995F0DE8E832EA7C75D5C0B9A063C501AE3B14CE6B900" + } + } + ], "products": [ { "name": "default", @@ -12,12 +26,17 @@ "caseSensitiveCheck": true, "useNormalizedOHMUrl": true } - } + }, + "targetSdkVersion": "6.0.2(22)" } ], "buildModeSet": [ - { "name": "debug" }, - { "name": "release" } + { + "name": "debug" + }, + { + "name": "release" + } ] }, "modules": [ @@ -25,14 +44,24 @@ "name": "xuqmSdk", "srcPath": "./xuqm-sdk", "targets": [ - { "name": "default", "applyToProducts": ["default"] } + { + "name": "default", + "applyToProducts": [ + "default" + ] + } ] }, { "name": "entry", "srcPath": "./entry", "targets": [ - { "name": "default", "applyToProducts": ["default"] } + { + "name": "default", + "applyToProducts": [ + "default" + ] + } ] } ] diff --git a/oh_modules/.ohpm/lock.json5 b/oh_modules/.ohpm/lock.json5 index 30de325..5f7c78b 100644 --- a/oh_modules/.ohpm/lock.json5 +++ b/oh_modules/.ohpm/lock.json5 @@ -28,7 +28,7 @@ "maskedByOverrideDependencyMap": false }, "xuqm-sdk": { - "name": "xuqm-sdk", + "name": "xuqmSdk", "dependencies": {}, "devDependencies": {}, "dynamicDependencies": {}, diff --git a/xuqm-sdk/scripts/xuqm_release.gradle.kts b/xuqm-sdk/scripts/xuqm_release.gradle.kts new file mode 100644 index 0000000..3ea6c87 --- /dev/null +++ b/xuqm-sdk/scripts/xuqm_release.gradle.kts @@ -0,0 +1,182 @@ +/** + * 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.appId=your-app-id + * xuqm.apiToken=your-api-token + * xuqm.storeTargets=HUAWEI # optional: HUAWEI,HONOR,... + * xuqm.autoPublishAfterReview=false + * xuqm.scheduledPublishAt= # optional ISO datetime + * xuqm.webhookUrl= # optional + * --- + * + * 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) +} + +// ── 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) + +// ── 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 appId = cfg.getProperty("xuqm.appId") ?: throw GradleException("xuqm.appId missing") + val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing") + val storeTargets = cfg.getProperty("xuqm.storeTargets", "") + val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", "false").toBoolean() + val scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "") + val webhookUrl = cfg.getProperty("xuqm.webhookUrl", "") + + // ── 1. Read local version ────────────────────────────────────────── + val (versionName, versionCode) = readHarmonyVersion(projectDir) + println("[xuqm] Local version: $versionName ($versionCode)") + + // ── 2. Check server ──────────────────────────────────────────────── + val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appId&platform=ANDROID", 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) { + throw GradleException( + "[xuqm] Local versionCode ($versionCode) ≤ server ($serverCode). " + + "Bump versionCode in AppScope/app.json5 first." + ) + } + + // ── 3. Locate HAP ────────────────────────────────────────────────── + val hapDir = File(projectDir, "entry/build/default/outputs/default") + val hapFile = hapDir.listFiles { f -> f.extension == "hap" }?.firstOrNull() + ?: throw GradleException("HAP not found in ${hapDir.absolutePath}") + println("[xuqm] HAP: ${hapFile.absolutePath}") + + // ── 4. Upload ────────────────────────────────────────────────────── + val parts = mutableMapOf( + "appId" to appId, "platform" to "ANDROID", + "versionName" to versionName, "versionCode" to versionCode, + "forceUpdate" to "false", "autoPublishAfterReview" to autoPublish.toString(), + "apkFile" to hapFile, + ) + if (storeTargets.isNotBlank()) parts["storeSubmitTargets"] = "[\"${storeTargets.split(",").joinToString("\",\"")}\"]" + if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt + if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl + + println("[xuqm] Uploading HAP...") + 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 store submission ──────────────────────────────────── + if (storeTargets.isNotBlank()) { + println("[xuqm] Triggering store submission: $storeTargets") + val body = """{"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(body)).build() + val storeResp = client.send(req, HttpResponse.BodyHandlers.ofString()) + println("[xuqm] Store submission HTTP ${storeResp.statusCode()}") + } + + println("[xuqm] Done.") + if (scheduledAt.isNotBlank()) { + println("[xuqm] Will publish at: $scheduledAt") + } else if (autoPublish) { + println("[xuqm] Will auto-publish after store reviews pass.") + } else { + println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish") + } + } +} diff --git a/xuqm-sdk/src/main/ets/im/ImClient.ets b/xuqm-sdk/src/main/ets/im/ImClient.ets index 9a751e4..e1c98f8 100644 --- a/xuqm-sdk/src/main/ets/im/ImClient.ets +++ b/xuqm-sdk/src/main/ets/im/ImClient.ets @@ -323,6 +323,20 @@ export class ImClient { return HttpClient.get('/api/im/groups/' + encodeURIComponent(groupId), this.buildAppQuery()) } + async listGroupMembers(groupId: string): Promise { + return HttpClient.get( + '/api/im/groups/' + encodeURIComponent(groupId) + '/members', + this.buildAppQuery(), + ) + } + + async searchGroupMembers(groupId: string, keyword: string, size: number = 20): Promise { + return HttpClient.get( + '/api/im/groups/' + encodeURIComponent(groupId) + '/members/search', + this.buildSearchQuery(keyword, size), + ) + } + async searchUsers(keyword: string, size: number = 20): Promise { return HttpClient.get( '/api/im/admin/users/search', @@ -360,6 +374,14 @@ export class ImClient { ) } + async revokeMessage(messageId: string): Promise { + return HttpClient.post( + '/api/im/messages/' + encodeURIComponent(messageId) + '/revoke', + this.buildAppBody(), + this.buildAppQuery(), + ) + } + async sendFriendRequest(toUserId: string, remark: string | null = null): Promise { const params = new FriendRequestBody() params.appId = SDKContext.getConfig().appKey