diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json index efa3990..f483c77 100644 --- a/public/.well-known/assetlinks.json +++ b/public/.well-known/assetlinks.json @@ -1,11 +1,14 @@ [ { - "relation": ["delegate_permission/common.handle_all_urls"], + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.use_as_origin" + ], "target": { "namespace": "android_app", "package_name": "com.imyeyu.gao.customer", "sha256_cert_fingerprints": [ - "EF:65:BA:62:7D:E3:1C:D3:BD:C7:D9:1D:6A:69:35:81:56:87:DF:03:0D:E7:76:27:94:90:5E:CC:F4:45:02:69" + "3E:9C:CC:8A:29:71:18:74:BB:56:AA:88:AD:62:E1:F3:AC:F2:70:07:C4:67:D9:32:B8:A2:4B:B7:E7:8E:06:34" ] } } diff --git a/src/components/EventRecordVerifyDialog.vue b/src/components/EventRecordVerifyDialog.vue index 0776a92..cd6d730 100644 --- a/src/components/EventRecordVerifyDialog.vue +++ b/src/components/EventRecordVerifyDialog.vue @@ -60,6 +60,7 @@ import { getAttachmentReadURL } from "@/utils/attachment"; import { getErrorMessage } from "@/utils/error"; import { compressUploadImageList } from "@/utils/imageCompression"; import { Message } from "@/utils/message"; +import { saveCapturedFilesFromInput } from "@/utils/twaBridge"; defineOptions({ name: "EventRecordVerifyDialog" @@ -131,6 +132,7 @@ async function handlePhotoSelected(event: Event): Promise { if (!fileList.length) { return; } + saveCapturedFilesFromInput(input, fileList); isPhotoUploading.value = true; try { diff --git a/src/components/mediaSelector/MediaSelector.vue b/src/components/mediaSelector/MediaSelector.vue index 3c21d92..b395db4 100644 --- a/src/components/mediaSelector/MediaSelector.vue +++ b/src/components/mediaSelector/MediaSelector.vue @@ -52,6 +52,7 @@ import type { MediaSelectorAddAction, MediaSelectorViewerItem } from "./types"; import { getMediaPreviewURL, normalizePhotoItem } from "./utils"; import type { PhotoItem } from "@/types/Photo.ts"; import { Message } from "@/utils/message.ts"; +import { saveCapturedFilesFromInput } from "@/utils/twaBridge"; defineOptions({ inheritAttrs: false @@ -142,6 +143,7 @@ async function handleFileChange(event: Event): Promise { if (!fileList.length || !props.upload) { return; } + saveCapturedFilesFromInput(input, fileList); isUploading.value = true; try { diff --git a/src/main.ts b/src/main.ts index 76a8604..d5ac058 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import Network from "timi-web-api/utils/Network"; import { VCan, VRole } from "timi-web-api/utils/directives/Access"; import type { ApiResponse } from "timi-web-api"; import { Message } from "@/utils/message"; +import { initializeTwaBridge } from "@/utils/twaBridge"; import vEntranceTransition from "@/directives/entranceTransition"; import timiWebStyle from "../../timi-web/src/assets/style/timi-web.less?inline"; import tdesignCustomStyle from "../../timi-tdesign-mobile/src/assets/style/tdesign-custom.less?inline"; @@ -24,6 +25,8 @@ export const pinia = createPinia(); const apiBaseURL = import.meta.env.VITE_API_BASE_URL as string | undefined; const libraryOverrideStyleId = "library-override-style"; +initializeTwaBridge(); + function mountLibraryOverrideStyle(): void { const oldStyle = document.getElementById(libraryOverrideStyleId); oldStyle?.remove(); diff --git a/src/pages/customer/CustomerEdit.vue b/src/pages/customer/CustomerEdit.vue index fb4ca32..d62eef3 100644 --- a/src/pages/customer/CustomerEdit.vue +++ b/src/pages/customer/CustomerEdit.vue @@ -244,6 +244,7 @@ import { getAttachmentReadURL } from "@/utils/attachment"; import { normalizeBodyAreaValue } from "@/utils/customer"; import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { compressUploadImage } from "@/utils/imageCompression"; +import { saveCapturedFilesFromInput } from "@/utils/twaBridge"; defineOptions({ name: "CustomerEdit" @@ -638,6 +639,7 @@ async function handlePhotoSelected(event: Event): Promise { if (!file) { return; } + saveCapturedFilesFromInput(input, [file]); isPhotoUploading.value = true; try { const compressedFile = await compressUploadImage(file); diff --git a/src/pages/event/component/useEventRecordPhoto.ts b/src/pages/event/component/useEventRecordPhoto.ts index b887e8c..23c7e6e 100644 --- a/src/pages/event/component/useEventRecordPhoto.ts +++ b/src/pages/event/component/useEventRecordPhoto.ts @@ -3,6 +3,7 @@ import { AttachmentAPI, type Attachment } from "timi-web-api"; import type { PhotoItem } from "@/types/Photo"; import { getAttachmentReadURL } from "@/utils/attachment"; import { compressUploadImageList } from "@/utils/imageCompression"; +import { saveCapturedFilesFromInput } from "@/utils/twaBridge"; export function useEventRecordPhoto() { const recordPhotoInputRef = ref(); @@ -38,6 +39,7 @@ export function useEventRecordPhoto() { if (!fileList.length) { return; } + saveCapturedFilesFromInput(input, fileList); isPhotoUploading.value = true; try { const compressedFileList = await compressUploadImageList(fileList); diff --git a/src/pages/home/QuickSignInPage.vue b/src/pages/home/QuickSignInPage.vue index 5ecdb04..37b6647 100644 --- a/src/pages/home/QuickSignInPage.vue +++ b/src/pages/home/QuickSignInPage.vue @@ -183,6 +183,7 @@ import { resolveSelectedOptionGroupList, validateEventOptionSelection } from "@/ import { permissionCodes } from "@/utils/permission"; import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord"; import { compressUploadImageList } from "@/utils/imageCompression"; +import { saveCapturedFilesFromInput } from "@/utils/twaBridge"; import { Storage, Time } from "timi-web"; import { type Attachment, AttachmentAPI } from "timi-web-api"; import EventSelector from "@/components/EventSelector.vue"; @@ -437,6 +438,7 @@ async function handleRecordPhotoSelected(event: Event): Promise { if (!fileList.length) { return; } + saveCapturedFilesFromInput(input, fileList); recordPhotoUploading.value = true; try { const compressedFileList = await compressUploadImageList(fileList); diff --git a/src/pages/product/ProductEdit.vue b/src/pages/product/ProductEdit.vue index ccb0c0b..1d5ab01 100644 --- a/src/pages/product/ProductEdit.vue +++ b/src/pages/product/ProductEdit.vue @@ -226,6 +226,7 @@ import { emitListDataChanged, listDataEventKeys } from "@/utils/listDataEvents"; import { formatProductAmount, getProductStatusText, getProductTypeText, normalizeProductQuantity, parseProductAmount } from "@/utils/product"; import { Message } from "@/utils/message"; import { permissionCodes } from "@/utils/permission"; +import { saveCapturedFilesFromInput } from "@/utils/twaBridge"; type PickerValue = string | number; @@ -647,6 +648,7 @@ async function handleAttachmentSelected(event: Event): Promise { if (!file) { return; } + saveCapturedFilesFromInput(input, [file]); isAttachmentUploading.value = true; try { const tempFile = (await AttachmentAPI.upload(await compressUploadImage(file)))[0]; diff --git a/src/pages/store/StoreDiaryEdit.vue b/src/pages/store/StoreDiaryEdit.vue index 5f293ba..0c4f01a 100644 --- a/src/pages/store/StoreDiaryEdit.vue +++ b/src/pages/store/StoreDiaryEdit.vue @@ -91,6 +91,7 @@ import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompr import { getAttachmentReadURL } from "@/utils/attachment"; import { Message } from "@/utils/message"; import { permissionCodes } from "@/utils/permission"; +import { saveCapturedFilesFromInput } from "@/utils/twaBridge"; defineOptions({ name: "StoreDiaryEdit" @@ -180,6 +181,7 @@ async function handlePhotoSelected(event: Event): Promise { if (!fileList.length) { return; } + saveCapturedFilesFromInput(input, fileList); isPhotoUploading.value = true; try { diff --git a/src/pages/tabs/StoreTab.vue b/src/pages/tabs/StoreTab.vue index 51f2cef..8095b40 100644 --- a/src/pages/tabs/StoreTab.vue +++ b/src/pages/tabs/StoreTab.vue @@ -38,7 +38,7 @@ v-if="canManageStoreDiary" class="action" title="门店日记" - note="记录门店经营和护理过程" + note="记录门店经营日常" arrow hover @click="goStoreDiaryManagement" diff --git a/src/utils/twaBridge.ts b/src/utils/twaBridge.ts new file mode 100644 index 0000000..8062d5c --- /dev/null +++ b/src/utils/twaBridge.ts @@ -0,0 +1,330 @@ +const TWA_ORIGIN = "https://gao.imyeyu.com"; +const TWA_BRIDGE_TIMEOUT = 10_000; +const MEDIA_CHUNK_SIZE = 64 * 1024; +const LOG_PREFIX = "[TWA 相册]"; + +type CapturedMediaType = "image" | "video"; + +interface TwaMessage { + type: string; + id?: string; + index?: number; + data?: string; + error?: string; + version?: number; +} + +type TwaResponseHandler = (message: TwaMessage) => void; + +let bridgeInitialized = false; +let bridgePort: MessagePort | undefined; +let bridgeWaiterList: Array<(port: MessagePort | undefined) => void> = []; +let transferQueue = Promise.resolve(); +const responseHandlerMap = new Map(); + +/** + * 初始化 TWA 通信监听。 + * + * 普通浏览器不会收到带 MessagePort 的消息,因此初始化失败时不会影响网页功能。 + */ +export function initializeTwaBridge(): void { + if (bridgeInitialized || typeof window === "undefined") { + return; + } + + bridgeInitialized = true; + window.addEventListener("message", handleWindowMessage); + logInfo("已初始化网页侧通信监听"); +} + +/** + * 将直接拍摄得到的媒体保存到 Android 系统相册。 + * + * 非 TWA 环境会直接返回,不会阻塞原有上传流程。 + */ +export function saveCapturedMediaToAlbum(file: File, mediaType: CapturedMediaType): void { + if (!file.size) { + logWarn("跳过相册保存:文件大小为 0", { + fileName: file.name, + mediaType + }); + return; + } + + initializeTwaBridge(); + logInfo("收到相册保存请求", { + fileName: file.name, + fileType: file.type, + fileSize: file.size, + mediaType + }); + transferQueue = transferQueue + .then(async () => { + await transferMedia(file, mediaType); + }) + .catch(error => { + // 相册保存是增强能力,失败时不能影响媒体上传。 + logError("相册保存失败,已忽略并继续原上传流程", error); + }); +} + +/** + * 根据文件输入框判断是否为直接拍摄,并启动相册保存。 + */ +export function saveCapturedFilesFromInput(input: HTMLInputElement, fileList: readonly File[]): void { + if (!input.capture || !fileList.length) { + return; + } + + const mediaType: CapturedMediaType = input.accept.toLowerCase().includes("video") ? "video" : "image"; + logInfo("识别为直接拍摄媒体", { + accept: input.accept, + capture: input.capture, + fileCount: fileList.length, + mediaType + }); + for (const file of fileList) { + saveCapturedMediaToAlbum(file, mediaType); + } +} + +function handleWindowMessage(event: MessageEvent): void { + const message = parseMessage(event.data); + if (message?.type !== "twa-bridge-ready") { + return; + } + if (event.origin !== TWA_ORIGIN || !event.ports.length) { + logWarn("忽略无效的 TWA 桥接就绪消息", { + origin: event.origin, + hasMessagePort: event.ports.length > 0 + }); + return; + } + + logInfo("收到 Android 侧桥接就绪消息", { + origin: event.origin, + version: message.version + }); + bridgePort = event.ports[0]; + bridgePort.onmessage = handlePortMessage; + bridgePort.start(); + bridgePort.postMessage(JSON.stringify({ + type: "twa-bridge-ready-ack", + version: 1 + })); + + const waiterList = bridgeWaiterList; + bridgeWaiterList = []; + for (const waiter of waiterList) { + waiter(bridgePort); + } +} + +function handlePortMessage(event: MessageEvent): void { + const message = parseMessage(event.data); + if (!message?.id) { + return; + } + + responseHandlerMap.get(message.id)?.(message); +} + +async function transferMedia(file: File, mediaType: CapturedMediaType): Promise { + const port = await waitForBridgePort(); + if (!port) { + logWarn("未连接到 TWA,跳过相册保存", { + fileName: file.name, + mediaType + }); + return; + } + + const id = createTransferId(); + let transferFinished = false; + logInfo("开始传输媒体", { + id, + fileName: file.name, + mediaType, + fileSize: file.size, + chunkSize: MEDIA_CHUNK_SIZE + }); + try { + await sendTransferMessage(port, id, { + type: "media-start", + id, + mediaType, + mimeType: file.type || `${mediaType}/*`, + fileName: file.name, + size: file.size + }, "media-started"); + + let offset = 0; + let index = 0; + while (offset < file.size) { + const chunk = await file.slice(offset, offset + MEDIA_CHUNK_SIZE).arrayBuffer(); + if (index === 0 || index % 16 === 0 || offset + chunk.byteLength >= file.size) { + logDebug("发送媒体分块", { + id, + index, + chunkSize: chunk.byteLength, + offset + }); + } + await sendTransferMessage(port, id, { + type: "media-chunk", + id, + index, + data: encodeBase64(chunk) + }, "media-chunk-ack", index); + offset += chunk.byteLength; + index++; + } + + await sendTransferMessage(port, id, { + type: "media-finish", + id + }, "media-saved"); + transferFinished = true; + logInfo("媒体已保存到系统相册", { + id, + fileName: file.name, + fileSize: file.size + }); + } finally { + if (!transferFinished) { + logWarn("媒体传输未完成,发送中止请求", { id }); + port.postMessage(JSON.stringify({ + type: "media-abort", + id + })); + } + } +} + +async function sendTransferMessage( + port: MessagePort, + id: string, + message: Record, + expectedType: string, + expectedIndex?: number +): Promise { + const responsePromise = waitForTransferResponse(id, expectedType, expectedIndex); + logDebug("发送桥接消息", { + id, + type: message.type, + expectedType, + expectedIndex + }); + port.postMessage(JSON.stringify(message)); + await responsePromise; +} + +function waitForBridgePort(): Promise { + if (bridgePort) { + return Promise.resolve(bridgePort); + } + + return new Promise(resolve => { + let settled = false; + const settle = (port?: MessagePort): void => { + if (settled) { + return; + } + settled = true; + resolve(port); + }; + bridgeWaiterList.push(settle); + logDebug("等待 Android 侧桥接就绪"); + window.setTimeout(() => { + bridgeWaiterList = bridgeWaiterList.filter(waiter => waiter !== settle); + logWarn("等待 TWA 桥接超时,当前页面不是已建立通道的 TWA,或 Android 来源校验未通过", { + origin: window.location.origin, + displayMode: getDisplayMode(), + userAgent: navigator.userAgent + }); + settle(); + }, TWA_BRIDGE_TIMEOUT); + }); +} + +function waitForTransferResponse(id: string, expectedType: string, expectedIndex?: number): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + responseHandlerMap.delete(id); + logError("等待桥接响应超时", new Error(`${expectedType}, id=${id}`)); + reject(new Error("TWA 媒体保存响应超时")); + }, 15_000); + + responseHandlerMap.set(id, message => { + if (message.type === "media-error") { + window.clearTimeout(timeout); + responseHandlerMap.delete(id); + logError("Android 侧返回媒体错误", new Error(message.error || "TWA 媒体保存失败")); + reject(new Error(message.error || "TWA 媒体保存失败")); + return; + } + if (message.type !== expectedType || (expectedIndex !== undefined && message.index !== expectedIndex)) { + return; + } + + window.clearTimeout(timeout); + responseHandlerMap.delete(id); + resolve(); + }); + }); +} + +function logInfo(message: string, detail?: unknown): void { + console.warn(LOG_PREFIX, message, detail ?? ""); +} + +function logDebug(message: string, detail?: unknown): void { + console.warn(LOG_PREFIX, message, detail ?? ""); +} + +function logWarn(message: string, detail?: unknown): void { + console.warn(LOG_PREFIX, message, detail ?? ""); +} + +function logError(message: string, error: unknown): void { + console.error(LOG_PREFIX, message, error); +} + +function parseMessage(value: unknown): TwaMessage | undefined { + if (typeof value !== "string") { + return undefined; + } + + try { + const message = JSON.parse(value) as TwaMessage; + return typeof message.type === "string" ? message : undefined; + } catch { + return undefined; + } +} + +function encodeBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + } + return window.btoa(binary); +} + +function createTransferId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function getDisplayMode(): string { + if (window.matchMedia("(display-mode: standalone)").matches) { + return "standalone"; + } + if (window.matchMedia("(display-mode: fullscreen)").matches) { + return "fullscreen"; + } + return "browser"; +} diff --git a/twa/README.md b/twa/README.md index 74c54f7..85c67e6 100644 --- a/twa/README.md +++ b/twa/README.md @@ -32,6 +32,12 @@ pnpm --config.registry=https://registry.npmjs.org dlx @bubblewrap/cli@1.25.0 bui 当前 `assetlinks.json` 中登记的是本机调试证书指纹,只能配合本次验证 APK 使用。正式发布前必须替换为 release 证书指纹;如果使用 Google Play,还要把 Play App Signing 证书指纹一并登记。 +## TWA 媒体桥接 + +TWA 通过 `postMessage` 接收网页直接拍摄的照片和视频,Android 10 及以上使用 `MediaStore` 分块写入系统相册,照片保存到“图片/糕店管理”,视频保存到“视频/糕店管理”。普通浏览器和从相册选择的文件不会触发该桥接。 + +桥接依赖 `assetlinks.json` 中的 `delegate_permission/common.use_as_origin` 关系。发布 APK 前必须使用实际 release 签名证书的 SHA-256 指纹更新该文件,否则来源验证失败,桥接不会启用。 + ## 前端发布顺序 1. 先上传新版本 `dist/assets`。 diff --git a/twa/app/src/main/AndroidManifest.xml b/twa/app/src/main/AndroidManifest.xml index 7b28ccb..447d2f5 100644 --- a/twa/app/src/main/AndroidManifest.xml +++ b/twa/app/src/main/AndroidManifest.xml @@ -172,6 +172,10 @@ android:resource="@xml/filepaths" /> + + = MAX_CHANNEL_ATTEMPTS || postMessageChannelReady) { + return; + } + mainHandler.postDelayed(() -> requestPostMessageChannel(attempt), CHANNEL_RETRY_DELAY_MILLIS); + } + + private void requestPostMessageChannel(int attempt) { + if (isFinishing() || postMessageChannelReady) { + return; + } + + CustomTabsSession session = getCustomTabsSession(); + if (session == null) { + Log.w(TAG, "请求 PostMessage 通道失败:当前没有可用的 CustomTabsSession,attempt=" + attempt); + scheduleChannelRequest(attempt + 1); + return; + } + postMessageSession = session; + + if (!originValidationRequested) { + originValidationRequested = true; + Log.i(TAG, "开始验证 TWA 来源关系:" + WEB_ORIGIN); + session.validateRelationship(CustomTabsService.RELATION_USE_AS_ORIGIN, WEB_ORIGIN, null); + return; + } + if (!originValidated || postMessageChannelRequested) { + return; + } + + postMessageChannelRequested = true; + boolean requested = session.requestPostMessageChannel(WEB_ORIGIN, WEB_ORIGIN, new Bundle()); + Log.i(TAG, "请求 PostMessage 通道,结果=" + requested); + if (!requested) { + postMessageChannelRequested = false; + scheduleChannelRequest(attempt + 1); + } + } + + /** + * 当前 Bubblewrap 生成的 Android Browser Helper 版本没有公开 TWA 会话访问器, + * 这里按字段类型读取会话,不依赖混淆后的私有字段名称。 + */ + @Nullable + private CustomTabsSession getCustomTabsSession() { + try { + Object twaLauncher = findFieldValue(this, TwaLauncher.class); + if (twaLauncher == null) { + Log.w(TAG, "未找到 TwaLauncher 实例"); + return null; + } + CustomTabsSession session = (CustomTabsSession) findFieldValue(twaLauncher, CustomTabsSession.class); + if (session == null) { + Log.w(TAG, "未找到 CustomTabsSession 实例"); + } + return session; + } catch (ReflectiveOperationException | RuntimeException exception) { + Log.w(TAG, "读取 TWA 会话失败", exception); + return null; + } + } + + @Nullable + private static Object findFieldValue(Object target, Class fieldType) throws IllegalAccessException { + Class currentClass = target.getClass(); + while (currentClass != null) { + for (Field field : currentClass.getDeclaredFields()) { + if (!fieldType.isAssignableFrom(field.getType())) { + continue; + } + field.setAccessible(true); + return field.get(target); + } + currentClass = currentClass.getSuperclass(); + } + return null; + } + + private void handleBridgeMessage(String message) { + if (mediaStoreBridge == null || !postMessageChannelReady) { + Log.w(TAG, "忽略媒体消息:桥接尚未就绪"); + return; + } + + try { + JSONObject payload = new JSONObject(message); + String type = payload.optString("type"); + String id = payload.optString("id"); + Log.d(TAG, "收到网页桥接消息,type=" + type + ", id=" + id); + if ("twa-bridge-ready-ack".equals(type)) { + Log.i(TAG, "网页已确认 TWA 桥接就绪"); + return; + } + if (id.length() == 0) { + return; + } + + if ("media-start".equals(type)) { + String error = mediaStoreBridge.start( + id, + payload.optString("mimeType"), + payload.optString("fileName"), + payload.optLong("size", -1L) + ); + Log.i(TAG, "开始保存媒体,id=" + id + ", mimeType=" + payload.optString("mimeType") + + ", fileName=" + payload.optString("fileName") + ", size=" + payload.optLong("size", -1L) + + ", result=" + (error == null ? "success" : error)); + postBridgeResponse(error == null ? "media-started" : "media-error", id, null, error); + return; + } + if ("media-chunk".equals(type)) { + int index = payload.optInt("index", -1); + if (index < 0) { + mediaStoreBridge.abort(id); + postBridgeResponse("media-error", id, null, "媒体分块编号无效"); + return; + } + byte[] chunk = Base64.decode(payload.optString("data"), Base64.NO_WRAP); + String error = mediaStoreBridge.append(id, index, chunk); + if (error != null || index == 0 || index % 16 == 0) { + Log.d(TAG, "写入媒体分块,id=" + id + ", index=" + index + ", bytes=" + chunk.length + + ", result=" + (error == null ? "success" : error)); + } + postBridgeResponse(error == null ? "media-chunk-ack" : "media-error", id, index, error); + return; + } + if ("media-finish".equals(type)) { + String error = mediaStoreBridge.finish(id); + Log.i(TAG, "完成保存媒体,id=" + id + ", result=" + (error == null ? "success" : error)); + postBridgeResponse(error == null ? "media-saved" : "media-error", id, null, error); + return; + } + if ("media-abort".equals(type)) { + Log.w(TAG, "网页主动中止媒体保存,id=" + id); + mediaStoreBridge.abort(id); + } + } catch (Exception exception) { + Log.e(TAG, "解析 TWA 媒体消息失败,原始消息长度=" + message.length(), exception); + } + } + + private void postBridgeResponse(String type, String id, @Nullable Integer index, @Nullable String error) { + JSONObject response = new JSONObject(); + try { + response.put("type", type); + response.put("id", id); + if (index != null) { + response.put("index", index); + } + if (error != null) { + response.put("error", error); + } + } catch (Exception exception) { + return; + } + + mainHandler.post(() -> { + if (!postMessageChannelReady || postMessageSession == null) { + return; + } + int result = postMessageSession.postMessage(response.toString(), null); + Log.d(TAG, "发送桥接响应,type=" + type + ", id=" + id + ", result=" + result); + }); + } + + private final class BridgeCallback extends QualityEnforcer { + @Override + public void onNavigationEvent(int navigationEvent, @Nullable Bundle extras) { + super.onNavigationEvent(navigationEvent, extras); + Log.d(TAG, "TWA 导航事件=" + navigationEvent); + if (navigationEvent == NAVIGATION_FINISHED) { + scheduleChannelRequest(0); + } + } + + @Override + public void onRelationshipValidationResult( + int relation, + @Nullable Uri requestedOrigin, + boolean result, + @Nullable Bundle extras + ) { + super.onRelationshipValidationResult(relation, requestedOrigin, result, extras); + if (relation != CustomTabsService.RELATION_USE_AS_ORIGIN || !WEB_ORIGIN.equals(requestedOrigin)) { + return; + } + originValidated = result; + Log.i(TAG, "TWA 来源验证结果=" + result + ", origin=" + requestedOrigin); + if (result) { + scheduleChannelRequest(0); + } else { + Log.w(TAG, "TWA 来源验证失败,请检查 assetlinks.json"); + } + } + + @Override + public void onMessageChannelReady(@Nullable Bundle extras) { + super.onMessageChannelReady(extras); + postMessageChannelReady = true; + Log.i(TAG, "PostMessage 通道已就绪"); + postMessageSession = getCustomTabsSession(); + if (postMessageSession == null) { + Log.e(TAG, "PostMessage 通道已就绪,但无法获取 CustomTabsSession"); + return; + } + JSONObject readyMessage = new JSONObject(); + try { + readyMessage.put("type", "twa-bridge-ready"); + readyMessage.put("version", 1); + } catch (Exception exception) { + return; + } + int result = postMessageSession.postMessage(readyMessage.toString(), null); + Log.i(TAG, "发送 TWA 桥接就绪消息,result=" + result); + } + + @Override + public void onPostMessage(String message, @Nullable Bundle extras) { + super.onPostMessage(message, extras); + handleBridgeMessage(message); + } + } } diff --git a/twa/app/src/main/java/com/imyeyu/gao/customer/TwaMediaStoreBridge.java b/twa/app/src/main/java/com/imyeyu/gao/customer/TwaMediaStoreBridge.java new file mode 100644 index 0000000..c8682ab --- /dev/null +++ b/twa/app/src/main/java/com/imyeyu/gao/customer/TwaMediaStoreBridge.java @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 imyeyu.com + * + * MIT License + */ +package com.imyeyu.gao.customer; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * 将 TWA 传来的媒体分块写入 Android 系统相册。 + */ +final class TwaMediaStoreBridge { + private static final String TAG = "GaoTwaMediaStore"; + private static final long MAX_MEDIA_SIZE = 512L * 1024L * 1024L; + private static final int BUFFER_SIZE = 64 * 1024; + + private final ContentResolver contentResolver; + private final Map pendingMediaMap = new HashMap<>(); + + TwaMediaStoreBridge(Context context) { + contentResolver = context.getContentResolver(); + } + + synchronized String start( + String id, + String mimeType, + String fileName, + long expectedSize + ) { + Log.i(TAG, "准备创建相册媒体,id=" + id + ", mimeType=" + mimeType + ", fileName=" + fileName + + ", expectedSize=" + expectedSize + ", sdk=" + Build.VERSION.SDK_INT); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Log.w(TAG, "Android 版本低于 10,无法无权限写入相册"); + return "Android 10 以下系统不支持无权限写入系统相册"; + } + if (id.length() == 0 || id.length() > 128) { + return "媒体标识无效"; + } + if (pendingMediaMap.containsKey(id)) { + return "媒体传输已存在"; + } + if (expectedSize <= 0 || expectedSize > MAX_MEDIA_SIZE) { + return "媒体文件大小超出限制"; + } + + String normalizedMimeType = normalizeMimeType(mimeType); + if (normalizedMimeType == null) { + return "只支持照片和视频"; + } + + Uri mediaUri = null; + try { + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, buildDisplayName(fileName, normalizedMimeType)); + values.put(MediaStore.MediaColumns.MIME_TYPE, normalizedMimeType); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, buildRelativePath(normalizedMimeType)); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + + mediaUri = contentResolver.insert(getCollectionUri(normalizedMimeType), values); + if (mediaUri == null) { + Log.e(TAG, "MediaStore insert 返回空 Uri,id=" + id); + return "创建相册媒体失败"; + } + + OutputStream outputStream = contentResolver.openOutputStream(mediaUri); + if (outputStream == null) { + deleteQuietly(mediaUri); + Log.e(TAG, "无法打开 MediaStore 输出流,id=" + id + ", uri=" + mediaUri); + return "打开相册媒体失败"; + } + pendingMediaMap.put(id, new PendingMedia(mediaUri, outputStream, expectedSize)); + Log.i(TAG, "相册媒体创建成功,id=" + id + ", uri=" + mediaUri + ", path=" + + buildRelativePath(normalizedMimeType)); + return null; + } catch (Exception exception) { + if (mediaUri != null) { + deleteQuietly(mediaUri); + } + Log.e(TAG, "创建相册媒体异常,id=" + id, exception); + return "创建相册媒体失败"; + } + } + + synchronized String append(String id, int index, byte[] chunk) { + PendingMedia pendingMedia = pendingMediaMap.get(id); + if (pendingMedia == null) { + return "媒体传输不存在"; + } + if (index != pendingMedia.nextChunkIndex) { + abort(id); + return "媒体分块顺序错误"; + } + if (chunk.length == 0 || pendingMedia.receivedSize + chunk.length > pendingMedia.expectedSize) { + abort(id); + return "媒体分块大小错误"; + } + + try { + pendingMedia.outputStream.write(chunk); + pendingMedia.outputStream.flush(); + pendingMedia.receivedSize += chunk.length; + pendingMedia.nextChunkIndex++; + return null; + } catch (IOException exception) { + abort(id); + Log.e(TAG, "写入相册媒体异常,id=" + id + ", index=" + index, exception); + return "写入相册媒体失败"; + } + } + + synchronized String finish(String id) { + PendingMedia pendingMedia = pendingMediaMap.remove(id); + if (pendingMedia == null) { + return "媒体传输不存在"; + } + if (pendingMedia.receivedSize != pendingMedia.expectedSize) { + closeQuietly(pendingMedia.outputStream); + deleteQuietly(pendingMedia.mediaUri); + Log.e(TAG, "媒体内容不完整,id=" + id + ", received=" + pendingMedia.receivedSize + + ", expected=" + pendingMedia.expectedSize); + return "媒体内容不完整"; + } + + try { + pendingMedia.outputStream.flush(); + pendingMedia.outputStream.close(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.IS_PENDING, 0); + if (contentResolver.update(pendingMedia.mediaUri, values, null, null) != 1) { + deleteQuietly(pendingMedia.mediaUri); + Log.e(TAG, "发布相册媒体失败,MediaStore update 返回 0,id=" + id); + return "发布相册媒体失败"; + } + Log.i(TAG, "相册媒体发布成功,id=" + id + ", uri=" + pendingMedia.mediaUri + + ", bytes=" + pendingMedia.receivedSize); + return null; + } catch (Exception exception) { + closeQuietly(pendingMedia.outputStream); + deleteQuietly(pendingMedia.mediaUri); + Log.e(TAG, "保存相册媒体异常,id=" + id, exception); + return "保存相册媒体失败"; + } + } + + synchronized void abort(String id) { + PendingMedia pendingMedia = pendingMediaMap.remove(id); + if (pendingMedia == null) { + return; + } + closeQuietly(pendingMedia.outputStream); + deleteQuietly(pendingMedia.mediaUri); + Log.w(TAG, "已清理中止的相册媒体,id=" + id + ", uri=" + pendingMedia.mediaUri); + } + + synchronized void abortAll() { + for (PendingMedia pendingMedia : pendingMediaMap.values()) { + closeQuietly(pendingMedia.outputStream); + deleteQuietly(pendingMedia.mediaUri); + } + pendingMediaMap.clear(); + Log.i(TAG, "已清理全部未完成的相册媒体"); + } + + @Nullable + private static String normalizeMimeType(String mimeType) { + String normalizedMimeType = mimeType == null ? "" : mimeType.trim().toLowerCase(Locale.ROOT); + if (normalizedMimeType.startsWith("image/")) { + return normalizedMimeType.equals("image/*") ? "image/jpeg" : normalizedMimeType; + } + if (normalizedMimeType.startsWith("video/")) { + return normalizedMimeType.equals("video/*") ? "video/mp4" : normalizedMimeType; + } + return null; + } + + private static Uri getCollectionUri(String mimeType) { + if (mimeType.startsWith("video/")) { + return MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + } + return MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + } + + private static String buildRelativePath(String mimeType) { + if (mimeType.startsWith("video/")) { + return Environment.DIRECTORY_MOVIES + "/糕店管理"; + } + return Environment.DIRECTORY_PICTURES + "/糕店管理"; + } + + private static String buildDisplayName(String fileName, String mimeType) { + String safeFileName = fileName == null ? "" : fileName + .replaceAll("[^A-Za-z0-9._-]", "_") + .replaceAll("^\\.+$", ""); + if (safeFileName.length() == 0) { + safeFileName = "capture-" + System.currentTimeMillis(); + } + if (safeFileName.lastIndexOf('.') < 0) { + safeFileName += mimeType.startsWith("video/") ? ".mp4" : ".jpg"; + } + return safeFileName; + } + + private void deleteQuietly(Uri uri) { + try { + contentResolver.delete(uri, null, null); + } catch (Exception ignored) { + // 相册清理失败时不能覆盖原始错误。 + } + } + + private static void closeQuietly(OutputStream outputStream) { + try { + outputStream.close(); + } catch (IOException ignored) { + // 关闭失败时继续清理媒体记录。 + } + } + + private static final class PendingMedia { + private final Uri mediaUri; + private final OutputStream outputStream; + private final long expectedSize; + private long receivedSize; + private int nextChunkIndex; + + private PendingMedia(Uri mediaUri, OutputStream outputStream, long expectedSize) { + this.mediaUri = mediaUri; + this.outputStream = new BufferedOutputStream(outputStream, BUFFER_SIZE); + this.expectedSize = expectedSize; + } + } +}