M3(T-M3-13): 分享面板对齐原工程——微信SDK + QQ/抖音系统分享

对齐 Android SharePanelHelper 流程:H5 friendsSharetypeUrlToptitleDescript →
原生弹自定义面板(微信/QQ/抖音) → 按所选平台分享。

- common ShareEvents:SHOW_PANEL + SharePanelRequest/Result;ShareProvider 经一次性
  resultEvent 与 entry 容器一对一解耦(双槽实例隔离,避免广播串扰)
- ShareProvider 重构:微信走 SDK(type2 Canvas截图→WXImageObject / 其他→WXWebpageObject;
  scene 好友/朋友圈;回传 sharesuccess);QQ/抖音走 @kit.ShareKit 系统分享(文本/图片/链接/视频,
  不集成对应 SDK、不回传);截图失败统一降级
- 截图:CapabilityContext.captureCanvas(WebSlot 用 controller.runJavaScript 取 canvas
  toDataURL,非激活/出错返回空);FileSystem.writeBytes 落盘;fileUri 给 WXImageObject/系统分享
- entry SharePanel 组件 + BridgeGameContainer 顶层叠加;重复面板/销毁均取消回投防 once 悬挂

真机验证项:微信开放平台注册后的真分享、系统分享面板拉起、canvas 截图

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-26 08:00:03 +08:00
co-authored by Claude Opus 4.8
parent 01e4c0e99e
commit 5cbe5d4dd0
8 changed files with 478 additions and 21 deletions
@@ -23,6 +23,12 @@ export interface CapabilityContext {
uploadServer: LocalUploadServer;
/** 通用日志。 */
log: Logger;
/**
* 截取本槽当前 Web 的 canvas(截图分享 type=="2",对齐 Android GlobalWebViewHelper.getCanvasBase64)。
* @param canvasId 可选元素 id;空则取首个 <canvas>。
* @returns dataURL(如 "data:image/jpeg;base64,...");失败/非激活返回空串。
*/
captureCanvas?: (canvasId: string) => Promise<string>;
}
/** 能力插件接口。 */
@@ -1,34 +1,60 @@
/**
* 分享能力(契约 §8.1/§10.1T-M3-13)。微信开放 SDK
* - friendsSharetypeUrlToptitleDescriptdata=sharetypeBean JSON。
* sharefriend "2"→朋友圈 / 其他→会话;type "1"网页/"2"Canvas截图/"3"图片/"4"视频。
* - 回传 sharesuccess {success(2成功/3取消), type(1好友/2朋友圈)}。
* 分享能力(契约 §8.1/§10.1T-M3-13)。对齐原 Android「自定义三按钮分享面板」(微信/QQ/抖音)
*
* 本版完整支持 type=1(网页分享);type 2/3/4 暂回退为网页分享(webpageUrl),见 TODO
* ⚠ 运行期成功需在微信开放平台注册本 HarmonyOS 应用(bundleName+指纹 绑定 AppID)。
* H5 调 friendsSharetypeUrlToptitleDescriptdata=sharetypeBean JSON)→ 弹自定义面板(entry 容器渲染)
* 用户点平台后经一次性 resultEvent 回投本 Provider
* - 微信(@tencent/wechat_open_sdk):
* type=="2" → Canvas 截图图片分享(截当前激活 Web 的 canvas.toDataURL → 落盘 → WXImageObject.uri);
* 其他 type → 网页分享(WXWebpageObject)。
* scenesharefriend=="1" → 好友(WXSceneSession),否则 → 朋友圈(WXSceneTimeline)。
* 分享结果回传 H5callHandler('sharesuccess', {success:2成功/3取消, type:1好友/2朋友圈})。
* - QQ / 抖音(不集成对应 SDK,用 HarmonyOS 系统分享 @kit.ShareKit,让用户在系统面板选目标 app):
* type=="1" → 文本(title+description)type=="2" → 截图图片;
* type=="4"(抖音视频) → 视频(webpageUrl 为视频地址)else → 网页链接(webpageUrl)。**不回传 sharesuccess**。
*
* ⚠ 微信运行期成功需在微信开放平台注册本 HarmonyOS 应用(bundleName+指纹 绑定 AppID)。
* 截图/系统分享拉起/微信分享 均只能真机验证。
*/
import { SendMessageToWXReq, WXMediaMessage, WXWebpageObject, ErrCode } from '@tencent/wechat_open_sdk';
import { SendMessageToWXReq, WXMediaMessage, WXWebpageObject, WXImageObject, ErrCode } from '@tencent/wechat_open_sdk';
import { systemShare } from '@kit.ShareKit';
import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { fileUri } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { util } from '@kit.ArkTS';
import { BridgeController } from 'feature_bridge';
import { InboundHandlers, OutboundHandlers, SharetypeBean, ShareSuccessResp } from 'contracts';
import { Logger } from 'common';
import { EventBus, Logger, ShareEvents, SharePanelRequest, SharePanelResult } from 'common';
import { FileSystem } from 'platform';
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
import { WeChatApi } from '../wx/WeChatApi';
export class ShareProvider implements CapabilityProvider {
/** 进程内实例计数:双 Web 槽各有一个 ShareProvider 实例,用它给 resultEvent 命名空间隔离。 */
private static instanceCount: number = 0;
readonly name: string = 'share';
private readonly log: Logger = Logger.tag('ShareProvider');
private readonly instanceId: number;
private bridge: BridgeController | undefined = undefined;
private ctx: CapabilityContext | undefined = undefined;
/** 截图落盘 + resultEvent 命名计数器(实例自增,避免 Math.random 串扰)。 */
private seq: number = 0;
constructor() {
ShareProvider.instanceCount += 1;
this.instanceId = ShareProvider.instanceCount;
}
register(bridge: BridgeController, ctx: CapabilityContext): void {
this.bridge = bridge;
this.ctx = ctx;
bridge.registerHandler(InboundHandlers.FriendsShare, (data: string, _cb: (resp: string) => void) => {
this.share(data);
this.onShare(data);
});
}
private share(data: string): void {
/** 入站:校验 bean → 发起面板请求(一对一回投)。 */
private onShare(data: string): void {
const ctx = this.ctx;
if (ctx === undefined) {
return;
@@ -40,36 +66,263 @@ export class ShareProvider implements CapabilityProvider {
this.log.w(`share bad json: ${(e as Error).message}`);
return;
}
const typeNum: number = bean.sharefriend === '2' ? 2 : 1;
if (bean === undefined || bean === null) {
this.log.w('share bean empty');
return;
}
this.seq += 1;
const resultEvent: string = `share.result.${this.instanceId}.${this.seq}`;
EventBus.once(resultEvent, (p) => this.onPlatformChosen(bean, p));
const req: SharePanelRequest = { data, resultEvent };
EventBus.emit(ShareEvents.SHOW_PANEL, req);
}
/** 用户在面板选定平台后回投。 */
private onPlatformChosen(bean: SharetypeBean, payload?: Object): void {
const platform: string = payload !== undefined ? (payload as SharePanelResult).platform : 'cancel';
if (platform === 'wechat') {
this.shareToWeChat(bean);
} else if (platform === 'qq') {
this.shareViaSystem(bean, 'qq');
} else if (platform === 'douyin') {
this.shareViaSystem(bean, 'douyin');
}
// cancel:不分享、不回传(对齐 Android 点空白关闭面板)。
}
// —— 微信 ——
/** sharefriend=="1" → 好友(回传 type=1);否则 → 朋友圈(回传 type=2)。对齐 Android scene 语义。 */
private wechatReportType(bean: SharetypeBean): number {
return bean.sharefriend === '1' ? 1 : 2;
}
private shareToWeChat(bean: SharetypeBean): void {
const ctx = this.ctx;
if (ctx === undefined) {
return;
}
const reportType: number = this.wechatReportType(bean);
const wx: WeChatApi = WeChatApi.getInstance();
if (!wx.isWXInstalled()) {
this.log.i('WeChat not installed; report cancel');
this.reportResult(3, typeNum);
this.reportResult(3, reportType);
return;
}
if (bean.type === '2') {
// Canvas 截图图片分享(失败降级网页)
const cap = ctx.captureCanvas;
if (cap === undefined) {
this.wechatWebPage(ctx.uiAbilityContext, wx, bean, reportType);
return;
}
cap(bean.sharetype).then((dataUrl: string) => {
const filePath: string = dataUrl !== '' ? this.saveDataUrlToFile(ctx.uiAbilityContext, dataUrl) : '';
if (filePath !== '') {
this.wechatImage(ctx.uiAbilityContext, wx, filePath, bean, reportType);
} else {
this.log.w('canvas capture empty; fallback to webpage share');
this.wechatWebPage(ctx.uiAbilityContext, wx, bean, reportType);
}
}).catch((e: Error) => {
this.log.w(`captureCanvas failed: ${e.message}; fallback to webpage share`);
this.wechatWebPage(ctx.uiAbilityContext, wx, bean, reportType);
});
return;
}
this.wechatWebPage(ctx.uiAbilityContext, wx, bean, reportType);
}
private wechatScene(bean: SharetypeBean): number {
return bean.sharefriend === '1' ? SendMessageToWXReq.WXSceneSession : SendMessageToWXReq.WXSceneTimeline;
}
private wechatWebPage(ctx: common.UIAbilityContext, wx: WeChatApi, bean: SharetypeBean, reportType: number): void {
const req: SendMessageToWXReq = new SendMessageToWXReq();
req.scene = bean.sharefriend === '2' ? SendMessageToWXReq.WXSceneTimeline : SendMessageToWXReq.WXSceneSession;
req.scene = this.wechatScene(bean);
const msg: WXMediaMessage = new WXMediaMessage();
msg.title = bean.title;
msg.description = bean.description;
// TODO(M3)type "2" Canvas 截图(需容器 runJavaScript canvas.toDataURL + LocalUploadServer)、
// "3" 图片链接(下载后 WXImageObject)、"4" 视频。当前统一按网页分享回退,保证可分享不报错。
const web: WXWebpageObject = new WXWebpageObject();
web.webpageUrl = bean.webpageUrl;
msg.mediaObject = web;
req.message = msg;
wx.sendShare(ctx.uiAbilityContext, req, (resp) => {
const success: number = resp.errCode === ErrCode.ERR_OK ? 2 : 3;
this.reportResult(success, typeNum);
wx.sendShare(ctx, req, (resp) => {
this.reportResult(resp.errCode === ErrCode.ERR_OK ? 2 : 3, reportType);
});
}
private wechatImage(ctx: common.UIAbilityContext, wx: WeChatApi, filePath: string,
bean: SharetypeBean, reportType: number): void {
const req: SendMessageToWXReq = new SendMessageToWXReq();
req.scene = this.wechatScene(bean);
const msg: WXMediaMessage = new WXMediaMessage();
const img: WXImageObject = new WXImageObject();
img.uri = fileUri.getUriFromPath(filePath); // 沙箱文件 file uriSDK 支持 jpeg/png
msg.mediaObject = img;
req.message = msg;
wx.sendShare(ctx, req, (resp) => {
this.reportResult(resp.errCode === ErrCode.ERR_OK ? 2 : 3, reportType);
});
}
// —— QQ / 抖音:HarmonyOS 系统分享(不回传 sharesuccess)——
private shareViaSystem(bean: SharetypeBean, platform: string): void {
const ctx = this.ctx;
if (ctx === undefined) {
return;
}
const uiCtx: common.UIAbilityContext = ctx.uiAbilityContext;
if (bean.type === '2') {
// 截图图片
const cap = ctx.captureCanvas;
if (cap === undefined) {
this.systemText(uiCtx, bean, platform);
return;
}
cap(bean.sharetype).then((dataUrl: string) => {
const filePath: string = dataUrl !== '' ? this.saveDataUrlToFile(uiCtx, dataUrl) : '';
if (filePath !== '') {
this.systemImage(uiCtx, filePath, bean);
} else {
this.log.w(`[${platform}] canvas capture empty; fallback to text`);
this.systemText(uiCtx, bean, platform);
}
}).catch((e: Error) => {
this.log.w(`[${platform}] captureCanvas failed: ${e.message}; fallback to text`);
this.systemText(uiCtx, bean, platform);
});
return;
}
if (bean.type === '1') {
this.systemText(uiCtx, bean, platform);
return;
}
if (bean.type === '4') {
// 抖音视频:webpageUrl 为视频地址
this.systemVideo(uiCtx, bean);
return;
}
// else:网页链接(title + description + webpageUrl
this.systemLink(uiCtx, bean);
}
/** 文本分享(title + description,对齐 Android 文本拼接)。 */
private systemText(uiCtx: common.UIAbilityContext, bean: SharetypeBean, _platform: string): void {
const text: string = this.joinText(bean.title, bean.description);
const record: systemShare.SharedRecord = { utd: utd.UniformDataType.PLAIN_TEXT, content: text !== '' ? text : ' ' };
this.showSystemShare(uiCtx, record);
}
/** 网页链接分享。 */
private systemLink(uiCtx: common.UIAbilityContext, bean: SharetypeBean): void {
const url: string = bean.webpageUrl !== '' ? bean.webpageUrl : ' ';
const record: systemShare.SharedRecord = {
utd: utd.UniformDataType.HYPERLINK,
content: url,
title: bean.title !== '' ? bean.title : undefined,
description: bean.description !== '' ? bean.description : undefined,
};
this.showSystemShare(uiCtx, record);
}
/** 视频分享(webpageUrl 为视频地址;远程地址降级为链接分享)。 */
private systemVideo(uiCtx: common.UIAbilityContext, bean: SharetypeBean): void {
const path: string = bean.webpageUrl;
if (path !== '' && (path.startsWith('/') || path.startsWith('file://')) && FileSystem.exists(this.toLocalPath(path))) {
const local: string = this.toLocalPath(path);
const typeId: string = utd.getUniformDataTypeByFilenameExtension(this.extOf(local), utd.UniformDataType.VIDEO);
const record: systemShare.SharedRecord = {
utd: typeId,
uri: fileUri.getUriFromPath(local),
title: bean.title !== '' ? bean.title : undefined,
description: bean.description !== '' ? bean.description : undefined,
};
this.showSystemShare(uiCtx, record);
return;
}
// 非本地视频文件:以链接形式分享地址(系统分享无法直接推送远程视频)
this.systemLink(uiCtx, bean);
}
/** 图片分享(沙箱文件)。 */
private systemImage(uiCtx: common.UIAbilityContext, filePath: string, bean: SharetypeBean): void {
const typeId: string = utd.getUniformDataTypeByFilenameExtension(this.extOf(filePath), utd.UniformDataType.IMAGE);
const record: systemShare.SharedRecord = {
utd: typeId,
uri: fileUri.getUriFromPath(filePath),
title: bean.title !== '' ? bean.title : undefined,
description: bean.description !== '' ? bean.description : undefined,
};
this.showSystemShare(uiCtx, record);
}
/** 拉起系统分享面板(失败 log 不崩溃)。 */
private showSystemShare(uiCtx: common.UIAbilityContext, record: systemShare.SharedRecord): void {
try {
const data: systemShare.SharedData = new systemShare.SharedData(record);
const controller: systemShare.ShareController = new systemShare.ShareController(data);
controller.show(uiCtx, {
selectionMode: systemShare.SelectionMode.SINGLE,
previewMode: systemShare.SharePreviewMode.DETAIL,
}).catch((e: BusinessError) => {
this.log.w(`systemShare show failed: code=${e.code} ${e.message}`);
});
} catch (e) {
this.log.w(`systemShare init failed: ${(e as BusinessError).message}`);
}
}
// —— 公共 ——
private reportResult(success: number, type: number): void {
const out: ShareSuccessResp = { success, type };
this.bridge?.callHandler(OutboundHandlers.ShareSuccess, JSON.stringify(out));
}
/**
* dataURL"data:image/...;base64,XXXX")→ 解码 → 写沙箱 filesDir/share/shot_<seq>.jpg → 返回沙箱路径。
* 失败返回空串(调用方降级)。
*/
private saveDataUrlToFile(uiCtx: common.UIAbilityContext, dataUrl: string): string {
try {
const comma: number = dataUrl.indexOf(',');
const base64: string = comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl;
if (base64 === '') {
return '';
}
const bytes: Uint8Array = new util.Base64Helper().decodeSync(base64);
this.seq += 1;
const ext: string = dataUrl.indexOf('image/png') >= 0 ? '.png' : '.jpg';
const filePath: string = `${uiCtx.filesDir}/share/shot_${this.seq}${ext}`;
FileSystem.writeBytes(filePath, bytes);
return filePath;
} catch (e) {
this.log.w(`saveDataUrlToFile failed: ${(e as Error).message}`);
return '';
}
}
private joinText(title: string, description: string): string {
const parts: string[] = [];
if (title !== '') {
parts.push(title);
}
if (description !== '') {
parts.push(description);
}
return parts.join('\n\n');
}
/** file:// 前缀 → 本地路径。 */
private toLocalPath(p: string): string {
return p.startsWith('file://') ? p.substring('file://'.length) : p;
}
private extOf(path: string): string {
const dot: number = path.lastIndexOf('.');
const slash: number = path.lastIndexOf('/');
return dot > slash && dot >= 0 ? path.substring(dot) : '.jpg';
}
}