From 4a8524bcbe6560c12ddbbd5439e728ff76a2e55d Mon Sep 17 00:00:00 2001 From: lanterngamescn Date: Thu, 25 Jun 2026 20:14:02 +0800 Subject: [PATCH] =?UTF-8?q?M3(=E6=89=B95):=20AudioProvider=20=E6=92=AD?= =?UTF-8?q?=E6=94=BE=20+=20=E4=B8=83=E7=89=9B=E4=B8=8A=E4=BC=A0=E5=99=A8?= =?UTF-8?q?=EF=BC=88@qiniu/upload=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依赖:ohpm i @qiniu/upload@1.0.2(platform) platform QiniuUploader:UploadFile.fromPath + key + createMultipartUploadV2Task(context,file,{tokenProvider})。 🔴 token 走 tokenProvider(服务端用 AK/SK 生成 uploadToken(bucket)),AK/SK 不入端——纠正原 Android 硬编码 AK/SK T-M3-10 AudioProvider(@kit.MediaKit AVPlayer): - mediaTypeAudio:播放语音 url → gameui_play_voice/gameui_stop_voice(user 透传) - srcIsloop:播放本地 wav(/assets/wav/,isloop 0一次/>0循环/<0停止) - voicePlaying:静音总开关 setVolume - prepareaudio:录音分档(QiniuUploader 链路就绪;按住说话手势+麦克风+七牛服务端 token 三依赖未就绪前安全空实现) bucket/域名/key 规则对齐原工程(bucket gameauio、回放 http://gameaudio.daoqi88.cn/,由服务端 token 与 AudioProvider 接入时落地) devecocli build 通过(CompileArkTS 编译七牛/音频无误)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- domain_resource/oh-package-lock.json5 | 11 +- entry/oh-package-lock.json5 | 11 +- entry/src/main/ets/di/AppModule.ets | 3 +- feature_capabilities/Index.ets | 1 + feature_capabilities/oh-package-lock.json5 | 11 +- .../src/main/ets/providers/AudioProvider.ets | 167 ++++++++++++++++++ platform/Index.ets | 1 + platform/oh-package-lock.json5 | 8 + platform/oh-package.json5 | 9 +- .../src/main/ets/upload/QiniuUploader.ets | 46 +++++ 10 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 feature_capabilities/src/main/ets/providers/AudioProvider.ets create mode 100644 platform/src/main/ets/upload/QiniuUploader.ets diff --git a/domain_resource/oh-package-lock.json5 b/domain_resource/oh-package-lock.json5 index 4c03084..1f8b91c 100644 --- a/domain_resource/oh-package-lock.json5 +++ b/domain_resource/oh-package-lock.json5 @@ -6,11 +6,19 @@ "lockfileVersion": 3, "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { + "@qiniu/upload@^1.0.2": "@qiniu/upload@1.0.2", "common@../common": "common@../common", "contracts@../contracts": "contracts@../contracts", "platform@../platform": "platform@../platform" }, "packages": { + "@qiniu/upload@1.0.2": { + "name": "@qiniu/upload", + "version": "1.0.2", + "integrity": "sha512-LWUgUSXo7KSHZ9DaTTso7D7t5VUJqLNu3vPkEdhVNSnjbajqeK8X4yIuWKp9MqCHCyB9KjHSvliRrt4A0ukQ5A==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@qiniu/upload/-/upload-1.0.2.har", + "registryType": "ohpm" + }, "common@../common": { "name": "common", "version": "1.0.0", @@ -33,7 +41,8 @@ "registryType": "local", "dependencies": { "contracts": "file:../contracts", - "common": "file:../common" + "common": "file:../common", + "@qiniu/upload": "^1.0.2" } } } diff --git a/entry/oh-package-lock.json5 b/entry/oh-package-lock.json5 index 1593f06..a776d90 100644 --- a/entry/oh-package-lock.json5 +++ b/entry/oh-package-lock.json5 @@ -6,6 +6,7 @@ "lockfileVersion": 3, "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { + "@qiniu/upload@^1.0.2": "@qiniu/upload@1.0.2", "@tencent/wechat_open_sdk@^1.0.19": "@tencent/wechat_open_sdk@1.0.19", "common@../common": "common@../common", "contracts@../contracts": "contracts@../contracts", @@ -15,6 +16,13 @@ "platform@../platform": "platform@../platform" }, "packages": { + "@qiniu/upload@1.0.2": { + "name": "@qiniu/upload", + "version": "1.0.2", + "integrity": "sha512-LWUgUSXo7KSHZ9DaTTso7D7t5VUJqLNu3vPkEdhVNSnjbajqeK8X4yIuWKp9MqCHCyB9KjHSvliRrt4A0ukQ5A==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@qiniu/upload/-/upload-1.0.2.har", + "registryType": "ohpm" + }, "@tencent/wechat_open_sdk@1.0.19": { "name": "@tencent/wechat_open_sdk", "version": "1.0.19", @@ -79,7 +87,8 @@ "registryType": "local", "dependencies": { "contracts": "file:../contracts", - "common": "file:../common" + "common": "file:../common", + "@qiniu/upload": "^1.0.2" } } } diff --git a/entry/src/main/ets/di/AppModule.ets b/entry/src/main/ets/di/AppModule.ets index 0bb1b07..b9ea1f2 100644 --- a/entry/src/main/ets/di/AppModule.ets +++ b/entry/src/main/ets/di/AppModule.ets @@ -8,7 +8,7 @@ import { CapabilityProvider, EchoSampleProvider, RoomStubProvider, PayStubProvider, VibrateProvider, ClipboardProvider, AppSystemProvider, DeviceProvider, NetworkProvider, ShareProvider, LoginProvider, PhotoProvider, ShakeProvider, - ScanProvider, CameraProvider, LocationProvider } from 'feature_capabilities'; + ScanProvider, CameraProvider, LocationProvider, AudioProvider } from 'feature_capabilities'; export function buildCapabilities(): CapabilityProvider[] { return [ @@ -26,6 +26,7 @@ export function buildCapabilities(): CapabilityProvider[] { new ScanProvider(), // opensaoma → getsaomaData new CameraProvider(), // opencamera → getcameraaAddress new LocationProvider(), // startlocation/getlocationinfo → getlocationinfo + new AudioProvider(), // mediaTypeAudio/srcIsloop/voicePlaying(播放);prepareaudio 录音分档 // —— 暂缓能力占位桩(§6.5)—— new RoomStubProvider(), new PayStubProvider(), diff --git a/feature_capabilities/Index.ets b/feature_capabilities/Index.ets index 3f6947a..7f69034 100644 --- a/feature_capabilities/Index.ets +++ b/feature_capabilities/Index.ets @@ -21,6 +21,7 @@ export { ShakeProvider } from './src/main/ets/providers/ShakeProvider'; export { ScanProvider } from './src/main/ets/providers/ScanProvider'; export { CameraProvider } from './src/main/ets/providers/CameraProvider'; export { LocationProvider } from './src/main/ets/providers/LocationProvider'; +export { AudioProvider } from './src/main/ets/providers/AudioProvider'; // 微信 SDK 封装(EntryAbility 处理回调 want 需用) export { WeChatApi, WX_APP_ID } from './src/main/ets/wx/WeChatApi'; diff --git a/feature_capabilities/oh-package-lock.json5 b/feature_capabilities/oh-package-lock.json5 index d3ee3dc..3973b1d 100644 --- a/feature_capabilities/oh-package-lock.json5 +++ b/feature_capabilities/oh-package-lock.json5 @@ -6,6 +6,7 @@ "lockfileVersion": 3, "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { + "@qiniu/upload@^1.0.2": "@qiniu/upload@1.0.2", "@tencent/wechat_open_sdk@^1.0.19": "@tencent/wechat_open_sdk@1.0.19", "common@../common": "common@../common", "contracts@../contracts": "contracts@../contracts", @@ -14,6 +15,13 @@ "platform@../platform": "platform@../platform" }, "packages": { + "@qiniu/upload@1.0.2": { + "name": "@qiniu/upload", + "version": "1.0.2", + "integrity": "sha512-LWUgUSXo7KSHZ9DaTTso7D7t5VUJqLNu3vPkEdhVNSnjbajqeK8X4yIuWKp9MqCHCyB9KjHSvliRrt4A0ukQ5A==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@qiniu/upload/-/upload-1.0.2.har", + "registryType": "ohpm" + }, "@tencent/wechat_open_sdk@1.0.19": { "name": "@tencent/wechat_open_sdk", "version": "1.0.19", @@ -64,7 +72,8 @@ "registryType": "local", "dependencies": { "contracts": "file:../contracts", - "common": "file:../common" + "common": "file:../common", + "@qiniu/upload": "^1.0.2" } } } diff --git a/feature_capabilities/src/main/ets/providers/AudioProvider.ets b/feature_capabilities/src/main/ets/providers/AudioProvider.ets new file mode 100644 index 0000000..01ad9cd --- /dev/null +++ b/feature_capabilities/src/main/ets/providers/AudioProvider.ets @@ -0,0 +1,167 @@ +/** + * 音频能力(契约 §8.7/§9/§10.6,T-M3-10)。@kit.MediaKit AVPlayer。 + * - mediaTypeAudio:data={audiourl,type,user},播放语音消息 → gameui_play_voice/gameui_stop_voice(user 透传)。 + * - srcIsloop:data={src,isloop},播放本地 wav 音效/背景乐(isloop 0一次/>0循环/<0停止)。 + * - voicePlaying:data="1" 开 / 其他 静音(总开关)。 + * - prepareaudio:按住说话录音 →(上传七牛)→ getaudiourl。 + * + * ⚠ prepareaudio 录音为分档实现:录音上传链路用 platform QiniuUploader(token 走服务端,见其注释), + * 但"按住说话"的原生触摸起止手势与麦克风权限需与容器触摸集成(原 Android 在 WebView 上做手势), + * 且需七牛服务端 token 端点——此三者就绪前 prepareaudio 为安全空实现(不录、不卡死)。见 TODO。 + */ +import { media } from '@kit.MediaKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { common } from '@kit.AbilityKit'; +import { BridgeController } from 'feature_bridge'; +import { InboundHandlers, OutboundHandlers, MediaTypeAudioReq, SrcIsLoopReq } from 'contracts'; +import { ConfigManager, ResourceManager } from 'domain_resource'; +import { Logger } from 'common'; +import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider'; + +export class AudioProvider implements CapabilityProvider { + readonly name: string = 'audio'; + private readonly log: Logger = Logger.tag('AudioProvider'); + private bridge: BridgeController | undefined = undefined; + private context: common.UIAbilityContext | undefined = undefined; + private config: ConfigManager | undefined = undefined; + private voicePlayer: media.AVPlayer | undefined = undefined; + private sfxPlayer: media.AVPlayer | undefined = undefined; + private muted: boolean = false; + + register(bridge: BridgeController, ctx: CapabilityContext): void { + this.bridge = bridge; + this.context = ctx.uiAbilityContext; + this.config = ctx.config; + bridge.registerHandler(InboundHandlers.MediaTypeAudio, (data: string, _cb: (resp: string) => void) => { + this.playVoiceMessage(data); + }); + bridge.registerHandler(InboundHandlers.SrcIsLoop, (data: string, _cb: (resp: string) => void) => { + this.playSfx(data); + }); + bridge.registerHandler(InboundHandlers.VoicePlaying, (data: string, _cb: (resp: string) => void) => { + this.setMuted(data !== '1'); + }); + bridge.registerHandler(InboundHandlers.PrepareAudio, (_d: string, _cb: (resp: string) => void) => { + // TODO(M3 录音):按住说话录音(AVRecorder)→ platform QiniuUploader.upload(token 走服务端) → + // callHandler(getaudiourl, {audiourl,time,filepath})。需容器触摸手势(起/止)、麦克风权限、七牛服务端 token 端点。 + this.log.i('prepareaudio: recording requires touch-gesture + mic perm + qiniu server token (stub)'); + }); + } + + onDestroy(): void { + this.releaseVoice(); + this.releaseSfx(); + } + + /** 播放语音消息(远程 url),上报 gameui_play_voice/gameui_stop_voice。 */ + private playVoiceMessage(data: string): void { + let req: MediaTypeAudioReq; + try { + req = JSON.parse(data) as MediaTypeAudioReq; + } catch (e) { + this.log.w(`mediaTypeAudio bad json: ${(e as Error).message}`); + return; + } + if (req.audiourl === '') { + return; + } + const user: string = req.user; + this.releaseVoice(); + media.createAVPlayer().then((player: media.AVPlayer) => { + this.voicePlayer = player; + player.on('stateChange', (state: string) => { + if (state === 'initialized') { + player.prepare(); + } else if (state === 'prepared') { + player.loop = false; + player.setVolume(this.muted ? 0 : 1); + player.play(); + this.bridge?.callHandler(OutboundHandlers.GameUiPlayVoice, user); + } else if (state === 'completed') { + this.bridge?.callHandler(OutboundHandlers.GameUiStopVoice, user); + this.releaseVoice(); + } + }); + player.on('error', (e: BusinessError) => { + this.log.w(`voice play error: ${e.code} ${e.message}`); + this.bridge?.callHandler(OutboundHandlers.GameUiStopVoice, user); + this.releaseVoice(); + }); + player.url = req.audiourl; + }).catch((e: BusinessError) => this.log.w(`createAVPlayer(voice) failed: ${e.message}`)); + } + + /** 播放本地 wav 音效/背景乐:/assets/wav/。isloop<0 停止。 */ + private playSfx(data: string): void { + let req: SrcIsLoopReq; + try { + req = JSON.parse(data) as SrcIsLoopReq; + } catch (e) { + this.log.w(`srcIsloop bad json: ${(e as Error).message}`); + return; + } + if (req.isloop < 0) { + this.releaseSfx(); + return; + } + const ctx = this.context; + const cfg = this.config; + if (ctx === undefined || cfg === undefined) { + return; + } + const local = cfg.getLocal(); + const wavPath: string = + `${ResourceManager.resourceRoot(ctx)}/${local.gamedir}/${local.gamestart}/assets/wav/${req.src}`; + const loop: boolean = req.isloop > 0; + this.releaseSfx(); + media.createAVPlayer().then((player: media.AVPlayer) => { + this.sfxPlayer = player; + player.on('stateChange', (state: string) => { + if (state === 'initialized') { + player.prepare(); + } else if (state === 'prepared') { + player.loop = loop; + player.setVolume(this.muted ? 0 : 1); + player.play(); + } else if (state === 'completed') { + if (!loop) { + this.releaseSfx(); + } + } + }); + player.on('error', (e: BusinessError) => { + this.log.w(`sfx play error: ${e.code} ${e.message}`); + this.releaseSfx(); + }); + player.url = `file://${wavPath}`; + }).catch((e: BusinessError) => this.log.w(`createAVPlayer(sfx) failed: ${e.message}`)); + } + + /** 语音总开关:静音/取消静音,作用于当前两个播放器。 */ + private setMuted(muted: boolean): void { + this.muted = muted; + const vol: number = muted ? 0 : 1; + try { + this.voicePlayer?.setVolume(vol); + this.sfxPlayer?.setVolume(vol); + } catch (e) { + this.log.w(`setVolume failed: ${(e as Error).message}`); + } + } + + private releaseVoice(): void { + const p = this.voicePlayer; + this.voicePlayer = undefined; + if (p !== undefined) { + p.release().catch((e: BusinessError) => this.log.w(`release voice failed: ${e.message}`)); + } + } + + private releaseSfx(): void { + const p = this.sfxPlayer; + this.sfxPlayer = undefined; + if (p !== undefined) { + p.release().catch((e: BusinessError) => this.log.w(`release sfx failed: ${e.message}`)); + } + } +} diff --git a/platform/Index.ets b/platform/Index.ets index 9091486..f91243f 100644 --- a/platform/Index.ets +++ b/platform/Index.ets @@ -14,3 +14,4 @@ export { Unzipper } from './src/main/ets/zip/Unzipper'; // 权限 / 上传服务 export { PermissionGuard } from './src/main/ets/perm/PermissionGuard'; export { LocalUploadServer } from './src/main/ets/upload/LocalUploadServer'; +export { QiniuUploader, QiniuTokenProvider } from './src/main/ets/upload/QiniuUploader'; diff --git a/platform/oh-package-lock.json5 b/platform/oh-package-lock.json5 index 9613757..5df63ba 100644 --- a/platform/oh-package-lock.json5 +++ b/platform/oh-package-lock.json5 @@ -6,10 +6,18 @@ "lockfileVersion": 3, "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { + "@qiniu/upload@^1.0.2": "@qiniu/upload@1.0.2", "common@../common": "common@../common", "contracts@../contracts": "contracts@../contracts" }, "packages": { + "@qiniu/upload@1.0.2": { + "name": "@qiniu/upload", + "version": "1.0.2", + "integrity": "sha512-LWUgUSXo7KSHZ9DaTTso7D7t5VUJqLNu3vPkEdhVNSnjbajqeK8X4yIuWKp9MqCHCyB9KjHSvliRrt4A0ukQ5A==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@qiniu/upload/-/upload-1.0.2.har", + "registryType": "ohpm" + }, "common@../common": { "name": "common", "version": "1.0.0", diff --git a/platform/oh-package.json5 b/platform/oh-package.json5 index ee05594..e39fe80 100644 --- a/platform/oh-package.json5 +++ b/platform/oh-package.json5 @@ -7,6 +7,9 @@ "license": "Apache-2.0", "dependencies": { "contracts": "file:../contracts", - "common": "file:../common" - } -} + "common": "file:../common", + "@qiniu/upload": "^1.0.2" + }, + "devDependencies": {}, + "dynamicDependencies": {} +} \ No newline at end of file diff --git a/platform/src/main/ets/upload/QiniuUploader.ets b/platform/src/main/ets/upload/QiniuUploader.ets new file mode 100644 index 0000000..cb0f398 --- /dev/null +++ b/platform/src/main/ets/upload/QiniuUploader.ets @@ -0,0 +1,46 @@ +/** + * 七牛云对象存储上传(@qiniu/upload,契约 §10.6 录音上传)。 + * + * 🔴 安全(CLAUDE.md 附录 B / §14.2):上传 token 由 **tokenProvider 异步提供**, + * 应由服务端用 AK/SK 生成 uploadToken(bucket) 后下发;**AK/SK 绝不入客户端**。 + * 原 Android 把 AK/SK 硬编码在端侧,本平台用 tokenProvider 模式纠正。 + */ +import { common } from '@kit.AbilityKit'; +import { createMultipartUploadV2Task, UploadFile, UploadConfig, UploadTask } from '@qiniu/upload'; +import { Logger } from 'common'; + +/** token 提供器:返回上传 token 的 Promise(来源应为服务端)。 */ +export type QiniuTokenProvider = () => Promise; + +export class QiniuUploader { + private static readonly log: Logger = Logger.tag('QiniuUploader'); + + /** + * 上传本地文件到七牛,资源名为 key。成功返回 true。 + * @param context UIAbility 上下文 + * @param localPath 本地文件路径 + * @param key 七牛资源名(= 回放 URL 的路径段) + * @param tokenProvider 上传 token 提供器(服务端生成 uploadToken(bucket)) + */ + static async upload(context: common.Context, localPath: string, key: string, + tokenProvider: QiniuTokenProvider): Promise { + const file: UploadFile = UploadFile.fromPath(localPath); + file.key = key; + const config: UploadConfig = { tokenProvider }; + const task: UploadTask = createMultipartUploadV2Task(context, file, config); + let failed: boolean = false; + task.onError(() => { + failed = true; + }); + try { + await task.start(); + } catch (e) { + QiniuUploader.log.w(`upload start failed: ${(e as Error).message}`); + return false; + } + if (failed) { + QiniuUploader.log.w(`upload failed for key=${key}`); + } + return !failed; + } +}