diff --git a/common/Index.ets b/common/Index.ets index b970b3b..5a1f563 100644 --- a/common/Index.ets +++ b/common/Index.ets @@ -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'; diff --git a/common/src/main/ets/event/ShareEvents.ets b/common/src/main/ets/event/ShareEvents.ets new file mode 100644 index 0000000..c6d61f4 --- /dev/null +++ b/common/src/main/ets/event/ShareEvents.ets @@ -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; +} diff --git a/entry/src/main/ets/components/SharePanel.ets b/entry/src/main/ets/components/SharePanel.ets new file mode 100644 index 0000000..b2244a3 --- /dev/null +++ b/entry/src/main/ets/components/SharePanel.ets @@ -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)) + } +} diff --git a/entry/src/main/ets/pages/BridgeGameContainer.ets b/entry/src/main/ets/pages/BridgeGameContainer.ets index 018de79..456472a 100644 --- a/entry/src/main/ets/pages/BridgeGameContainer.ets +++ b/entry/src/main/ets/pages/BridgeGameContainer.ets @@ -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; + }) } } diff --git a/entry/src/main/ets/web/WebSlot.ets b/entry/src/main/ets/web/WebSlot.ets index b5ab61c..89aab8a 100644 --- a/entry/src/main/ets/web/WebSlot.ets +++ b/entry/src/main/ets/web/WebSlot.ets @@ -66,6 +66,7 @@ export class WebSlot { kv, uploadServer: this.uploadServer, log: Logger.tag(`Cap-${this.role}`), + captureCanvas: (canvasId: string): Promise => 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 { + 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((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); diff --git a/feature_capabilities/src/main/ets/core/CapabilityProvider.ets b/feature_capabilities/src/main/ets/core/CapabilityProvider.ets index 4ad8857..578ef1a 100644 --- a/feature_capabilities/src/main/ets/core/CapabilityProvider.ets +++ b/feature_capabilities/src/main/ets/core/CapabilityProvider.ets @@ -23,6 +23,12 @@ export interface CapabilityContext { uploadServer: LocalUploadServer; /** 通用日志。 */ log: Logger; + /** + * 截取本槽当前 Web 的 canvas(截图分享 type=="2",对齐 Android GlobalWebViewHelper.getCanvasBase64)。 + * @param canvasId 可选元素 id;空则取首个 。 + * @returns dataURL(如 "data:image/jpeg;base64,...");失败/非激活返回空串。 + */ + captureCanvas?: (canvasId: string) => Promise; } /** 能力插件接口。 */ diff --git a/feature_capabilities/src/main/ets/providers/ShareProvider.ets b/feature_capabilities/src/main/ets/providers/ShareProvider.ets index 964771d..b615c36 100644 --- a/feature_capabilities/src/main/ets/providers/ShareProvider.ets +++ b/feature_capabilities/src/main/ets/providers/ShareProvider.ets @@ -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_.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'; + } } diff --git a/platform/src/main/ets/fs/FileSystem.ets b/platform/src/main/ets/fs/FileSystem.ets index ea9f6f2..40e57c3 100644 --- a/platform/src/main/ets/fs/FileSystem.ets +++ b/platform/src/main/ets/fs/FileSystem.ets @@ -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)) {