save image for TWA

This commit is contained in:
Timi
2026-09-22 15:41:12 +08:00
parent ac4d65808a
commit 5465fb7b27
15 changed files with 880 additions and 3 deletions
+5 -2
View File
@@ -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"
]
}
}
@@ -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<void> {
if (!fileList.length) {
return;
}
saveCapturedFilesFromInput(input, fileList);
isPhotoUploading.value = true;
try {
@@ -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<void> {
if (!fileList.length || !props.upload) {
return;
}
saveCapturedFilesFromInput(input, fileList);
isUploading.value = true;
try {
+3
View File
@@ -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();
+2
View File
@@ -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<void> {
if (!file) {
return;
}
saveCapturedFilesFromInput(input, [file]);
isPhotoUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
@@ -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<HTMLInputElement>();
@@ -38,6 +39,7 @@ export function useEventRecordPhoto() {
if (!fileList.length) {
return;
}
saveCapturedFilesFromInput(input, fileList);
isPhotoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
+2
View File
@@ -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<void> {
if (!fileList.length) {
return;
}
saveCapturedFilesFromInput(input, fileList);
recordPhotoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
+2
View File
@@ -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<void> {
if (!file) {
return;
}
saveCapturedFilesFromInput(input, [file]);
isAttachmentUploading.value = true;
try {
const tempFile = (await AttachmentAPI.upload(await compressUploadImage(file)))[0];
+2
View File
@@ -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<void> {
if (!fileList.length) {
return;
}
saveCapturedFilesFromInput(input, fileList);
isPhotoUploading.value = true;
try {
+1 -1
View File
@@ -38,7 +38,7 @@
v-if="canManageStoreDiary"
class="action"
title="门店日记"
note="记录门店经营和护理过程"
note="记录门店经营日常"
arrow
hover
@click="goStoreDiaryManagement"
+330
View File
@@ -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<string, TwaResponseHandler>();
/**
* 初始化 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<void> {
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<string, unknown>,
expectedType: string,
expectedIndex?: number
): Promise<void> {
const responsePromise = waitForTransferResponse(id, expectedType, expectedIndex);
logDebug("发送桥接消息", {
id,
type: message.type,
expectedType,
expectedIndex
});
port.postMessage(JSON.stringify(message));
await responsePromise;
}
function waitForBridgePort(): Promise<MessagePort | undefined> {
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<void> {
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";
}
+6
View File
@@ -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`。
+4
View File
@@ -172,6 +172,10 @@
android:resource="@xml/filepaths" />
</provider>
<service
android:name="androidx.browser.customtabs.PostMessageService"
android:exported="true" />
<service
android:name=".DelegationService"
android:enabled="@bool/enableNotification"
@@ -19,11 +19,39 @@ import android.content.pm.ActivityInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import org.json.JSONObject;
import java.lang.reflect.Field;
import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabsCallback;
import androidx.browser.customtabs.CustomTabsService;
import androidx.browser.customtabs.CustomTabsSession;
import com.google.androidbrowserhelper.trusted.QualityEnforcer;
import com.google.androidbrowserhelper.trusted.TwaLauncher;
public class LauncherActivity
extends com.google.androidbrowserhelper.trusted.LauncherActivity {
private static final String TAG = "GaoTwaBridge";
private static final Uri WEB_ORIGIN = Uri.parse("https://gao.imyeyu.com");
private static final long CHANNEL_RETRY_DELAY_MILLIS = 500L;
private static final int MAX_CHANNEL_ATTEMPTS = 20;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final CustomTabsCallback bridgeCallback = new BridgeCallback();
private TwaMediaStoreBridge mediaStoreBridge;
private CustomTabsSession postMessageSession;
private boolean originValidationRequested;
private boolean originValidated;
private boolean postMessageChannelRequested;
private boolean postMessageChannelReady;
@@ -31,6 +59,8 @@ public class LauncherActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mediaStoreBridge = new TwaMediaStoreBridge(this);
Log.i(TAG, "TWA Activity 已创建,开始初始化媒体相册桥接");
// Setting an orientation crashes the app due to the transparent background on Android 8.0
// Oreo and below. We only set the orientation on Oreo and above. This only affects the
// splash screen and Chrome will still respect the orientation.
@@ -51,4 +81,242 @@ public class LauncherActivity
return uri;
}
@Override
protected CustomTabsCallback getCustomTabsCallback() {
return bridgeCallback;
}
@Override
protected void onDestroy() {
Log.i(TAG, "TWA Activity 销毁,清理未完成的媒体传输");
if (mediaStoreBridge != null) {
mediaStoreBridge.abortAll();
}
mainHandler.removeCallbacksAndMessages(null);
super.onDestroy();
}
private void scheduleChannelRequest(int attempt) {
if (attempt >= 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);
}
}
}
@@ -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<String, PendingMedia> 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;
}
}
}