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:
co-authored by
Claude Opus 4.8
parent
01e4c0e99e
commit
5cbe5d4dd0
@@ -6,6 +6,8 @@ export { BridgeTracer } from './src/main/ets/log/BridgeTracer';
|
||||
export { EventBus, EventPayload } from './src/main/ets/event/EventBus';
|
||||
export { NavEvents, OpenGenericWebPayload, SwitchGamePayload, BackGamePayload }
|
||||
from './src/main/ets/event/NavEvents';
|
||||
export { ShareEvents, SharePanelRequest, SharePanelResult }
|
||||
from './src/main/ets/event/ShareEvents';
|
||||
export { DIContainer, Factory } from './src/main/ets/di/DIContainer';
|
||||
export { ErrorCenter, ErrorStrategy, ErrorSink } from './src/main/ets/error/ErrorCenter';
|
||||
export { AppEnv } from './src/main/ets/env/AppEnv';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 分享面板事件名 + 载荷(跨模块共享:ShareProvider(feature_capabilities) 发 SHOW_PANEL →
|
||||
* BridgeGameContainer(entry) 订阅弹出自定义分享面板 → 用户选平台后经一次性 resultEvent 回投给 ShareProvider)。
|
||||
*
|
||||
* 对齐原 Android「自定义三按钮分享面板」(SharePanelHelper:微信/QQ/抖音 + 点空白关闭=取消)。
|
||||
* 能力层不持 UI;面板 UI 在 entry 容器。一对一回传:ShareProvider 生成唯一 resultEvent,
|
||||
* EventBus.once(resultEvent) 自动注销,避免多 Provider 实例广播串扰。
|
||||
* 载荷用具名 interface(ArkTS 严格:禁 Record/无类型对象字面量),且禁传函数。
|
||||
*/
|
||||
export class ShareEvents {
|
||||
/** 请求弹出分享面板。 */
|
||||
static readonly SHOW_PANEL: string = 'share.showPanel';
|
||||
}
|
||||
|
||||
/** SHOW_PANEL 载荷。 */
|
||||
export interface SharePanelRequest {
|
||||
/** sharetypeBean 的原始 JSON(透传,由 ShareProvider 解析)。 */
|
||||
data: string;
|
||||
/** 用户选定平台后回投的一次性事件名(ShareProvider 已 EventBus.once 监听)。 */
|
||||
resultEvent: string;
|
||||
}
|
||||
|
||||
/** resultEvent 载荷(用户选定结果回投)。 */
|
||||
export interface SharePanelResult {
|
||||
/** 'wechat' | 'qq' | 'douyin' | 'cancel' */
|
||||
platform: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 自定义分享面板(对齐原 Android SharePanelHelper:底部弹出 + 三按钮 微信/QQ/抖音 + 点空白关闭=取消)。
|
||||
*
|
||||
* 纯展示组件:由 BridgeGameContainer 以 @State visible 控制显隐,叠在 Stack 顶层。
|
||||
* 用户点平台 → onPick(platform);点空白/取消 → onPick('cancel')。无图标资源用文字按钮。
|
||||
* 不持业务逻辑、不直接发 EventBus——回投由容器统一经一次性 resultEvent 完成(避免组件耦合事件名)。
|
||||
*/
|
||||
@Component
|
||||
export struct SharePanel {
|
||||
/** 选择回调:'wechat' | 'qq' | 'douyin' | 'cancel'。 */
|
||||
onPick: (platform: string) => void = () => { };
|
||||
|
||||
build() {
|
||||
// 半透明遮罩:点击空白处取消
|
||||
Column() {
|
||||
Blank()
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.onClick(() => this.onPick('cancel'))
|
||||
|
||||
// 内容区:点击不关闭(消费事件)
|
||||
Column() {
|
||||
Text('分享到')
|
||||
.fontSize(16)
|
||||
.fontColor('#333333')
|
||||
.margin({ top: 16, bottom: 16 })
|
||||
|
||||
Row() {
|
||||
this.platformButton('微信', 'wechat', '#07C160')
|
||||
this.platformButton('QQ', 'qq', '#12B7F5')
|
||||
this.platformButton('抖音', 'douyin', '#000000')
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.SpaceEvenly)
|
||||
.margin({ bottom: 12 })
|
||||
|
||||
Divider().color('#EEEEEE')
|
||||
|
||||
Text('取消')
|
||||
.fontSize(16)
|
||||
.fontColor('#666666')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 14, bottom: 14 })
|
||||
.onClick(() => this.onPick('cancel'))
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor(Color.White)
|
||||
.borderRadius({ topLeft: 16, topRight: 16 })
|
||||
.onClick(() => { /* 消费点击,避免穿透到遮罩 */ })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('rgba(0,0,0,0.45)')
|
||||
}
|
||||
|
||||
@Builder
|
||||
platformButton(label: string, platform: string, color: string) {
|
||||
Column() {
|
||||
Circle({ width: 56, height: 56 }).fill(color)
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor('#333333')
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
.onClick(() => this.onPick(platform))
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import { ConfigManager, ResourceManager, AppDataInjector, AppDataValues } from '
|
||||
import { KvStore, LocalUploadServer } from 'platform';
|
||||
import { OutboundHandlers } from 'contracts';
|
||||
import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload,
|
||||
BackGamePayload, Logger } from 'common';
|
||||
BackGamePayload, ShareEvents, SharePanelRequest, SharePanelResult, Logger } from 'common';
|
||||
import { BridgeGameParams, GenericWebParams, GenericWebResult, RouteName, AppEvents } from '../routes/AppRoutes';
|
||||
import { WebSlot } from '../web/WebSlot';
|
||||
import { SharePanel } from '../components/SharePanel';
|
||||
|
||||
/**
|
||||
* 大厅/子游戏容器(对应 webviewActivity,框架 §7.1/§7.5)。**双 Web 槽模型**:
|
||||
@@ -34,6 +35,10 @@ export struct BridgeGameContainer {
|
||||
private cancels: Array<() => void> = [];
|
||||
/** 非空 → 子游戏 Web 显示(路径 A 切换)。 */
|
||||
@State private subgameUrl: string = '';
|
||||
/** 分享面板显隐(叠在 Stack 顶层,对齐 Android SharePanelHelper)。 */
|
||||
@State private sharePanelVisible: boolean = false;
|
||||
/** 当前分享面板的一次性回投事件名(用户选定平台后 emit 回 ShareProvider)。 */
|
||||
private shareResultEvent: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (AppEnv.isDebug()) {
|
||||
@@ -99,6 +104,32 @@ export struct BridgeGameContainer {
|
||||
this.cancels.push(EventBus.on(NavEvents.OPEN_GENERIC_WEB, (p?: EventPayload) => this.openGenericWeb(p)));
|
||||
this.cancels.push(EventBus.on(NavEvents.SWITCH_GAME, (p?: EventPayload) => this.switchGame(p)));
|
||||
this.cancels.push(EventBus.on(NavEvents.BACK_GAME, (p?: EventPayload) => this.backToLobby(p)));
|
||||
this.cancels.push(EventBus.on(ShareEvents.SHOW_PANEL, (p?: EventPayload) => this.showSharePanel(p)));
|
||||
}
|
||||
|
||||
/** 收到 ShareProvider 的 SHOW_PANEL:记下一次性回投事件名并弹出面板。 */
|
||||
private showSharePanel(p?: EventPayload): void {
|
||||
if (p === undefined) {
|
||||
return;
|
||||
}
|
||||
// 已有面板未关闭:先按取消回投旧请求,避免上一个 once 永不触发致 ShareProvider 悬挂。
|
||||
if (this.sharePanelVisible && this.shareResultEvent !== '') {
|
||||
this.emitShareResult('cancel');
|
||||
}
|
||||
const req = p as SharePanelRequest;
|
||||
this.shareResultEvent = req.resultEvent;
|
||||
this.sharePanelVisible = true;
|
||||
}
|
||||
|
||||
/** 用户选定平台/取消:回投并关闭面板。 */
|
||||
private emitShareResult(platform: string): void {
|
||||
const ev: string = this.shareResultEvent;
|
||||
this.shareResultEvent = '';
|
||||
this.sharePanelVisible = false;
|
||||
if (ev !== '') {
|
||||
const result: SharePanelResult = { platform };
|
||||
EventBus.emit(ev, result);
|
||||
}
|
||||
}
|
||||
|
||||
/** 路径 B:打开通用网页容器;关闭回传 → 当前激活槽 callHandler('getWebdata')。 */
|
||||
@@ -211,9 +242,22 @@ export struct BridgeGameContainer {
|
||||
})
|
||||
.onRenderExited((event: OnRenderExitedEvent) => this.slotS?.onRenderExited(event.renderExitReason))
|
||||
}
|
||||
|
||||
// 分享面板(顶层叠加;用户选平台/取消 → 一次性回投 ShareProvider)
|
||||
if (this.sharePanelVisible) {
|
||||
SharePanel({ onPick: (platform: string) => this.emitShareResult(platform) })
|
||||
}
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.onBackPressed(() => {
|
||||
// 分享面板打开时,返回键关闭面板并按取消回投(对齐 Android 点空白关闭)
|
||||
if (this.sharePanelVisible) {
|
||||
this.emitShareResult('cancel');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export class WebSlot {
|
||||
kv,
|
||||
uploadServer: this.uploadServer,
|
||||
log: Logger.tag(`Cap-${this.role}`),
|
||||
captureCanvas: (canvasId: string): Promise<string> => this.captureCanvas(canvasId),
|
||||
};
|
||||
const registrar = new CapabilityRegistrar(buildCapabilities());
|
||||
registrar.registerAll(bridge, ctx);
|
||||
@@ -192,6 +193,49 @@ export class WebSlot {
|
||||
this.adapter = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截取本槽 Web 内的 canvas(截图分享)。脚本在 H5 内 toDataURL,返回 dataURL;
|
||||
* runJavaScript 回调的 result 是 JSON 字符串(外层带引号),需 JSON.parse 去引号还原。
|
||||
* 非激活/出错一律返回空串(由 ShareProvider 降级网页分享,绝不抛错)。
|
||||
*/
|
||||
private captureCanvas(canvasId: string): Promise<string> {
|
||||
if (!this.active || this.disposed) {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
const sel: string = canvasId !== ''
|
||||
? `document.getElementById(${JSON.stringify(canvasId)}).toDataURL('image/png')`
|
||||
: `document.querySelector('canvas').toDataURL('image/jpeg',0.8)`;
|
||||
const script: string = `(function(){try{return ${sel};}catch(e){return '';}})()`;
|
||||
return new Promise<string>((resolve: (v: string) => void) => {
|
||||
try {
|
||||
this.controller.runJavaScript(script, (err: BusinessError, result: string) => {
|
||||
if (err) {
|
||||
WebSlot.log.w(`[${this.role}] captureCanvas runJavaScript error: ${err.message}`);
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
resolve(WebSlot.parseJsString(result));
|
||||
});
|
||||
} catch (e) {
|
||||
WebSlot.log.w(`[${this.role}] captureCanvas failed: ${(e as BusinessError).message}`);
|
||||
resolve('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** runJavaScript 返回值是 JSON 字符串(字符串结果外层带引号);去引号还原,异常返回空串。 */
|
||||
private static parseJsString(result: string): string {
|
||||
if (result === undefined || result === null || result === '' || result === 'null') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed: Object = JSON.parse(result) as Object;
|
||||
return typeof parsed === 'string' ? parsed as string : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private flushPending(): void {
|
||||
if (this.pendingWebdata !== undefined) {
|
||||
this.bridgeCtrl?.callHandler(OutboundHandlers.GetWebData, this.pendingWebdata);
|
||||
|
||||
@@ -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.1,T-M3-13)。微信开放 SDK。
|
||||
* - friendsSharetypeUrlToptitleDescript:data=sharetypeBean JSON。
|
||||
* sharefriend "2"→朋友圈 / 其他→会话;type "1"网页/"2"Canvas截图/"3"图片/"4"视频。
|
||||
* - 回传 sharesuccess {success(2成功/3取消), type(1好友/2朋友圈)}。
|
||||
* 分享能力(契约 §8.1/§10.1,T-M3-13)。对齐原 Android「自定义三按钮分享面板」(微信/QQ/抖音)。
|
||||
*
|
||||
* 本版完整支持 type=1(网页分享);type 2/3/4 暂回退为网页分享(webpageUrl),见 TODO。
|
||||
* ⚠ 运行期成功需在微信开放平台注册本 HarmonyOS 应用(bundleName+指纹 绑定 AppID)。
|
||||
* H5 调 friendsSharetypeUrlToptitleDescript(data=sharetypeBean JSON)→ 弹自定义面板(entry 容器渲染)。
|
||||
* 用户点平台后经一次性 resultEvent 回投本 Provider:
|
||||
* - 微信(@tencent/wechat_open_sdk):
|
||||
* type=="2" → Canvas 截图图片分享(截当前激活 Web 的 canvas.toDataURL → 落盘 → WXImageObject.uri);
|
||||
* 其他 type → 网页分享(WXWebpageObject)。
|
||||
* scene:sharefriend=="1" → 好友(WXSceneSession),否则 → 朋友圈(WXSceneTimeline)。
|
||||
* 分享结果回传 H5:callHandler('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 uri,SDK 支持 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,19 @@ export class FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
/** 写二进制(覆盖;自动建父目录)。用于截图分享落盘等。 */
|
||||
static writeBytes(path: string, bytes: Uint8Array): void {
|
||||
FileSystem.ensureDir(FileSystem.dirOf(path));
|
||||
const file: fs.File = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC);
|
||||
try {
|
||||
// 按实际视图(byteOffset/byteLength)写入,避免底层 buffer 为大池子视图时写多余字节
|
||||
const view: ArrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
fs.writeSync(file.fd, view);
|
||||
} finally {
|
||||
fs.closeSync(file);
|
||||
}
|
||||
}
|
||||
|
||||
/** 读文本(不存在返回空串)。 */
|
||||
static readText(path: string): string {
|
||||
if (!FileSystem.exists(path)) {
|
||||
|
||||
Reference in New Issue
Block a user