feat(sdk): 添加鸿蒙SDK核心功能模块
- 实现SDKContext用于配置管理和数据持久化存储 - 定义完整的类型系统包括消息、用户、群组等接口 - 集成更新SDK支持原生应用和RN热更新检查 - 提供统一的XuqmSDK入口类和模块导出 - 编写详细的开发文档和使用示例
这个提交包含在:
父节点
215859f03f
当前提交
3d1873b6a3
12
README.md
12
README.md
@ -207,19 +207,25 @@ Harmony 版本不提供本地安装包下载,更新只跳转应用市场。
|
||||
### 检查 RN Bundle 热更新
|
||||
|
||||
```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) {
|
||||
const filePath = await XuqmSDK.update.downloadRnBundle(
|
||||
context,
|
||||
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 引擎重新加载
|
||||
}
|
||||
```
|
||||
|
||||
`checkRnUpdate` 会优先读取本地保存的 RN bundleVersion;如果本地没有缓存,再回退到调用方手工传入的版本号。
|
||||
如果项目里还有别的 bundle 安装入口,不经过 `downloadRnBundle`,那条链路也要在安装成功后调用 `XuqmSDK.update.rememberRnBundleInfo(...)`,否则本地缓存会落后于真实已安装版本。
|
||||
|
||||
---
|
||||
|
||||
## 发版(ohpm)
|
||||
|
||||
@ -10,6 +10,7 @@ export type {
|
||||
SendMessageParams,
|
||||
AppVersionInfo,
|
||||
RnBundleInfo,
|
||||
InstalledRnBundleInfo,
|
||||
PushTokenInfo,
|
||||
MsgType,
|
||||
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 { SDKContext } from './core/SDKContext'
|
||||
import { ImClient } from './im/ImClient'
|
||||
import { UpdateSDK } from './update/UpdateSDK'
|
||||
|
||||
export class XuqmSDK {
|
||||
private static _imClient: ImClient | null = null
|
||||
@ -33,4 +34,8 @@ export class XuqmSDK {
|
||||
}
|
||||
return XuqmSDK._imClient
|
||||
}
|
||||
|
||||
static get update(): UpdateSDK {
|
||||
return UpdateSDK
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import preferences from '@ohos.data.preferences'
|
||||
import type { SDKConfig } from './Types'
|
||||
import type { InstalledRnBundleInfo, SDKConfig } from './Types'
|
||||
|
||||
const TOKEN_KEY = 'xuqm_token'
|
||||
const RN_BUNDLE_PREFIX = 'xuqm_rn_bundle_'
|
||||
const PREF_NAME = 'xuqm_sdk_prefs'
|
||||
|
||||
export class SDKContext {
|
||||
@ -53,4 +54,37 @@ export class SDKContext {
|
||||
static getUserId(): string | null {
|
||||
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
|
||||
forceUpdate: boolean
|
||||
packageName?: string
|
||||
minCommonVersion?: string
|
||||
packageMatched?: boolean
|
||||
}
|
||||
|
||||
export interface InstalledRnBundleInfo {
|
||||
bundleVersion: number
|
||||
packageName?: string
|
||||
minCommonVersion?: string
|
||||
installedAt: number
|
||||
}
|
||||
|
||||
export interface PushTokenInfo {
|
||||
vendor: string
|
||||
token: string
|
||||
|
||||
@ -3,7 +3,7 @@ import common from '@ohos.app.ability.common'
|
||||
import request from '@ohos.request'
|
||||
import type { BusinessError } from '@ohos.base'
|
||||
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'
|
||||
|
||||
export interface AppUpdateResult {
|
||||
@ -43,14 +43,15 @@ export class UpdateSDK {
|
||||
static async checkRnUpdate(
|
||||
appKey: string,
|
||||
bundleName: string,
|
||||
currentBundleVersion: number,
|
||||
currentBundleVersion?: number,
|
||||
packageName?: string
|
||||
): Promise<RnUpdateResult> {
|
||||
const localBundleVersion = currentBundleVersion ?? await SDKContext.getRnBundleVersion(bundleName) ?? 0
|
||||
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 }
|
||||
}
|
||||
if (packageName && data.packageMatched === false) {
|
||||
@ -62,7 +63,9 @@ export class UpdateSDK {
|
||||
static async downloadRnBundle(
|
||||
context: common.UIAbilityContext,
|
||||
downloadUrl: string,
|
||||
destFilename: string
|
||||
destFilename: string,
|
||||
bundleName?: string,
|
||||
bundleInfo?: RnBundleInfo,
|
||||
): Promise<string> {
|
||||
const destPath = context.cacheDir + '/' + destFilename
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@ -75,9 +78,36 @@ export class UpdateSDK {
|
||||
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) {
|
||||
console.log('[UpdateSDK] RN bundle downloaded to', 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)
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户