补齐移动壳通知角标失败观测
移动壳通知和角标记录原生异常日志 通知角标测试覆盖稳定错误和日志边界 配置门禁和文档同步通知角标观测约束
This commit is contained in:
@@ -2036,6 +2036,11 @@ for (const notificationSnippet of [
|
||||
"invalidRequest('title is required')",
|
||||
"message: 'notification permission unavailable'",
|
||||
"message: 'notification delivery unavailable'",
|
||||
'logMobileNotificationFailure',
|
||||
'mobile notification failed for',
|
||||
"logMobileNotificationFailure('permission.current', error)",
|
||||
"logMobileNotificationFailure('permission.request', error)",
|
||||
"logMobileNotificationFailure('delivery.schedule', error)",
|
||||
'showMobileLocalNotification(notification)',
|
||||
'ok(request, await showMobileLocalNotification(notification))',
|
||||
'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT',
|
||||
@@ -2052,6 +2057,9 @@ for (const snippet of [
|
||||
'maps native notification schedule failures to stable delivery errors',
|
||||
"message: 'notification permission unavailable'",
|
||||
"message: 'notification delivery unavailable'",
|
||||
'mobile notification failed for permission.current',
|
||||
'mobile notification failed for permission.request',
|
||||
'mobile notification failed for delivery.schedule',
|
||||
'expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled()',
|
||||
'expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()',
|
||||
]) {
|
||||
@@ -2774,6 +2782,13 @@ for (const snippet of [
|
||||
'app badge permission denied',
|
||||
'app badge update unavailable',
|
||||
'app badge count is only supported on iOS mobile shell',
|
||||
'logMobileBadgeFailure',
|
||||
'mobile app badge failed for',
|
||||
"logMobileBadgeFailure('permission.current', error)",
|
||||
"logMobileBadgeFailure('permission.request', error)",
|
||||
"logMobileBadgeFailure('update.set_count', error)",
|
||||
"logMobileBadgeFailure(",
|
||||
"'update.rejected'",
|
||||
]) {
|
||||
if (!badgeSource.includes(snippet)) {
|
||||
throw new Error(`mobile shell badge module is missing ${snippet}`);
|
||||
@@ -2799,6 +2814,12 @@ for (const snippet of [
|
||||
'rejects denied iOS badge permission before touching the system badge',
|
||||
'maps native badge update false results to a stable HostBridge error',
|
||||
'maps native badge update rejections to a stable HostBridge error',
|
||||
'maps current badge permission lookup failures to a stable HostBridge error',
|
||||
'maps badge permission request failures to a stable HostBridge error',
|
||||
'mobile app badge failed for permission.current',
|
||||
'mobile app badge failed for permission.request',
|
||||
'mobile app badge failed for update.set_count',
|
||||
'mobile app badge failed for update.rejected',
|
||||
'rejects invalid badge counts before touching the system badge',
|
||||
'rejects missing badge payload before touching the system badge',
|
||||
'returns unsupported on Android before validating payload or touching badge APIs',
|
||||
|
||||
@@ -157,7 +157,60 @@ describe('mobile app badge helper', () => {
|
||||
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('maps current badge permission lookup failures to a stable HostBridge error', async () => {
|
||||
const error = new Error('native badge permission failed');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
setMobileAppBadgeCount(
|
||||
request({
|
||||
count: 1,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'host_error',
|
||||
message: 'app badge permission unavailable',
|
||||
});
|
||||
|
||||
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
|
||||
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile app badge failed for permission.current',
|
||||
error,
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('maps badge permission request failures to a stable HostBridge error', async () => {
|
||||
const error = new Error('native badge permission request failed');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
||||
BADGE_DENIED_PERMISSION,
|
||||
);
|
||||
vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
setMobileAppBadgeCount(
|
||||
request({
|
||||
count: 1,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'host_error',
|
||||
message: 'app badge permission unavailable',
|
||||
});
|
||||
|
||||
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile app badge failed for permission.request',
|
||||
error,
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('maps native badge update false results to a stable HostBridge error', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
@@ -170,12 +223,17 @@ describe('mobile app badge helper', () => {
|
||||
code: 'host_error',
|
||||
message: 'app badge update unavailable',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile app badge failed for update.rejected',
|
||||
expect.any(Error),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('maps native badge update rejections to a stable HostBridge error', async () => {
|
||||
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue(
|
||||
new Error('native badge failed'),
|
||||
);
|
||||
const error = new Error('native badge failed');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
setMobileAppBadgeCount(
|
||||
@@ -187,6 +245,11 @@ describe('mobile app badge helper', () => {
|
||||
code: 'host_error',
|
||||
message: 'app badge update unavailable',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile app badge failed for update.set_count',
|
||||
error,
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('rejects invalid badge counts before touching the system badge', async () => {
|
||||
|
||||
@@ -18,11 +18,16 @@ function hasBadgePermission(permission: NotificationPermissionStatus) {
|
||||
return permission.ios?.allowsBadge === true;
|
||||
}
|
||||
|
||||
function logMobileBadgeFailure(label: string, error: unknown) {
|
||||
console.warn(`mobile app badge failed for ${label}`, error);
|
||||
}
|
||||
|
||||
async function ensureMobileBadgePermission() {
|
||||
let currentPermission: NotificationPermissionStatus;
|
||||
try {
|
||||
currentPermission = await Notifications.getPermissionsAsync();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logMobileBadgeFailure('permission.current', error);
|
||||
throw {
|
||||
code: 'host_error',
|
||||
message: 'app badge permission unavailable',
|
||||
@@ -41,7 +46,8 @@ async function ensureMobileBadgePermission() {
|
||||
allowSound: false,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logMobileBadgeFailure('permission.request', error);
|
||||
throw {
|
||||
code: 'host_error',
|
||||
message: 'app badge permission unavailable',
|
||||
@@ -76,13 +82,18 @@ export async function setMobileAppBadgeCount(request: HostBridgeRequest) {
|
||||
let updated = false;
|
||||
try {
|
||||
updated = await Notifications.setBadgeCountAsync(count);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logMobileBadgeFailure('update.set_count', error);
|
||||
throw {
|
||||
code: 'host_error',
|
||||
message: 'app badge update unavailable',
|
||||
} satisfies HostBridgeError;
|
||||
}
|
||||
if (!updated) {
|
||||
logMobileBadgeFailure(
|
||||
'update.rejected',
|
||||
new Error('setBadgeCountAsync returned false'),
|
||||
);
|
||||
throw {
|
||||
code: 'host_error',
|
||||
message: 'app badge update unavailable',
|
||||
|
||||
@@ -593,24 +593,36 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
});
|
||||
|
||||
test('原生异常对象不会透传非协议错误码', async () => {
|
||||
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValueOnce({
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const nativeError = {
|
||||
code: 'native_badge_failure',
|
||||
message: 'native badge failed',
|
||||
nativeStackIOS: ['private native frame'],
|
||||
});
|
||||
};
|
||||
try {
|
||||
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValueOnce({
|
||||
...nativeError,
|
||||
});
|
||||
|
||||
const response = await send(
|
||||
request('app.setBadgeCount', {
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
const response = await send(
|
||||
request('app.setBadgeCount', {
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const failedResponse = expectFailed(response);
|
||||
const failedResponse = expectFailed(response);
|
||||
|
||||
expect(failedResponse.error).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'app badge update unavailable',
|
||||
});
|
||||
expect(failedResponse.error).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'app badge update unavailable',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile app badge failed for update.set_count',
|
||||
nativeError,
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('app.openExternalUrl 原生异常返回稳定 host_error', async () => {
|
||||
@@ -935,18 +947,27 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
});
|
||||
|
||||
test('app.setBadgeCount 在 iOS 系统拒绝设置时不返回成功', async () => {
|
||||
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
try {
|
||||
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false);
|
||||
|
||||
const response = await send(
|
||||
request('app.setBadgeCount', {
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
const response = await send(
|
||||
request('app.setBadgeCount', {
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(expectFailed(response).error).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'app badge update unavailable',
|
||||
});
|
||||
expect(expectFailed(response).error).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'app badge update unavailable',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile app badge failed for update.rejected',
|
||||
expect.any(Error),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => {
|
||||
|
||||
@@ -153,9 +153,9 @@ describe('mobile local notification helpers', () => {
|
||||
});
|
||||
|
||||
test('rejects delivery when current permission lookup is unavailable', async () => {
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(
|
||||
new Error('native permission failed'),
|
||||
);
|
||||
const error = new Error('native permission failed');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
showMobileLocalNotification({ title: '生成完成' }),
|
||||
@@ -167,15 +167,20 @@ describe('mobile local notification helpers', () => {
|
||||
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
|
||||
expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled();
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile notification failed for permission.current',
|
||||
error,
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('rejects delivery when permission request is unavailable', async () => {
|
||||
const error = new Error('native permission failed');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
||||
DENIED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(
|
||||
new Error('native permission failed'),
|
||||
);
|
||||
vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
showMobileLocalNotification({ title: '生成完成' }),
|
||||
@@ -186,6 +191,11 @@ describe('mobile local notification helpers', () => {
|
||||
|
||||
expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled();
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile notification failed for permission.request',
|
||||
error,
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('schedules iOS notification without channel trigger', async () => {
|
||||
@@ -227,10 +237,10 @@ describe('mobile local notification helpers', () => {
|
||||
});
|
||||
|
||||
test('maps Android channel setup failures to stable delivery errors', async () => {
|
||||
const error = new Error('native channel failed');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
setPlatformOS('android');
|
||||
vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValueOnce(
|
||||
new Error('native channel failed'),
|
||||
);
|
||||
vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
showMobileLocalNotification({ title: '生成完成' }),
|
||||
@@ -240,12 +250,17 @@ describe('mobile local notification helpers', () => {
|
||||
});
|
||||
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile notification failed for delivery.schedule',
|
||||
error,
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('maps native notification schedule failures to stable delivery errors', async () => {
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce(
|
||||
new Error('native schedule failed'),
|
||||
);
|
||||
const error = new Error('native schedule failed');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
showMobileLocalNotification({ title: '生成完成' }),
|
||||
@@ -253,6 +268,11 @@ describe('mobile local notification helpers', () => {
|
||||
code: 'host_error',
|
||||
message: 'notification delivery unavailable',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile notification failed for delivery.schedule',
|
||||
error,
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('normalizes HostBridge payload and wraps structured delivery result', async () => {
|
||||
|
||||
@@ -20,6 +20,10 @@ Notifications.setNotificationHandler({
|
||||
}),
|
||||
});
|
||||
|
||||
function logMobileNotificationFailure(label: string, error: unknown) {
|
||||
console.warn(`mobile notification failed for ${label}`, error);
|
||||
}
|
||||
|
||||
function hasNotificationPermission(
|
||||
permission: Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>,
|
||||
) {
|
||||
@@ -35,7 +39,8 @@ async function ensureNotificationPermission() {
|
||||
>;
|
||||
try {
|
||||
currentPermission = await Notifications.getPermissionsAsync();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logMobileNotificationFailure('permission.current', error);
|
||||
throw {
|
||||
code: 'host_error',
|
||||
message: 'notification permission unavailable',
|
||||
@@ -56,7 +61,8 @@ async function ensureNotificationPermission() {
|
||||
allowSound: false,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logMobileNotificationFailure('permission.request', error);
|
||||
throw {
|
||||
code: 'host_error',
|
||||
message: 'notification permission unavailable',
|
||||
@@ -92,7 +98,8 @@ export async function showMobileLocalNotification(
|
||||
? { channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID }
|
||||
: null,
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logMobileNotificationFailure('delivery.schedule', error);
|
||||
throw {
|
||||
code: 'host_error',
|
||||
message: 'notification delivery unavailable',
|
||||
|
||||
@@ -3106,6 +3106,12 @@
|
||||
- 决策:`apps/mobile-shell/src/host-bridge/files.ts` 必须在 Expo Sharing 可用性 / 分享面板、DocumentPicker、文本 / base64 文件读取、相册 / 相机权限请求和相册 / 相机打开失败时记录 `mobile HostBridge file failed for ...` 日志;HostBridge 对 H5 仍只返回稳定 `host_error` / `unsupported_capability` / `cancelled` / `invalid_request` 语义,不透传原生异常明细。移动壳配置检查反查日志 helper、关键 label 和对应单测。
|
||||
- 验证方式:`npm run mobile-shell:test -- src/host-bridge/files.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-20 移动壳通知与角标系统异常必须可观测
|
||||
|
||||
- 背景:Expo 移动壳已声明即时本地通知,iOS 额外声明应用角标;这些能力会触发系统权限读取、权限请求、Android channel 设置、通知调度和角标更新。如果原生 API 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分权限模块异常、系统通知调度失败或角标 API 拒绝。
|
||||
- 决策:`apps/mobile-shell/src/host-bridge/notifications.ts` 必须在权限读取 / 请求和通知投递失败时记录 `mobile notification failed for ...` 日志;`apps/mobile-shell/src/host-bridge/badge.ts` 必须在角标权限读取 / 请求、`setBadgeCountAsync` reject 和返回 `false` 时记录 `mobile app badge failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增远程推送 token、后台通知、定时提醒或 Android 角标 capability。
|
||||
- 验证方式:`npm run mobile-shell:test -- src/host-bridge/notifications.test.ts src/host-bridge/badge.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-20 移动壳门禁脚本必须自登记自扫描
|
||||
|
||||
- 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。
|
||||
|
||||
@@ -336,6 +336,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:H5 的作品架未读草稿生成完成更新开始消费 `app.setBadgeCount`。Expo 移动壳仍只在 iOS 声明该能力,Android 不声明、不伪造成功;H5 只同步可见作品架内未读完成草稿数量,同一草稿多恢复 ID 只计 1,宿主不支持或设置失败不影响 H5 红点与作品架状态。根级原生壳门禁必须覆盖平台壳同步层通过真实 HostBridge transport 发出该 method,避免未读计数模型和宿主消费链路脱节。
|
||||
|
||||
2026-06-20 追加:移动壳本地通知和 iOS 角标的原生异常必须可观测。Expo Notifications 权限读取 / 权限请求、本地通知 Android channel 设置 / 通知调度、iOS 角标权限读取 / 请求、`setBadgeCountAsync` reject 或返回 `false` 时,移动壳必须分别记录 `mobile notification failed for ...` 或 `mobile app badge failed for ...` 日志;HostBridge 回包仍只暴露稳定 `notification permission unavailable`、`notification delivery unavailable`、`app badge permission unavailable` 或 `app badge update unavailable` 语义。该约束不新增远程推送 token、后台通知、定时提醒或 Android 角标能力。
|
||||
|
||||
2026-06-18 追加:H5 的平台外部生成队列概览开始消费 `network.status` / `network.statusChanged`。宿主未声明网络能力时继续按原逻辑轮询;宿主明确离线或不可达时暂停概览请求,恢复在线后重新刷新,不改变生成任务、作品架或后端回读事实。
|
||||
|
||||
2026-06-18 追加:移动壳 `haptics.impact` 只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`;未知强度返回 `invalid_request`,不会静默降级成真实设备触觉反馈。桌面壳不声明该能力,H5 继续按 HostBridge fallback 处理。
|
||||
|
||||
Reference in New Issue
Block a user