Files
digital-pilates/services/backgroundTaskManagerV2.ts
richarjiang f80a1bae78 feat(background-task): 实现iOS原生后台任务V2系统并重构锻炼通知消息模板
- 新增iOS原生BackgroundTaskBridge桥接模块,支持后台任务注册、调度和完成
- 重构BackgroundTaskManager为V2版本,集成原生iOS后台任务能力
- 在AppDelegate中注册后台任务处理器,确保应用启动时正确初始化
- 重构锻炼通知消息生成逻辑,使用配置化模板提升可维护性
- 扩展健康数据类型映射,支持更多运动项目的中文显示
- 替换原有backgroundTaskManager引用为backgroundTaskManagerV2
2025-11-04 09:41:10 +08:00

523 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NativeEventEmitter, NativeModules, Platform, type EmitterSubscription } from 'react-native';
import { listChallenges } from '@/services/challengesApi';
import { resyncFastingNotifications } from '@/services/fastingNotifications';
import { store } from '@/store';
import { selectActiveFastingPlan, selectActiveFastingSchedule } from '@/store/fastingSlice';
import { getWaterIntakeFromHealthKit } from '@/utils/health';
import AsyncStorage from '@/utils/kvStore';
import { log } from '@/utils/logger';
import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getWaterGoalFromStorage } from '@/utils/userPreferences';
import dayjs from 'dayjs';
export const BACKGROUND_TASK_IDENTIFIER = 'com.anonymous.digitalpilates.task';
const DEFAULT_RESCHEDULE_INTERVAL_SECONDS = 60 * 30; // 30 minutes
const BACKGROUND_EVENT = 'BackgroundTaskBridge.execute';
const EXPIRATION_EVENT = 'BackgroundTaskBridge.expire';
const NativeBackgroundModule = NativeModules.BackgroundTaskBridge;
const isIosBackgroundModuleAvailable = Platform.OS === 'ios' && NativeBackgroundModule;
// 检查通知权限
async function checkNotificationPermissions(): Promise<boolean> {
try {
const Notifications = await import('expo-notifications');
const { status } = await Notifications.getPermissionsAsync();
return status === 'granted';
} catch (error) {
console.error('检查通知权限失败:', error);
return false;
}
}
// 执行喝水提醒后台任务
async function executeWaterReminderTask(): Promise<void> {
try {
console.log('执行喝水提醒后台任务...');
let state;
try {
state = store.getState();
} catch (error) {
console.log('无法获取 Redux state使用本地存储:', error);
const dailyGoal = await getWaterGoalFromStorage();
if (!dailyGoal || dailyGoal <= 0) {
console.log('没有设置喝水目标,跳过喝水提醒');
return;
}
await sendSimpleWaterReminder();
return;
}
const waterStats = state.water?.todayStats;
const userProfile = state.user?.profile;
let dailyGoal = waterStats?.dailyGoal ?? 0;
if (!dailyGoal || dailyGoal <= 0) {
dailyGoal = await getWaterGoalFromStorage();
}
if (!dailyGoal || dailyGoal <= 0) {
console.log('没有设置喝水目标,跳过喝水提醒');
return;
}
const currentHour = new Date().getHours();
const userName = userProfile?.name || '朋友';
const todayRange = {
startDate: dayjs().startOf('day').toDate().toISOString(),
endDate: dayjs().endOf('day').toDate().toISOString()
};
let totalAmount = waterStats?.totalAmount ?? 0;
let completionRate = waterStats?.completionRate ?? (dailyGoal > 0 ? (totalAmount / dailyGoal) * 100 : 0);
try {
const healthKitRecords = await getWaterIntakeFromHealthKit(todayRange);
if (Array.isArray(healthKitRecords) && healthKitRecords.length > 0) {
totalAmount = healthKitRecords.reduce((sum: number, record: unknown) => {
if (record && typeof record === 'object' && 'value' in record) {
const { value } = record as { value?: number | string };
const numericValue = Number(value ?? 0);
return Number.isFinite(numericValue) ? sum + numericValue : sum;
}
return sum;
}, 0);
completionRate = Math.min((totalAmount / dailyGoal) * 100, 100);
} else {
console.log('HealthKit 未返回今日饮水记录,使用应用内缓存数据');
}
} catch (healthKitError) {
console.error('从HealthKit获取饮水记录失败使用应用内缓存数据:', healthKitError);
}
const todayWaterStats = {
totalAmount,
dailyGoal,
completionRate: Number.isFinite(completionRate) ? completionRate : 0
};
const notificationSent = await WaterNotificationHelpers.checkWaterGoalAndNotify(
userName,
todayWaterStats,
currentHour
);
if (notificationSent) {
console.log('后台喝水提醒通知已发送');
await AsyncStorage.setItem('@last_background_water_check', Date.now().toString());
} else {
console.log('无需发送后台喝水提醒通知');
}
} catch (error) {
console.error('执行喝水提醒后台任务失败:', error);
}
}
async function executeChallengeReminderTask(): Promise<void> {
try {
console.log('执行挑战鼓励提醒后台任务...');
let userName = '朋友';
try {
const state = store.getState();
const normalizedUserName = state.user?.profile?.name?.trim();
userName = normalizedUserName && normalizedUserName.length > 0 ? normalizedUserName : '朋友';
} catch (error) {
console.log('无法获取用户名,使用默认值:', error);
}
const challenges = await listChallenges();
const joinedChallenges = challenges.filter((challenge) => challenge.isJoined && challenge.progress);
if (!joinedChallenges.length) {
console.log('没有加入的挑战或挑战没有进度,跳过挑战提醒');
return;
}
const todayKey = new Date().toISOString().slice(0, 10);
const eligibleChallenges = [];
for (const challenge of joinedChallenges) {
const progress = challenge.progress;
if (!progress || progress.checkedInToday) {
continue;
}
const storageKey = `@challenge_encouragement_sent:${challenge.id}`;
const lastSent = await AsyncStorage.getItem(storageKey);
if (lastSent === todayKey) {
continue;
}
eligibleChallenges.push(challenge);
}
if (eligibleChallenges.length > 0) {
const randomIndex = Math.floor(Math.random() * eligibleChallenges.length);
const selectedChallenge = eligibleChallenges[randomIndex];
try {
await ChallengeNotificationHelpers.sendEncouragementNotification({
userName,
challengeTitle: selectedChallenge.title,
challengeId: selectedChallenge.id,
});
const storageKey = `@challenge_encouragement_sent:${selectedChallenge.id}`;
await AsyncStorage.setItem(storageKey, todayKey);
console.log(`已随机选择并发送挑战鼓励通知: ${selectedChallenge.title}`);
} catch (notificationError) {
console.error('发送挑战鼓励通知失败:', notificationError);
}
} else {
console.log('没有符合条件的挑战需要发送鼓励通知');
}
console.log('挑战鼓励提醒后台任务完成');
} catch (error) {
console.error('执行挑战鼓励提醒后台任务失败:', error);
}
}
// 执行断食通知后台任务
async function executeFastingNotificationTask(): Promise<void> {
try {
console.log('执行断食通知后台任务...');
let state;
try {
state = store.getState();
} catch (error) {
console.log('无法获取 Redux state跳过断食通知任务:', error);
return;
}
const activeSchedule = selectActiveFastingSchedule(state);
const activePlan = selectActiveFastingPlan(state);
if (!activeSchedule || !activePlan) {
console.log('没有激活的断食计划,跳过断食通知任务');
return;
}
const end = dayjs(activeSchedule.endISO);
if (end.isBefore(dayjs())) {
console.log('断食计划已结束,跳过断食通知任务');
return;
}
console.log('正在同步断食通知...', {
planId: activePlan.id,
start: activeSchedule.startISO,
end: activeSchedule.endISO,
});
await resyncFastingNotifications({
schedule: activeSchedule,
plan: activePlan,
enabled: true,
});
console.log('断食通知后台同步完成');
} catch (error) {
console.error('执行断食通知后台任务失败:', error);
}
}
// 发送测试通知以验证后台任务执行
async function sendTestNotification(): Promise<void> {
try {
console.log('发送后台任务测试通知...');
const Notifications = await import('expo-notifications');
await Notifications.scheduleNotificationAsync({
content: {
title: '后台任务测试',
body: `后台任务正在执行中... 时间: ${new Date().toLocaleTimeString()}`,
data: {
type: 'background_task_test',
timestamp: Date.now()
}
},
trigger: null,
});
console.log('后台任务测试通知发送成功');
await AsyncStorage.setItem('@last_background_test_notification', Date.now().toString());
} catch (error) {
console.error('发送测试通知失败:', error);
}
}
async function sendSimpleWaterReminder(): Promise<void> {
try {
const userName = '朋友';
const Notifications = await import('expo-notifications');
const notificationId = await Notifications.scheduleNotificationAsync({
content: {
title: '💧 该喝水啦!',
body: `${userName},记得补充水分,保持身体健康~`,
data: {
type: 'water_reminder',
url: '/statistics'
},
sound: 'default',
},
trigger: null,
});
console.log('简单喝水提醒已发送ID:', notificationId);
} catch (error) {
console.error('发送简单喝水提醒失败:', error);
}
}
async function executeBackgroundTasks(): Promise<void> {
console.log('开始执行后台任务...');
try {
const hasPermission = await checkNotificationPermissions();
if (!hasPermission) {
console.log('没有通知权限,跳过后台任务');
return;
}
try {
const state = store.getState();
if (!state) {
console.log('Redux store 未初始化,跳过后台任务');
return;
}
} catch (error) {
console.log('无法访问 Redux store跳过后台任务:', error);
return;
}
const testNotificationsEnabled = await AsyncStorage.getItem('@background_test_notifications_enabled') === 'true';
if (testNotificationsEnabled) {
await sendTestNotification();
}
await executeWaterReminderTask();
await executeChallengeReminderTask();
await executeFastingNotificationTask();
console.log('后台任务执行完成');
} catch (error) {
console.error('执行后台任务失败:', error);
throw error;
}
}
export class BackgroundTaskManagerV2 {
private static instance: BackgroundTaskManagerV2;
private isInitialized = false;
private executingPromise: Promise<void> | null = null;
private eventSubscription?: EmitterSubscription;
private expirationSubscription?: EmitterSubscription;
static getInstance(): BackgroundTaskManagerV2 {
if (!BackgroundTaskManagerV2.instance) {
BackgroundTaskManagerV2.instance = new BackgroundTaskManagerV2();
}
return BackgroundTaskManagerV2.instance;
}
async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
if (!isIosBackgroundModuleAvailable) {
log.warn('[BackgroundTaskManagerV2] iOS 原生后台模块不可用,跳过初始化');
this.isInitialized = false;
return;
}
const emitter = new NativeEventEmitter(NativeBackgroundModule);
this.eventSubscription = emitter.addListener(BACKGROUND_EVENT, (payload) => {
log.info('[BackgroundTaskManagerV2] 收到后台任务事件', payload);
this.handleBackgroundExecution();
});
this.expirationSubscription = emitter.addListener(EXPIRATION_EVENT, (payload) => {
log.warn('[BackgroundTaskManagerV2] 后台任务在完成前即将过期', payload);
});
try {
await NativeBackgroundModule.configure({
identifier: BACKGROUND_TASK_IDENTIFIER,
taskType: 'processing',
requiresNetworkConnectivity: false,
requiresExternalPower: false,
defaultDelay: DEFAULT_RESCHEDULE_INTERVAL_SECONDS,
});
this.isInitialized = true;
log.info('[BackgroundTaskManagerV2] 已初始化并注册 iOS 后台任务');
} catch (error: any) {
// BGTaskSchedulerErrorDomain 错误码 1 表示后台任务功能不可用
// 这在模拟器上是正常的,因为模拟器不完全支持后台任务
const errorMessage = error?.message || String(error);
const isBGTaskUnavailable = errorMessage.includes('BGTaskSchedulerErrorDomain') &&
errorMessage.includes('错误1');
if (isBGTaskUnavailable) {
log.warn('[BackgroundTaskManagerV2] 后台任务功能在当前环境不可用(模拟器限制),将在真机上正常工作');
this.removeListeners();
this.isInitialized = false;
// 不抛出错误,因为这是预期行为
return;
}
log.error('[BackgroundTaskManagerV2] 初始化失败', error);
this.removeListeners();
throw error;
}
}
private async handleBackgroundExecution(): Promise<void> {
if (this.executingPromise) {
log.info('[BackgroundTaskManagerV2] 已有后台任务在执行,忽略重复触发');
return;
}
this.executingPromise = executeBackgroundTasks()
.then(async () => {
if (isIosBackgroundModuleAvailable) {
try {
await NativeBackgroundModule.complete(true, DEFAULT_RESCHEDULE_INTERVAL_SECONDS);
} catch (error) {
log.error('[BackgroundTaskManagerV2] 标记后台任务成功完成失败', error);
}
}
})
.catch(async (error) => {
log.error('[BackgroundTaskManagerV2] 后台任务执行失败', error);
if (isIosBackgroundModuleAvailable) {
try {
await NativeBackgroundModule.complete(false, DEFAULT_RESCHEDULE_INTERVAL_SECONDS);
} catch (completionError) {
log.error('[BackgroundTaskManagerV2] 标记后台任务失败状态时出错', completionError);
}
}
})
.finally(() => {
this.executingPromise = null;
});
await this.executingPromise;
}
async stop(): Promise<void> {
if (!isIosBackgroundModuleAvailable) {
return;
}
try {
await NativeBackgroundModule.cancelAll();
} catch (error) {
log.error('[BackgroundTaskManagerV2] 停止后台任务失败', error);
} finally {
this.removeListeners();
this.isInitialized = false;
}
}
private removeListeners(): void {
this.eventSubscription?.remove();
this.expirationSubscription?.remove();
this.eventSubscription = undefined;
this.expirationSubscription = undefined;
}
async getStatus(): Promise<string> {
if (!isIosBackgroundModuleAvailable) {
return Platform.OS;
}
try {
const status = await NativeBackgroundModule.backgroundRefreshStatus();
return status;
} catch (error) {
log.error('[BackgroundTaskManagerV2] 获取后台任务状态失败', error);
return 'unknown';
}
}
async checkStatus(): Promise<string> {
const status = await this.getStatus();
switch (status) {
case 'available':
return '可用';
case 'restricted':
return '受限制';
case 'denied':
return '被拒绝';
default:
return '未知';
}
}
async triggerTaskForTesting(): Promise<void> {
if (!isIosBackgroundModuleAvailable) {
await executeBackgroundTasks();
return;
}
try {
await NativeBackgroundModule.simulateLaunch();
} catch (error) {
log.error('[BackgroundTaskManagerV2] 模拟后台任务触发失败', error);
throw error;
}
}
async testBackgroundTask(): Promise<void> {
await this.triggerTaskForTesting();
}
async getLastBackgroundCheckTime(): Promise<number | null> {
try {
const lastCheck = await AsyncStorage.getItem('@last_background_water_check');
return lastCheck ? parseInt(lastCheck, 10) : null;
} catch (error) {
console.error('获取最后后台检查时间失败:', error);
return null;
}
}
async getPendingRequests(): Promise<any[]> {
if (!isIosBackgroundModuleAvailable) {
return [];
}
try {
const requests = await NativeBackgroundModule.getPendingRequests();
return Array.isArray(requests) ? requests : [];
} catch (error) {
log.error('[BackgroundTaskManagerV2] 获取待处理的后台任务请求失败', error);
return [];
}
}
}
export type BackgroundTaskEvent = {
taskId: string;
timestamp: number;
success: boolean;
error?: string;
};
export const BackgroundTaskManager = BackgroundTaskManagerV2;