接入移动壳文本文件导出能力
Expo 移动壳通过文件系统写入缓存文本并调用系统分享保存面板 补充移动壳导出能力依赖、配置守卫和 HostBridge 单测 更新宿主壳能力协议、方案文档和共享决策记录
This commit is contained in:
@@ -15,8 +15,10 @@
|
||||
"@expo/metro-runtime": "^56.0.15",
|
||||
"expo": "^56.0.12",
|
||||
"expo-clipboard": "^56.0.4",
|
||||
"expo-file-system": "^56.0.8",
|
||||
"expo-haptics": "^56.0.3",
|
||||
"expo-linking": "^56.0.14",
|
||||
"expo-sharing": "^56.0.18",
|
||||
"expo-status-bar": "^56.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-native": "^0.86.0",
|
||||
|
||||
@@ -6,6 +6,10 @@ const appConfigPath = new URL('../app.json', import.meta.url);
|
||||
const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo;
|
||||
const appPath = new URL('../App.tsx', import.meta.url);
|
||||
const appSource = fs.readFileSync(appPath, 'utf8');
|
||||
const bridgePath = new URL('../src/mobileHostBridge.ts', import.meta.url);
|
||||
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
|
||||
const packagePath = new URL('../package.json', import.meta.url);
|
||||
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
const iconPath = new URL('../assets/icon.png', import.meta.url);
|
||||
const icon = PNG.sync.read(fs.readFileSync(iconPath));
|
||||
|
||||
@@ -48,3 +52,19 @@ for (const snippet of [
|
||||
throw new Error(`mobile shell App missing ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependency of ['expo-file-system', 'expo-sharing']) {
|
||||
if (!packageConfig.dependencies?.[dependency]) {
|
||||
throw new Error(`mobile shell package missing ${dependency}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'file.exportText',
|
||||
'Sharing.shareAsync',
|
||||
'normalizeHostBridgeExportFileName',
|
||||
]) {
|
||||
if (!bridgeSource.includes(snippet)) {
|
||||
throw new Error(`mobile shell HostBridge missing ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { Share } from 'react-native';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -19,6 +20,30 @@ vi.mock('expo-clipboard', () => ({
|
||||
setStringAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const writtenFiles = vi.hoisted(
|
||||
() => [] as { uri: string; content: string }[],
|
||||
);
|
||||
|
||||
vi.mock('expo-file-system', () => ({
|
||||
Paths: {
|
||||
cache: 'file:///cache/',
|
||||
},
|
||||
File: class MockFile {
|
||||
uri: string;
|
||||
|
||||
constructor(_base: string, fileName: string) {
|
||||
this.uri = `file:///cache/${fileName}`;
|
||||
}
|
||||
|
||||
write(content: string) {
|
||||
writtenFiles.push({
|
||||
uri: this.uri,
|
||||
content,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('expo-haptics', () => ({
|
||||
ImpactFeedbackStyle: {
|
||||
Heavy: 'heavy',
|
||||
@@ -32,6 +57,11 @@ vi.mock('expo-linking', () => ({
|
||||
openURL: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('expo-sharing', () => ({
|
||||
isAvailableAsync: vi.fn(async () => true),
|
||||
shareAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: {
|
||||
OS: 'ios',
|
||||
@@ -87,7 +117,11 @@ function expectFailed(response: HostBridgeResponse) {
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(Linking.openURL).mockReset();
|
||||
vi.mocked(Sharing.isAvailableAsync).mockReset();
|
||||
vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true);
|
||||
vi.mocked(Sharing.shareAsync).mockReset();
|
||||
vi.mocked(Share.share).mockReset();
|
||||
writtenFiles.length = 0;
|
||||
resetMobileHostBridgeForTest();
|
||||
});
|
||||
|
||||
@@ -104,6 +138,9 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(
|
||||
(okResponse.result as { capabilities: string[] }).capabilities,
|
||||
).toContain('navigation.openNativePage');
|
||||
expect(
|
||||
(okResponse.result as { capabilities: string[] }).capabilities,
|
||||
).toContain('file.exportText');
|
||||
expect(
|
||||
(okResponse.result as { capabilities: string[] }).capabilities,
|
||||
).toEqual(
|
||||
@@ -237,7 +274,41 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(Share.share).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('移动壳未接入真实导出能力时明确返回 unsupported', async () => {
|
||||
test('file.exportText 写入缓存文件并调起系统分享', async () => {
|
||||
const response = await send(
|
||||
request('file.exportText', {
|
||||
fileName: ' ../作品:记录?.txt ',
|
||||
content: '暖灯猫街',
|
||||
mimeType: 'text/markdown',
|
||||
}),
|
||||
);
|
||||
|
||||
const okResponse = expectOk(response);
|
||||
|
||||
expect(okResponse.result).toEqual({
|
||||
action: 'saved',
|
||||
fileName: '作品-记录-.txt',
|
||||
bytes: 12,
|
||||
});
|
||||
expect(writtenFiles).toEqual([
|
||||
{
|
||||
uri: 'file:///cache/作品-记录-.txt',
|
||||
content: '暖灯猫街',
|
||||
},
|
||||
]);
|
||||
expect(Sharing.shareAsync).toHaveBeenCalledWith(
|
||||
'file:///cache/作品-记录-.txt',
|
||||
{
|
||||
mimeType: 'text/markdown',
|
||||
UTI: 'public.plain-text',
|
||||
dialogTitle: '作品-记录-.txt',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('file.exportText 在系统分享不可用时明确返回 unsupported capability', async () => {
|
||||
vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(false);
|
||||
|
||||
const response = await send(
|
||||
request('file.exportText', {
|
||||
fileName: '作品记录.txt',
|
||||
@@ -247,6 +318,23 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
|
||||
const failedResponse = expectFailed(response);
|
||||
|
||||
expect(failedResponse.error.code).toBe('unsupported_method');
|
||||
expect(failedResponse.error.code).toBe('unsupported_capability');
|
||||
expect(writtenFiles).toEqual([]);
|
||||
expect(Sharing.shareAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('file.exportText 拒绝超出上限的文本内容', async () => {
|
||||
const response = await send(
|
||||
request('file.exportText', {
|
||||
fileName: '作品记录.txt',
|
||||
content: 'a'.repeat(5 * 1024 * 1024 + 1),
|
||||
}),
|
||||
);
|
||||
|
||||
const failedResponse = expectFailed(response);
|
||||
|
||||
expect(failedResponse.error.code).toBe('invalid_request');
|
||||
expect(writtenFiles).toEqual([]);
|
||||
expect(Sharing.shareAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { Platform, Share } from 'react-native';
|
||||
|
||||
import {
|
||||
type ClipboardWriteTextPayload,
|
||||
type FileExportTextPayload,
|
||||
type FileExportTextResult,
|
||||
type HapticsImpactPayload,
|
||||
HOST_BRIDGE_PROTOCOL,
|
||||
HOST_BRIDGE_VERSION,
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
type HostBridgeRequest,
|
||||
type HostBridgeResponse,
|
||||
type NavigateNativePagePayload,
|
||||
normalizeHostBridgeExportFileName,
|
||||
normalizeHostBridgeExternalUrl,
|
||||
type OpenExternalUrlPayload,
|
||||
type ShareOpenPayload,
|
||||
@@ -21,6 +26,7 @@ import {
|
||||
import { resolveMobileShellWebViewUrl } from './mobileShellNavigation';
|
||||
|
||||
const WEB_APP_ORIGIN = 'https://app.genarrative.world';
|
||||
const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'host.getRuntime',
|
||||
@@ -31,6 +37,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'navigation.canGoBack',
|
||||
'app.openExternalUrl',
|
||||
'clipboard.writeText',
|
||||
'file.exportText',
|
||||
'haptics.impact',
|
||||
];
|
||||
|
||||
@@ -62,6 +69,23 @@ function invalidRequest(message: string): HostBridgeError {
|
||||
};
|
||||
}
|
||||
|
||||
function utf8ByteLength(value: string) {
|
||||
let bytes = 0;
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (codePoint <= 0x7f) {
|
||||
bytes += 1;
|
||||
} else if (codePoint <= 0x7ff) {
|
||||
bytes += 2;
|
||||
} else if (codePoint <= 0xffff) {
|
||||
bytes += 3;
|
||||
} else {
|
||||
bytes += 4;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isHostBridgeRequest(value: unknown): value is HostBridgeRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
@@ -132,6 +156,43 @@ async function writeClipboard(payload: unknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function exportTextFile(payload: unknown): Promise<FileExportTextResult> {
|
||||
const exportPayload = payload as FileExportTextPayload | undefined;
|
||||
const content = exportPayload?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw invalidRequest('content is required');
|
||||
}
|
||||
|
||||
const bytes = utf8ByteLength(content);
|
||||
if (bytes > EXPORT_TEXT_MAX_BYTES) {
|
||||
throw invalidRequest('content exceeds file export size limit');
|
||||
}
|
||||
|
||||
const isSharingAvailable = await Sharing.isAvailableAsync();
|
||||
if (!isSharingAvailable) {
|
||||
throw {
|
||||
code: 'unsupported_capability',
|
||||
message: 'file sharing is unavailable in mobile shell',
|
||||
} satisfies HostBridgeError;
|
||||
}
|
||||
|
||||
const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName);
|
||||
const mimeType = exportPayload?.mimeType || 'text/plain';
|
||||
const file = new File(Paths.cache, fileName);
|
||||
file.write(content);
|
||||
await Sharing.shareAsync(file.uri, {
|
||||
mimeType,
|
||||
UTI: 'public.plain-text',
|
||||
dialogTitle: fileName,
|
||||
});
|
||||
|
||||
return {
|
||||
action: 'saved',
|
||||
fileName,
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
async function runHaptics(payload: unknown) {
|
||||
const style = (payload as HapticsImpactPayload | undefined)?.style;
|
||||
const impactStyle =
|
||||
@@ -259,6 +320,8 @@ async function handleRequest(request: HostBridgeRequest) {
|
||||
return ok(request, await openExternalUrl(request.payload));
|
||||
case 'clipboard.writeText':
|
||||
return ok(request, await writeClipboard(request.payload));
|
||||
case 'file.exportText':
|
||||
return ok(request, await exportTextFile(request.payload));
|
||||
case 'haptics.impact':
|
||||
return ok(request, await runHaptics(request.payload));
|
||||
case 'share.open':
|
||||
@@ -273,7 +336,6 @@ async function handleRequest(request: HostBridgeRequest) {
|
||||
return ok(request, openNativePage(request.payload));
|
||||
case 'auth.requestLogin':
|
||||
case 'payment.request':
|
||||
case 'file.exportText':
|
||||
return failure(request, unsupported(request.method));
|
||||
default:
|
||||
return failure(request, unsupported(request.method));
|
||||
|
||||
Reference in New Issue
Block a user