feat(sdk): 添加鸿蒙SDK核心功能模块

- 实现SDKContext用于配置管理和数据持久化存储
- 定义完整的类型系统包括消息、用户、群组等接口
- 集成更新SDK支持原生应用和RN热更新检查
- 提供统一的XuqmSDK入口类和模块导出
- 编写详细的开发文档和使用示例
这个提交包含在:
XuqmGroup 2026-04-29 19:08:12 +08:00
父节点 215859f03f
当前提交 3d1873b6a3
共有 7 个文件被更改,包括 355 次插入9 次删除

查看文件

@ -207,19 +207,25 @@ Harmony 版本不提供本地安装包下载,更新只跳转应用市场。
### 检查 RN Bundle 热更新 ### 检查 RN Bundle 热更新
```typescript ```typescript
const rnResult = await XuqmSDK.update.checkRnUpdate('ak_xxx', 'main', 100) const rnResult = await XuqmSDK.update.checkRnUpdate('ak_xxx', 'main')
if (rnResult.hasUpdate && rnResult.info) { if (rnResult.hasUpdate && rnResult.info) {
const filePath = await XuqmSDK.update.downloadRnBundle( const filePath = await XuqmSDK.update.downloadRnBundle(
context, context,
rnResult.info.downloadUrl, rnResult.info.downloadUrl,
'main.harmony.bundle' 'main.harmony.zip',
'main',
rnResult.info
) )
// 文件下载至 context.cacheDir/main.harmony.bundle // 文件下载至 context.cacheDir/main.harmony.zip
// 如需在本地缓存当前 RN 版本,downloadRnBundle 会顺手写入 bundleVersion
// 通知 RN 引擎重新加载 // 通知 RN 引擎重新加载
} }
``` ```
`checkRnUpdate` 会优先读取本地保存的 RN bundleVersion;如果本地没有缓存,再回退到调用方手工传入的版本号。
如果项目里还有别的 bundle 安装入口,不经过 `downloadRnBundle`,那条链路也要在安装成功后调用 `XuqmSDK.update.rememberRnBundleInfo(...)`,否则本地缓存会落后于真实已安装版本。
--- ---
## 发版ohpm ## 发版ohpm

查看文件

@ -10,6 +10,7 @@ export type {
SendMessageParams, SendMessageParams,
AppVersionInfo, AppVersionInfo,
RnBundleInfo, RnBundleInfo,
InstalledRnBundleInfo,
PushTokenInfo, PushTokenInfo,
MsgType, MsgType,
ChatType, ChatType,

查看文件

@ -0,0 +1,262 @@
/**
* XuqmGroup Update Service — Harmony RN Bundle Release Task
*
* Copy this file to your Harmony project and apply it:
* apply(from = "xuqm_rn_release.gradle.kts")
*
* Then run:
* ./hvigorw xuqmRnRelease
*
* 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.moduleId=main
* xuqm.bundleFile=/path/to/index.bundle
* xuqm.resourceDir=/path/to/resources # optional, but recommended
* xuqm.bundleVersion=100
* xuqm.minCommonVersion=1.0.0 # optional
* xuqm.packageName=com.example.app # optional
* xuqm.note=optional note
* ---
*
* The task:
* 1. Reads local RN release metadata from xuqm.properties
* 2. Packages bundle + resources + rn-manifest.json into a zip
* 3. Uploads the zip to update-service so the server can auto-detect metadata
*/
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.io.FileOutputStream
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.util.Properties
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
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
}
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()
}
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 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 parseCsv(value: String?): List<String> =
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 escapeJson(value: String?): String {
if (value == null) return ""
return buildString(value.length + 8) {
value.forEach { ch ->
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\b' -> append("\\b")
'\u000C' -> append("\\f")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
}
}
fun writeZipEntry(zip: ZipOutputStream, entryName: String, content: ByteArray) {
val entry = ZipEntry(entryName)
zip.putNextEntry(entry)
zip.write(content)
zip.closeEntry()
}
fun addFileToZip(zip: ZipOutputStream, file: File, entryName: String) {
val entry = ZipEntry(entryName)
zip.putNextEntry(entry)
file.inputStream().use { input ->
input.copyTo(zip)
}
zip.closeEntry()
}
fun addDirectoryToZip(zip: ZipOutputStream, dir: File, prefix: String) {
if (!dir.exists()) return
dir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
val relative = file.relativeTo(dir).path.replace(File.separatorChar, '/')
addFileToZip(zip, file, "$prefix/$relative")
}
}
fun packRnReleaseZip(bundleFile: File, resourceDir: File?, manifestJson: String): File {
val zipPath = Files.createTempFile("xuqm-rn-release-", ".zip").toFile()
ZipOutputStream(FileOutputStream(zipPath)).use { zip ->
writeZipEntry(zip, "rn-manifest.json", manifestJson.toByteArray(StandardCharsets.UTF_8))
addFileToZip(zip, bundleFile, "bundle/${bundleFile.name}")
if (resourceDir != null && resourceDir.exists()) {
addDirectoryToZip(zip, resourceDir, "resources")
}
}
return zipPath
}
fun findHighestVersion(listResp: String): Int {
return Regex("\"version\"\\s*:\\s*\"?(\\d+)\"?").findAll(listResp)
.mapNotNull { it.groupValues[1].toIntOrNull() }
.maxOrNull() ?: 0
}
tasks.register("xuqmRnRelease") {
group = "xuqm"
description = "Package RN bundle + resources into zip and upload to XuqmGroup Update Service"
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 moduleId = cfg.getProperty("xuqm.moduleId") ?: throw GradleException("xuqm.moduleId missing")
val bundleFilePath = cfg.getProperty("xuqm.bundleFile") ?: throw GradleException("xuqm.bundleFile missing")
val bundleVersion = cfg.getProperty("xuqm.bundleVersion")
?.trim()
?.toIntOrNull()
?: throw GradleException("xuqm.bundleVersion missing or invalid")
val resourceDirPath = cfg.getProperty("xuqm.resourceDir", "").trim()
val minCommonVersion = cfg.getProperty("xuqm.minCommonVersion", "").trim()
val packageName = cfg.getProperty("xuqm.packageName", "").trim()
val note = cfg.getProperty("xuqm.note", "").trim()
val bundleFile = File(bundleFilePath)
if (!bundleFile.exists()) {
throw GradleException("Bundle file not found: ${bundleFile.absolutePath}")
}
val resourceDir = if (resourceDirPath.isNotBlank()) File(resourceDirPath) else null
if (resourceDir != null && !resourceDir.exists()) {
throw GradleException("Resource directory not found: ${resourceDir.absolutePath}")
}
val listResp = httpGet("$serverUrl/api/v1/rn/list?appId=$appKey&moduleId=$moduleId&platform=HARMONY", apiToken)
val serverVersion = findHighestVersion(listResp)
println("[xuqm] Server latest bundleVersion: $serverVersion")
var releaseVersion = bundleVersion
if (releaseVersion <= serverVersion && System.console() != null) {
println("[xuqm] Local bundleVersion is not greater than server latest. Please enter corrected release version info.")
val inputVersion = promptLine("Release bundleVersion [$bundleVersion]: ")?.trim().orEmpty()
if (inputVersion.isNotBlank()) {
releaseVersion = inputVersion.toInt()
}
}
if (releaseVersion <= serverVersion) {
throw GradleException(
"Release bundleVersion ($releaseVersion) must be greater than server ($serverVersion). " +
"Bump the local RN release metadata or override intentionally via script config."
)
}
val manifestJson = buildString {
append("{")
append("\"moduleId\":\"").append(escapeJson(moduleId)).append("\",")
append("\"platform\":\"HARMONY\",")
append("\"version\":\"").append(releaseVersion).append("\",")
append("\"bundleVersion\":").append(releaseVersion).append(",")
append("\"minCommonVersion\":\"").append(escapeJson(minCommonVersion)).append("\",")
append("\"packageName\":\"").append(escapeJson(packageName)).append("\",")
append("\"note\":\"").append(escapeJson(note)).append("\"")
append("}")
}
val zipFile = packRnReleaseZip(bundleFile, resourceDir, manifestJson)
println("[xuqm] RN bundle zip: ${zipFile.absolutePath}")
println("[xuqm] bundleVersion=$releaseVersion, moduleId=$moduleId, minCommonVersion=${minCommonVersion.ifBlank { "-" }}, packageName=${packageName.ifBlank { "-" }}")
val parts = mutableMapOf<String, Any>(
"appId" to appKey,
"moduleId" to moduleId,
"platform" to "HARMONY",
"version" to releaseVersion.toString(),
"bundle" to zipFile,
)
if (minCommonVersion.isNotBlank()) parts["minCommonVersion"] = minCommonVersion
if (packageName.isNotBlank()) parts["packageName"] = packageName
if (note.isNotBlank()) parts["note"] = note
println("[xuqm] Uploading RN release zip...")
val uploadResp = httpMultipartPost("$serverUrl/api/v1/rn/upload", apiToken, parts)
val versionId = parseJson(uploadResp, "id")
?: throw GradleException("[xuqm] Upload failed:\n$uploadResp")
println("[xuqm] Uploaded, version ID: $versionId")
println("[xuqm] Release zip contains rn-manifest.json + bundle + resources")
}
}

查看文件

@ -2,6 +2,7 @@ import common from '@ohos.app.ability.common'
import type { SDKConfig } from './core/Types' import type { SDKConfig } from './core/Types'
import { SDKContext } from './core/SDKContext' import { SDKContext } from './core/SDKContext'
import { ImClient } from './im/ImClient' import { ImClient } from './im/ImClient'
import { UpdateSDK } from './update/UpdateSDK'
export class XuqmSDK { export class XuqmSDK {
private static _imClient: ImClient | null = null private static _imClient: ImClient | null = null
@ -33,4 +34,8 @@ export class XuqmSDK {
} }
return XuqmSDK._imClient return XuqmSDK._imClient
} }
static get update(): UpdateSDK {
return UpdateSDK
}
} }

查看文件

@ -1,7 +1,8 @@
import preferences from '@ohos.data.preferences' import preferences from '@ohos.data.preferences'
import type { SDKConfig } from './Types' import type { InstalledRnBundleInfo, SDKConfig } from './Types'
const TOKEN_KEY = 'xuqm_token' const TOKEN_KEY = 'xuqm_token'
const RN_BUNDLE_PREFIX = 'xuqm_rn_bundle_'
const PREF_NAME = 'xuqm_sdk_prefs' const PREF_NAME = 'xuqm_sdk_prefs'
export class SDKContext { export class SDKContext {
@ -53,4 +54,37 @@ export class SDKContext {
static getUserId(): string | null { static getUserId(): string | null {
return SDKContext._userId return SDKContext._userId
} }
private static rnBundleKey(bundleName: string): string {
return RN_BUNDLE_PREFIX + bundleName
}
static async setRnBundleInfo(bundleName: string, info: InstalledRnBundleInfo): Promise<void> {
if (!SDKContext._pref || !bundleName.trim()) {
return
}
const payload = JSON.stringify(info)
await SDKContext._pref.put(SDKContext.rnBundleKey(bundleName), payload)
await SDKContext._pref.flush()
}
static async getRnBundleInfo(bundleName: string): Promise<InstalledRnBundleInfo | null> {
if (!SDKContext._pref || !bundleName.trim()) {
return null
}
const raw = await SDKContext._pref.get(SDKContext.rnBundleKey(bundleName), '') as string
if (!raw) {
return null
}
try {
return JSON.parse(raw) as InstalledRnBundleInfo
} catch {
return null
}
}
static async getRnBundleVersion(bundleName: string): Promise<number | null> {
const info = await SDKContext.getRnBundleInfo(bundleName)
return info?.bundleVersion ?? null
}
} }

查看文件

@ -155,9 +155,17 @@ export interface RnBundleInfo {
md5: string md5: string
forceUpdate: boolean forceUpdate: boolean
packageName?: string packageName?: string
minCommonVersion?: string
packageMatched?: boolean packageMatched?: boolean
} }
export interface InstalledRnBundleInfo {
bundleVersion: number
packageName?: string
minCommonVersion?: string
installedAt: number
}
export interface PushTokenInfo { export interface PushTokenInfo {
vendor: string vendor: string
token: string token: string

查看文件

@ -3,7 +3,7 @@ import common from '@ohos.app.ability.common'
import request from '@ohos.request' import request from '@ohos.request'
import type { BusinessError } from '@ohos.base' import type { BusinessError } from '@ohos.base'
import { HttpClient } from '../core/HttpClient' import { HttpClient } from '../core/HttpClient'
import type { AppVersionInfo, RnBundleInfo } from '../core/Types' import type { AppVersionInfo, InstalledRnBundleInfo, RnBundleInfo } from '../core/Types'
import { SDKContext } from '../core/SDKContext' import { SDKContext } from '../core/SDKContext'
export interface AppUpdateResult { export interface AppUpdateResult {
@ -43,14 +43,15 @@ export class UpdateSDK {
static async checkRnUpdate( static async checkRnUpdate(
appKey: string, appKey: string,
bundleName: string, bundleName: string,
currentBundleVersion: number, currentBundleVersion?: number,
packageName?: string packageName?: string
): Promise<RnUpdateResult> { ): Promise<RnUpdateResult> {
const localBundleVersion = currentBundleVersion ?? await SDKContext.getRnBundleVersion(bundleName) ?? 0
const data = await HttpClient.get<RnBundleInfo>( const data = await HttpClient.get<RnBundleInfo>(
`/api/v1/rn/update/check?appKey=${appKey}&bundleName=${bundleName}&bundleVersion=${currentBundleVersion}${packageName ? `&packageName=${encodeURIComponent(packageName)}` : ''}` `/api/v1/rn/update/check?appKey=${appKey}&bundleName=${bundleName}&bundleVersion=${localBundleVersion}${packageName ? `&packageName=${encodeURIComponent(packageName)}` : ''}`
) )
if (data.bundleVersion <= currentBundleVersion) { if (data.bundleVersion <= localBundleVersion) {
return { hasUpdate: false } return { hasUpdate: false }
} }
if (packageName && data.packageMatched === false) { if (packageName && data.packageMatched === false) {
@ -62,7 +63,9 @@ export class UpdateSDK {
static async downloadRnBundle( static async downloadRnBundle(
context: common.UIAbilityContext, context: common.UIAbilityContext,
downloadUrl: string, downloadUrl: string,
destFilename: string destFilename: string,
bundleName?: string,
bundleInfo?: RnBundleInfo,
): Promise<string> { ): Promise<string> {
const destPath = context.cacheDir + '/' + destFilename const destPath = context.cacheDir + '/' + destFilename
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@ -75,9 +78,36 @@ export class UpdateSDK {
task.on('fail', (error: number) => reject(new Error(`Download failed: ${error}`))) task.on('fail', (error: number) => reject(new Error(`Download failed: ${error}`)))
}) })
}) })
if (bundleName && bundleInfo) {
const installedInfo: InstalledRnBundleInfo = {
bundleVersion: bundleInfo.bundleVersion,
packageName: bundleInfo.packageName,
minCommonVersion: bundleInfo.minCommonVersion,
installedAt: Date.now(),
}
await SDKContext.setRnBundleInfo(bundleName, installedInfo)
}
if (SDKContext.getConfig().debug) { if (SDKContext.getConfig().debug) {
console.log('[UpdateSDK] RN bundle downloaded to', destPath) console.log('[UpdateSDK] RN bundle downloaded to', destPath)
} }
return destPath return destPath
} }
static async rememberRnBundleInfo(
bundleName: string,
bundleVersion: number,
packageName?: string,
minCommonVersion?: string,
): Promise<void> {
await SDKContext.setRnBundleInfo(bundleName, {
bundleVersion,
packageName,
minCommonVersion,
installedAt: Date.now(),
})
}
static async getInstalledRnBundleInfo(bundleName: string): Promise<InstalledRnBundleInfo | null> {
return SDKContext.getRnBundleInfo(bundleName)
}
} }