Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions frontend/src/app/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,16 @@ function AuthenticatedRuntime({ services }: { readonly services: AppServices })
return;
}

void services.runtime.start();
// 不 catch 的话,runtime.start() 里任何一个模块启动失败(哪怕只是某次原生
// 调用偶发抛错)都会变成静默的 unhandled rejection——用户看到的就是"提醒/
// 守护线程这次没起来",没有任何日志能定位到具体是哪次启动失败的。
services.runtime.start().catch((error) => {
console.error('[app] runtime.start() failed', error);
});
return () => {
void services.runtime.stop();
services.runtime.stop().catch((error) => {
console.error('[app] runtime.stop() failed', error);
});
};
}, [isAuthenticated, services]);

Expand Down
11 changes: 10 additions & 1 deletion frontend/src/app/AppRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export function AppRoot({ services: providedServices }: { services?: AppServices
const controller = services.auth.controller;
const handlePermissionsUpdated = useCallback(() => {
void services.reminder.rebuild();
// ReminderGuardCoordinator 冷启动时如果权限还没给,会跳过启动且不重试;
// schedules.refresh() 会顺带唤醒它订阅的 reconcile(),不用单独给协调器开端口。
// refresh() 内部要读 SQLite,可能因为瞬时的原生/数据库错误 reject——不 catch
// 的话这里又是一次静默 unhandled rejection,跟这个 PR 本身要修的问题一样。
services.schedules.refresh().catch((error) => {
console.error('[app] schedules.refresh() failed after a permission update', error);
});
}, [services]);
return (
<AppProviders
Expand Down Expand Up @@ -243,7 +250,9 @@ function AuthenticatedScheduleRoute({
}
reminderState.attach(currentLoadState.repository, accountId);
scheduleReader.attach(currentLoadState.repository, accountId);
void scheduleReader.refresh();
scheduleReader.refresh().catch((error) => {
console.error('[app] schedules.refresh() failed after SQLite became ready', error);
});
return () => {
scheduleReader.detach();
reminderState.detach();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,17 @@ export class ReminderGuardCoordinator {
// 才需要)——上一版这里连后台权限一起卡,导致用户只给了"仅使用时允许"(没给
// "始终允许",这是安卓上很常见的选择)时,连时间型提醒的兜底轮询都启动不了,
// 跟这条日程要不要用到位置完全没关系。
const { status: foreground } = await Location.getForegroundPermissionsAsync();
// 冷启动时原生模块可能还没链接完,这次调用偶尔会抛错——不兜住的话会一路
// 传出 reconcile()/start(),把整个 AppRuntime 启动流程炸掉(详见
// AppProviders.tsx 里 runtime.start() 未 catch 的说明),而不是仅仅这一次
// reconcile 失败。
let foreground: Location.PermissionStatus;
try {
({ status: foreground } = await Location.getForegroundPermissionsAsync());
} catch (error) {
console.warn('[guard] getForegroundPermissionsAsync failed', error);
return;
}
if (foreground !== 'granted') {
console.warn('[guard] ensureLocationUpdates skipped: foreground permission not granted');
return;
Expand Down
46 changes: 44 additions & 2 deletions frontend/tests/unit/app/AppProviders.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from '@jest/globals';
import { render } from '@testing-library/react-native';
import { render, waitFor } from '@testing-library/react-native';
import type { PropsWithChildren } from 'react';

import type { AppServices } from '../../../src/app/composition/createAppServices';
Expand All @@ -26,7 +26,10 @@ function createServices(): AppServices {
reminder: { rebuild: jest.fn() },
reminderPorts: { device: {} },
alertDialog: {},
runtime: { start: jest.fn(), stop: jest.fn() },
runtime: {
start: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
stop: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
},
protectedClient: {},
scheduleView: {},
webSocketClient: {},
Expand Down Expand Up @@ -74,4 +77,43 @@ describe('AppProviders', () => {

expect(services.runtime.stop).toHaveBeenCalledTimes(1);
});

it('logs instead of throwing an unhandled rejection when runtime.start() fails', async () => {
mockAuthStatus = 'authenticated';
const services = createServices();
const startError = new Error('module start failed');
(services.runtime.start as jest.Mock<() => Promise<void>>).mockRejectedValue(startError);
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

render(
<AppProviders authController={{} as never} services={services}>
{null}
</AppProviders>,
);

await waitFor(() =>
expect(errorSpy).toHaveBeenCalledWith('[app] runtime.start() failed', startError),
);
errorSpy.mockRestore();
});

it('logs instead of throwing an unhandled rejection when runtime.stop() fails', async () => {
mockAuthStatus = 'authenticated';
const services = createServices();
const stopError = new Error('module stop failed');
(services.runtime.stop as jest.Mock<() => Promise<void>>).mockRejectedValue(stopError);
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

const { unmount } = render(
<AppProviders authController={{} as never} services={services}>
{null}
</AppProviders>,
);
unmount();

await waitFor(() =>
expect(errorSpy).toHaveBeenCalledWith('[app] runtime.stop() failed', stopError),
);
errorSpy.mockRestore();
});
});
66 changes: 66 additions & 0 deletions frontend/tests/unit/app/AppRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,20 +451,86 @@ describe('AppRoot', () => {
return true;
});
const rebuild = jest.spyOn(services.reminder, 'rebuild').mockResolvedValue([]);
const refresh = jest.spyOn(services.schedules, 'refresh').mockResolvedValue(undefined);

render(<AppRoot services={services} />);

await screen.findByText('需要这些权限');
// DB ready 那条 effect 自己也会在挂载时 refresh() 一次;只关心权限授予
// 这次触发的那一下,把挂载阶段的调用先清掉。
refresh.mockClear();
fireEvent.press(screen.getByTestId('permission-action-notifications'));

await waitFor(() =>
expect(screen.getByLabelText('进入 App').props.accessibilityState.disabled).toBe(false),
);
expect(rebuild).toHaveBeenCalledTimes(1);
// 冷启动权限没给时,守护线程的 reconcile() 会跳过启动且不重试;权限中途
// 被授予后必须有人把它叫醒,不然只能等下次重启才会再跑一次 reconcile()。
expect(refresh).toHaveBeenCalledTimes(1);

fireEvent.press(screen.getByLabelText('进入 App'));
await screen.findByText('日程日历');
});

it('logs instead of throwing when schedules.refresh() fails after a permission grant', async () => {
const services = createController({
accountId: 'acc_001',
accessToken: 'opaque-token',
expiresAt: 200_000,
username: 'timeflow_user',
});
let status = {
platform: 'android' as const,
supported: true,
permissions: {
notifications: false,
exact_alarm: true,
overlay: true,
full_screen: true,
battery_optimization: true,
location_foreground: true,
location_background: true,
microphone: true,
},
background_execution: true,
oemGuidance: {
manufacturer: null,
autostartGuided: false,
backgroundPopupGuided: false,
lastOverlayFailed: false,
},
};
jest.spyOn(services.reminderPorts.device, 'getStatus').mockImplementation(async () => status);
jest
.spyOn(services.reminderPorts.device, 'requestPermission')
.mockImplementation(async (permission) => {
status = { ...status, permissions: { ...status.permissions, [permission]: true } };
return true;
});
jest.spyOn(services.reminder, 'rebuild').mockResolvedValue([]);
const refreshError = new Error('sqlite read failed');
const refresh = jest.spyOn(services.schedules, 'refresh').mockRejectedValue(refreshError);
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

render(<AppRoot services={services} />);

await screen.findByText('需要这些权限');
refresh.mockClear();
fireEvent.press(screen.getByTestId('permission-action-notifications'));

await waitFor(() =>
expect(errorSpy).toHaveBeenCalledWith(
'[app] schedules.refresh() failed after a permission update',
refreshError,
),
);
// 失败不能挡住用户继续走完权限流程。
await waitFor(() =>
expect(screen.getByLabelText('进入 App').props.accessibilityState.disabled).toBe(false),
);
errorSpy.mockRestore();
});
});

function createController(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,20 @@ describe('ReminderGuardCoordinator', () => {
expect(startUpdates).toHaveBeenCalledTimes(1);
});

it('does not let a getForegroundPermissionsAsync failure reject start()', async () => {
getForeground.mockRejectedValue(new Error('boom'));
const reader = createReader([timeSchedule()]);
const coordinator = new ReminderGuardCoordinator({
schedules: reader,
handleLocation: jest.fn(async () => {}),
});

// 改之前:这次原生调用不兜错的话,start() 会直接 reject,把整个
// AppRuntime 的启动流程一起炸掉,而不是仅仅这次 reconcile 没启动成功。
await expect(coordinator.start()).resolves.toBeUndefined();
expect(startUpdates).not.toHaveBeenCalled();
});

it('swallows a startLocationUpdatesAsync failure', async () => {
startUpdates.mockRejectedValue(new Error('boom'));
const reader = createReader([timeSchedule()]);
Expand Down
Loading