diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 5f702ad..1c3e1cd 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -311,6 +311,11 @@ export default function PersonalScreen() { switchValue: notificationEnabled, onSwitchChange: handleNotificationToggle, }, + { + icon: 'settings-outline' as const, + title: '推送通知设置', + onPress: () => pushIfAuthedElseLogin('/push-notification-settings'), + }, ], }, // 开发者section(需要连续点击三次用户名激活) diff --git a/app/food-library.tsx b/app/food-library.tsx index 81b46fa..faa4293 100644 --- a/app/food-library.tsx +++ b/app/food-library.tsx @@ -23,7 +23,7 @@ import { Text, TextInput, TouchableOpacity, - View + View, } from 'react-native'; // 餐次映射保持不变 diff --git a/app/index.tsx b/app/index.tsx index 8715358..dff440e 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,5 +1,6 @@ import { ThemedView } from '@/components/ThemedView'; import { ROUTES } from '@/constants/Routes'; +import { usePushNotifications } from '@/hooks/usePushNotifications'; import { useThemeColor } from '@/hooks/useThemeColor'; import { preloadUserData } from '@/store/userSlice'; import { router } from 'expo-router'; @@ -12,6 +13,7 @@ export default function SplashScreen() { const backgroundColor = useThemeColor({}, 'background'); const primaryColor = useThemeColor({}, 'primary'); const [isLoading, setIsLoading] = useState(true); + const { initializePushNotifications } = usePushNotifications(); useEffect(() => { checkOnboardingStatus(); @@ -24,6 +26,12 @@ export default function SplashScreen() { await preloadUserData(); console.log('用户数据预加载完成'); + // 初始化推送通知(不阻塞应用启动) + console.log('开始初始化推送通知...'); + initializePushNotifications().catch((error) => { + console.warn('推送通知初始化失败,但不影响应用正常使用:', error); + }); + // const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); // if (onboardingCompleted === 'true') { diff --git a/app/push-notification-settings.tsx b/app/push-notification-settings.tsx new file mode 100644 index 0000000..3a74c80 --- /dev/null +++ b/app/push-notification-settings.tsx @@ -0,0 +1,328 @@ +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import { getTokenStatusDescription, isPushNotificationAvailable, usePushNotifications } from '@/hooks/usePushNotifications'; +import { useThemeColor } from '@/hooks/useThemeColor'; +import { TokenStatus } from '@/services/pushNotificationManager'; +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; + +export default function PushNotificationSettingsScreen() { + const backgroundColor = useThemeColor({}, 'background'); + const textColor = useThemeColor({}, 'text'); + const borderColor = useThemeColor({}, 'border'); + const primaryColor = useThemeColor({}, 'primary'); + + const { + isInitialized, + tokenStatus, + isLoading, + registerToken, + getCurrentToken, + updateTokenStatus, + clearAllData, + } = usePushNotifications(); + + const [isRegistering, setIsRegistering] = useState(false); + const [currentToken, setCurrentToken] = useState(null); + + useEffect(() => { + updateTokenStatus(); + setCurrentToken(getCurrentToken()); + }, [isInitialized, tokenStatus]); + + const handleRegisterToken = async () => { + setIsRegistering(true); + try { + const success = await registerToken(); + if (success) { + Alert.alert('成功', '设备令牌注册成功'); + } else { + Alert.alert('失败', '设备令牌注册失败,请检查网络连接和权限设置'); + } + } catch (error) { + console.error('注册设备令牌失败:', error); + Alert.alert('错误', '注册设备令牌时发生错误'); + } finally { + setIsRegistering(false); + } + }; + + const handleClearData = () => { + Alert.alert( + '确认清除', + '这将清除所有本地推送通知数据,包括设备令牌。确定要继续吗?', + [ + { text: '取消', style: 'cancel' }, + { + text: '确定', + style: 'destructive', + onPress: async () => { + try { + await clearAllData(); + Alert.alert('成功', '推送通知数据已清除'); + } catch (error) { + console.error('清除数据失败:', error); + Alert.alert('错误', '清除数据时发生错误'); + } + }, + }, + ] + ); + }; + + const copyTokenToClipboard = () => { + if (currentToken) { + // 这里可以使用 Clipboard API 复制到剪贴板 + Alert.alert('令牌已复制', '设备令牌已复制到剪贴板(功能待实现)'); + } + }; + + const getStatusColor = () => { + switch (tokenStatus) { + case TokenStatus.REGISTERED: + return '#4CAF50'; + case TokenStatus.GRANTED: + return '#2196F3'; + case TokenStatus.DENIED: + return '#F44336'; + case TokenStatus.FAILED: + return '#FF9800'; + default: + return '#9E9E9E'; + } + }; + + if (isLoading) { + return ( + + + + 加载推送通知设置... + + + ); + } + + return ( + + + + 推送通知设置 + + 管理您的推送通知权限和设备令牌 + + + + + 状态信息 + + + 初始化状态: + + + {isInitialized ? '已初始化' : '未初始化'} + + + + + + 令牌状态: + + + {getTokenStatusDescription(tokenStatus)} + + + + + + 推送可用: + + + {isPushNotificationAvailable(tokenStatus) ? '是' : '否'} + + + + + + {currentToken && ( + + 设备令牌 + + + {currentToken.substring(0, 20)}...{currentToken.substring(currentToken.length - 10)} + + + 复制 + + + + )} + + + 操作 + + + {isRegistering ? ( + + ) : ( + 注册设备令牌 + )} + + + updateTokenStatus()} + > + 刷新状态 + + + + 清除所有数据 + + + + + 说明 + + • 推送通知需要在真实iOS设备上使用{'\n'} + • 设备令牌用于接收远程推送通知{'\n'} + • 如果令牌状态显示"权限被拒绝",请在系统设置中开启通知权限{'\n'} + • 清除数据后需要重新初始化推送通知 + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + padding: 16, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 16, + fontSize: 16, + }, + header: { + marginBottom: 24, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + opacity: 0.7, + }, + card: { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderRadius: 12, + padding: 16, + marginBottom: 16, + borderWidth: 1, + }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + statusRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + statusLabel: { + fontSize: 16, + flex: 1, + }, + statusValue: { + flex: 1, + alignItems: 'flex-end', + }, + statusText: { + fontSize: 16, + fontWeight: '500', + }, + tokenContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + tokenText: { + fontSize: 14, + fontFamily: 'monospace', + flex: 1, + marginRight: 12, + }, + copyButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + borderWidth: 1, + }, + copyButtonText: { + fontSize: 14, + fontWeight: '500', + }, + actionButton: { + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 16, + alignItems: 'center', + marginBottom: 12, + }, + actionButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + secondaryButton: { + backgroundColor: 'transparent', + borderWidth: 1, + }, + secondaryButtonText: { + fontSize: 16, + fontWeight: '600', + }, + dangerButton: { + backgroundColor: '#F44336', + }, + dangerButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + description: { + fontSize: 14, + lineHeight: 20, + }, +}); \ No newline at end of file diff --git a/docs/push-notification-implementation.md b/docs/push-notification-implementation.md new file mode 100644 index 0000000..5dcbba9 --- /dev/null +++ b/docs/push-notification-implementation.md @@ -0,0 +1,267 @@ +# iOS APNs 推送通知集成文档 + +## 概述 + +本文档描述了如何在数字普拉提应用中集成iOS APNs (Apple Push Notification Service) 推送通知功能。该功能包括设备令牌管理、权限请求和与后端API的交互。 + +## 功能特性 + +- ✅ 设备令牌自动获取和注册 +- ✅ 设备令牌更新和注销 +- ✅ 推送通知权限管理 +- ✅ 本地通知和远程推送支持 +- ✅ 用户友好的设置界面 +- ✅ 错误处理和状态管理 + +## 文件结构 + +``` +services/ +├── pushNotifications.ts # 推送通知API服务 +├── pushNotificationManager.ts # 推送通知管理器 +└── notifications.ts # 本地通知服务(已更新) + +hooks/ +└── usePushNotifications.ts # 推送通知Hook + +app/ +├── index.tsx # 应用入口(已更新) +├── push-notification-settings.tsx # 推送通知设置页面 +└── (tabs)/personal.tsx # 个人页面(已更新) +``` + +## API接口 + +### 1. 注册设备令牌 + +```typescript +POST /api/push-notifications/register-token +Authorization: Bearer +Content-Type: application/json + +{ + "deviceToken": "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7", + "deviceType": "IOS", + "appVersion": "1.0.0", + "osVersion": "iOS 15.0", + "deviceName": "iPhone 13" +} +``` + +### 2. 更新设备令牌 + +```typescript +PUT /api/push-notifications/update-token +Authorization: Bearer +Content-Type: application/json + +{ + "currentDeviceToken": "old-device-token", + "newDeviceToken": "new-device-token", + "appVersion": "1.0.1", + "osVersion": "iOS 15.1" +} +``` + +### 3. 注销设备令牌 + +```typescript +DELETE /api/push-notifications/unregister-token +Authorization: Bearer +Content-Type: application/json + +{ + "deviceToken": "device-token-to-unregister" +} +``` + +## 使用方法 + +### 1. 自动初始化 + +推送通知功能会在应用启动时自动初始化,无需手动调用。初始化过程包括: + +1. 请求推送通知权限 +2. 获取设备令牌 +3. 注册设备令牌到后端 +4. 设置监听器 + +### 2. 使用Hook + +```typescript +import { usePushNotifications } from '@/hooks/usePushNotifications'; + +function MyComponent() { + const { + isInitialized, + tokenStatus, + isLoading, + registerToken, + getCurrentToken, + updateTokenStatus, + clearAllData, + } = usePushNotifications(); + + // 使用状态和方法 +} +``` + +### 3. 手动管理令牌 + +```typescript +import { pushNotificationManager } from '@/services/pushNotificationManager'; + +// 注册设备令牌 +await pushNotificationManager.registerDeviceToken(); + +// 更新设备令牌 +await pushNotificationManager.updateDeviceToken(newToken); + +// 注销设备令牌 +await pushNotificationManager.unregisterDeviceToken(); + +// 获取当前令牌 +const token = pushNotificationManager.getCurrentToken(); +``` + +### 4. 发送本地通知 + +```typescript +import { notificationService } from '@/services/notifications'; + +// 发送立即通知 +await notificationService.sendImmediateNotification({ + title: '标题', + body: '内容', + data: { type: 'custom' }, + sound: true, + priority: 'high', +}); + +// 发送定时通知 +await notificationService.scheduleNotificationAtDate( + { + title: '定时通知', + body: '这是一个定时通知', + data: { type: 'scheduled' }, + }, + new Date(Date.now() + 60000) // 1分钟后 +); +``` + +## 用户界面 + +### 推送通知设置页面 + +用户可以通过以下路径访问推送通知设置: + +`个人页面 → 通知 → 推送通知设置` + +设置页面包含: + +- 初始化状态显示 +- 令牌状态信息 +- 设备令牌显示(部分隐藏) +- 手动注册按钮 +- 状态刷新按钮 +- 数据清除按钮 + +### 个人页面集成 + +在个人页面的通知部分添加了"推送通知设置"入口,用户可以: + +- 开启/关闭消息推送 +- 访问详细的推送通知设置 +- 查看和管理设备令牌 + +## 状态管理 + +### 令牌状态 + +```typescript +enum TokenStatus { + UNKNOWN = 'unknown', // 状态未知 + GRANTED = 'granted', // 权限已授予,但未注册 + DENIED = 'denied', // 权限被拒绝 + REGISTERED = 'registered', // 设备令牌已注册 + FAILED = 'failed', // 初始化失败 +} +``` + +### 本地存储 + +- `@device_token`: 存储当前设备令牌 +- `@token_registered`: 标记令牌是否已注册 + +## 错误处理 + +### 常见错误及解决方案 + +1. **权限被拒绝** + - 引导用户到系统设置中开启通知权限 + - 提供友好的错误提示 + +2. **设备令牌获取失败** + - 检查设备是否支持推送通知 + - 确保在真实设备上测试(模拟器不支持) + +3. **网络请求失败** + - 提供重试机制 + - 显示网络错误提示 + +4. **API调用失败** + - 记录详细错误日志 + - 提供用户友好的错误信息 + +## 开发注意事项 + +1. **真机测试** + - 推送通知功能必须在真实iOS设备上测试 + - 模拟器无法获取设备令牌 + +2. **证书配置** + - 确保APNs证书正确配置 + - Bundle Identifier与证书匹配 + +3. **后台模式** + - 在`app.json`中启用了`remote-notification`后台模式 + - 支持后台接收推送通知 + +4. **调试技巧** + - 使用Xcode控制台查看推送相关日志 + - 检查设备令牌是否正确获取 + - 验证API请求和响应 + +## 测试建议 + +1. **功能测试** + - 测试权限请求流程 + - 验证设备令牌注册 + - 测试本地通知功能 + +2. **边界情况** + - 权限被拒绝时的处理 + - 网络不可用时的行为 + - 应用被杀死时的推送接收 + +3. **性能测试** + - 初始化时间测试 + - 内存使用情况 + - 电池消耗评估 + +## 后续优化 + +1. **功能增强** + - 添加推送通知统计 + - 支持通知分类管理 + - 实现推送消息历史 + +2. **用户体验** + - 优化设置页面UI + - 添加推送通知预览 + - 提供更详细的状态说明 + +3. **技术改进** + - 实现令牌自动刷新 + - 添加推送送达统计 + - 优化错误恢复机制 \ No newline at end of file diff --git a/hooks/usePushNotifications.ts b/hooks/usePushNotifications.ts new file mode 100644 index 0000000..c4abdda --- /dev/null +++ b/hooks/usePushNotifications.ts @@ -0,0 +1,138 @@ +import { notificationService } from '@/services/notifications'; +import { pushNotificationManager, TokenStatus } from '@/services/pushNotificationManager'; +import { useEffect, useState } from 'react'; + +/** + * 推送通知Hook + * 用于管理推送通知的初始化和状态 + */ +export function usePushNotifications() { + const [isInitialized, setIsInitialized] = useState(false); + const [tokenStatus, setTokenStatus] = useState(TokenStatus.UNKNOWN); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + initializePushNotifications(); + }, []); + + /** + * 初始化推送通知 + */ + const initializePushNotifications = async () => { + try { + setIsLoading(true); + + // 初始化本地通知服务 + await notificationService.initialize(); + + // 初始化推送通知管理器 + const success = await pushNotificationManager.initialize({ + onTokenReceived: (token) => { + console.log('Hook: 设备令牌已接收:', token.substring(0, 20) + '...'); + updateTokenStatus(); + }, + onTokenRefresh: (token) => { + console.log('Hook: 设备令牌已刷新:', token.substring(0, 20) + '...'); + updateTokenStatus(); + }, + onError: (error) => { + console.error('Hook: 推送通知管理器错误:', error); + } + }); + + setIsInitialized(success); + await updateTokenStatus(); + } catch (error) { + console.error('Hook: 初始化推送通知失败:', error); + } finally { + setIsLoading(false); + } + }; + + /** + * 更新令牌状态 + */ + const updateTokenStatus = async () => { + try { + const status = await pushNotificationManager.getTokenStatus(); + setTokenStatus(status); + } catch (error) { + console.error('Hook: 获取令牌状态失败:', error); + setTokenStatus(TokenStatus.FAILED); + } + }; + + /** + * 手动注册设备令牌 + */ + const registerToken = async () => { + try { + const success = await pushNotificationManager.registerDeviceToken(); + if (success) { + await updateTokenStatus(); + } + return success; + } catch (error) { + console.error('Hook: 注册设备令牌失败:', error); + return false; + } + }; + + /** + * 获取当前设备令牌 + */ + const getCurrentToken = () => { + return pushNotificationManager.getCurrentToken(); + }; + + /** + * 清除所有推送通知数据 + */ + const clearAllData = async () => { + try { + await pushNotificationManager.clearAllData(); + await updateTokenStatus(); + setIsInitialized(false); + } catch (error) { + console.error('Hook: 清除推送通知数据失败:', error); + } + }; + + return { + isInitialized, + tokenStatus, + isLoading, + initializePushNotifications, + registerToken, + getCurrentToken, + updateTokenStatus, + clearAllData, + }; +} + +/** + * 推送通知状态描述 + */ +export function getTokenStatusDescription(status: TokenStatus): string { + switch (status) { + case TokenStatus.UNKNOWN: + return '状态未知'; + case TokenStatus.GRANTED: + return '权限已授予,但未注册'; + case TokenStatus.DENIED: + return '权限被拒绝'; + case TokenStatus.REGISTERED: + return '设备令牌已注册'; + case TokenStatus.FAILED: + return '初始化失败'; + default: + return '未知状态'; + } +} + +/** + * 检查推送通知是否可用 + */ +export function isPushNotificationAvailable(status: TokenStatus): boolean { + return status === TokenStatus.REGISTERED || status === TokenStatus.GRANTED; +} \ No newline at end of file diff --git a/services/notifications.ts b/services/notifications.ts index bfdfa72..8cb56a7 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -2,6 +2,7 @@ import { ROUTES } from '@/constants/Routes'; import { getNotificationEnabled } from '@/utils/userPreferences'; import * as Notifications from 'expo-notifications'; import { router } from 'expo-router'; +import { pushNotificationManager } from './pushNotificationManager'; // 配置通知处理方式 Notifications.setNotificationHandler({ @@ -77,6 +78,24 @@ export class NotificationService { try { this.isIniting = true + + // 初始化推送通知管理器(包含设备令牌管理) + const pushManagerInitialized = await pushNotificationManager.initialize({ + onTokenReceived: (token) => { + console.log('设备令牌已接收:', token.substring(0, 20) + '...'); + }, + onTokenRefresh: (token) => { + console.log('设备令牌已刷新:', token.substring(0, 20) + '...'); + }, + onError: (error) => { + console.error('推送通知管理器错误:', error); + } + }); + + if (!pushManagerInitialized) { + console.warn('推送通知管理器初始化失败,但本地通知功能仍可用'); + } + // 请求通知权限 const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; @@ -91,7 +110,6 @@ export class NotificationService { return; } - // 设置通知监听器 this.setupNotificationListeners(); diff --git a/services/pushNotificationManager.ts b/services/pushNotificationManager.ts new file mode 100644 index 0000000..b48dda9 --- /dev/null +++ b/services/pushNotificationManager.ts @@ -0,0 +1,330 @@ +import { logger } from '@/utils/logger'; +import Constants from 'expo-constants'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; +import { DeviceTokenRequest, pushNotificationService } from './pushNotifications'; + +// 设备令牌管理状态 +export enum TokenStatus { + UNKNOWN = 'unknown', + GRANTED = 'granted', + DENIED = 'denied', + REGISTERED = 'registered', + FAILED = 'failed', +} + +// 推送通知管理器配置 +export interface PushNotificationConfig { + onTokenReceived?: (token: string) => void; + onTokenRefresh?: (token: string) => void; + onError?: (error: Error) => void; +} + +/** + * 推送通知管理器类 + * 负责iOS推送通知权限请求和设备令牌管理 + */ +export class PushNotificationManager { + private static instance: PushNotificationManager; + private isInitialized = false; + private isInitializing = false; + private config: PushNotificationConfig = {}; + private currentToken: string | null = null; + + private constructor() { } + + public static getInstance(): PushNotificationManager { + if (!PushNotificationManager.instance) { + PushNotificationManager.instance = new PushNotificationManager(); + } + return PushNotificationManager.instance; + } + + /** + * 初始化推送通知管理器 + */ + async initialize(config: PushNotificationConfig = {}): Promise { + if (this.isInitialized || this.isInitializing) { + return this.isInitialized; + } + + try { + this.isInitializing = true; + this.config = config; + + console.log('初始化推送通知管理器...'); + + // 检查设备是否支持推送通知 + // 在Expo Go环境中,某些推送功能可能受限 + if (Platform.OS === 'web') { + console.warn('Web平台不支持推送通知功能'); + this.config.onError?.(new Error('Web平台不支持推送通知功能')); + return false; + } + + // 配置推送通知处理方式 + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: true, + shouldSetBadge: true, + shouldShowBanner: true, + shouldShowList: true, + }), + }); + + // 请求推送通知权限 + const hasPermission = await this.requestPermissions(); + if (!hasPermission) { + console.warn('推送通知权限未授予'); + return false; + } + + // 获取设备令牌 + const token = await this.getDeviceToken(); + if (!token) { + console.error('获取设备令牌失败'); + return false; + } + + logger.info('获取到设备令牌:', token); + + // 检查是否需要注册令牌 + await this.checkAndRegisterToken(token); + + // 设置令牌刷新监听器 + this.setupTokenRefreshListener(); + + this.isInitialized = true; + console.log('推送通知管理器初始化成功'); + return true; + } catch (error) { + console.error('推送通知管理器初始化失败:', error); + this.config.onError?.(error as Error); + return false; + } finally { + this.isInitializing = false; + } + } + + /** + * 请求推送通知权限 + */ + async requestPermissions(): Promise { + try { + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== 'granted') { + console.warn('推送通知权限未授予:', finalStatus); + return false; + } + + console.log('推送通知权限已授予'); + return true; + } catch (error) { + console.error('请求推送通知权限失败:', error); + this.config.onError?.(error as Error); + return false; + } + } + + /** + * 获取设备令牌 + */ + async getDeviceToken(): Promise { + try { + if (Platform.OS === 'ios') { + // iOS使用APNs令牌 + const token = await Notifications.getDevicePushTokenAsync(); + this.currentToken = token.data; + console.log('获取到iOS设备令牌:', token.data.substring(0, 20) + '...'); + return token.data; + } else { + // Android使用FCM令牌(虽然项目只支持iOS,但保留代码完整性) + const token = await Notifications.getDevicePushTokenAsync(); + this.currentToken = token.data; + console.log('获取到Android设备令牌:', token.data.substring(0, 20) + '...'); + return token.data; + } + } catch (error) { + console.error('获取设备令牌失败:', error); + this.config.onError?.(error as Error); + return null; + } + } + + /** + * 检查并注册设备令牌 + */ + private async checkAndRegisterToken(token: string): Promise { + try { + const storedToken = await pushNotificationService.getStoredDeviceToken(); + const isRegistered = await pushNotificationService.isTokenRegistered(); + + // 如果令牌已改变或未注册,则重新注册 + if (!isRegistered || storedToken !== token) { + await this.registerDeviceToken(token); + } else { + console.log('设备令牌已注册,无需重复注册'); + } + } catch (error) { + console.error('检查和注册设备令牌失败:', error); + this.config.onError?.(error as Error); + } + } + + /** + * 注册设备令牌到后端 + */ + async registerDeviceToken(token?: string): Promise { + try { + const deviceToken = token || this.currentToken || await this.getDeviceToken(); + + if (!deviceToken) { + throw new Error('设备令牌为空'); + } + + // 构建设备信息 + const deviceInfo: DeviceTokenRequest = { + deviceToken, + deviceType: Platform.OS === 'ios' ? 'IOS' : 'ANDROID', + appVersion: Constants.expoConfig?.version || '1.0.0', + osVersion: `${Platform.OS} ${Platform.Version}`, + deviceName: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device', + }; + + logger.info('设备信息:', deviceInfo); + + // 注册到后端 + const response = await pushNotificationService.registerDeviceToken(deviceInfo); + + console.log('设备令牌注册成功:', response.tokenId); + this.config.onTokenReceived?.(deviceToken); + + return true; + } catch (error) { + console.error('注册设备令牌失败:', error); + this.config.onError?.(error as Error); + return false; + } + } + + /** + * 更新设备令牌 + */ + async updateDeviceToken(newToken: string): Promise { + try { + const oldToken = await pushNotificationService.getStoredDeviceToken(); + + if (!oldToken) { + console.warn('未找到旧令牌,将执行新注册'); + return this.registerDeviceToken(newToken); + } + + const updateRequest = { + currentDeviceToken: oldToken, + newDeviceToken: newToken, + appVersion: Constants.expoConfig?.version || '1.0.0', + osVersion: `${Platform.OS} ${Platform.Version}`, + }; + + const response = await pushNotificationService.updateDeviceToken(updateRequest); + this.currentToken = newToken; + + console.log('设备令牌更新成功:', response.tokenId); + this.config.onTokenRefresh?.(newToken); + + return true; + } catch (error) { + console.error('更新设备令牌失败:', error); + this.config.onError?.(error as Error); + return false; + } + } + + /** + * 注销设备令牌 + */ + async unregisterDeviceToken(): Promise { + try { + const token = this.currentToken || await pushNotificationService.getStoredDeviceToken(); + + if (!token) { + console.warn('未找到设备令牌,无需注销'); + return true; + } + + await pushNotificationService.unregisterDeviceToken(token); + this.currentToken = null; + + console.log('设备令牌注销成功'); + return true; + } catch (error) { + console.error('注销设备令牌失败:', error); + this.config.onError?.(error as Error); + return false; + } + } + + /** + * 设置令牌刷新监听器 + */ + private setupTokenRefreshListener(): void { + // 监听令牌变化(iOS上通常不会频繁变化) + Notifications.addNotificationResponseReceivedListener((response) => { + console.log('收到推送通知响应:', response); + }); + } + + /** + * 获取当前设备令牌 + */ + getCurrentToken(): string | null { + return this.currentToken; + } + + /** + * 获取令牌状态 + */ + async getTokenStatus(): Promise { + try { + const hasPermission = await this.requestPermissions(); + if (!hasPermission) { + return TokenStatus.DENIED; + } + + const isRegistered = await pushNotificationService.isTokenRegistered(); + if (isRegistered) { + return TokenStatus.REGISTERED; + } + + const token = await this.getDeviceToken(); + return token ? TokenStatus.GRANTED : TokenStatus.FAILED; + } catch (error) { + console.error('获取令牌状态失败:', error); + return TokenStatus.FAILED; + } + } + + /** + * 清除所有本地数据 + */ + async clearAllData(): Promise { + try { + await pushNotificationService.clearLocalTokenData(); + this.currentToken = null; + this.isInitialized = false; + console.log('推送通知管理器数据已清除'); + } catch (error) { + console.error('清除推送通知管理器数据失败:', error); + } + } +} + +// 导出单例实例 +export const pushNotificationManager = PushNotificationManager.getInstance(); \ No newline at end of file diff --git a/services/pushNotifications.ts b/services/pushNotifications.ts new file mode 100644 index 0000000..0295137 --- /dev/null +++ b/services/pushNotifications.ts @@ -0,0 +1,167 @@ +import AsyncStorage from '@/utils/kvStore'; +import { api } from './api'; + +// 设备令牌存储键 +const DEVICE_TOKEN_KEY = '@device_token'; +const TOKEN_REGISTERED_KEY = '@token_registered'; + +// 设备令牌注册请求参数 +export interface DeviceTokenRequest { + deviceToken: string; + deviceType: 'IOS' | 'ANDROID'; + appVersion: string; + osVersion: string; + deviceName: string; +} + +// 设备令牌更新请求参数 +export interface UpdateTokenRequest { + currentDeviceToken: string; + newDeviceToken: string; + appVersion: string; + osVersion: string; +} + +// 设备令牌注销请求参数 +export interface UnregisterTokenRequest { + deviceToken: string; +} + +// API响应类型 +export interface PushNotificationResponse { + success: boolean; + tokenId: string; +} + +// API错误响应类型 +export interface PushNotificationError { + code: number; + message: string; + data?: any; +} + +/** + * 推送通知API服务类 + */ +export class PushNotificationService { + private static instance: PushNotificationService; + + private constructor() { } + + public static getInstance(): PushNotificationService { + if (!PushNotificationService.instance) { + PushNotificationService.instance = new PushNotificationService(); + } + return PushNotificationService.instance; + } + + /** + * 注册设备令牌 + */ + async registerDeviceToken(request: DeviceTokenRequest): Promise { + try { + console.log('注册设备令牌:', request.deviceToken.substring(0, 20) + '...'); + + const response = await api.post( + '/push-notifications/register-token', + request + ); + + // 保存设备令牌到本地存储 + await AsyncStorage.setItem(DEVICE_TOKEN_KEY, request.deviceToken); + await AsyncStorage.setItem(TOKEN_REGISTERED_KEY, 'true'); + + console.log('设备令牌注册成功:', response.tokenId); + return response; + } catch (error) { + console.error('设备令牌注册失败:', error); + throw error; + } + } + + /** + * 更新设备令牌 + */ + async updateDeviceToken(request: UpdateTokenRequest): Promise { + try { + console.log('更新设备令牌:', request.newDeviceToken.substring(0, 20) + '...'); + + const response = await api.put( + '/push-notifications/update-token', + request + ); + + // 更新本地存储的设备令牌 + await AsyncStorage.setItem(DEVICE_TOKEN_KEY, request.newDeviceToken); + + console.log('设备令牌更新成功:', response.tokenId); + return response; + } catch (error) { + console.error('设备令牌更新失败:', error); + throw error; + } + } + + /** + * 注销设备令牌 + */ + async unregisterDeviceToken(deviceToken: string): Promise { + try { + console.log('注销设备令牌:', deviceToken.substring(0, 20) + '...'); + + await api.delete('/push-notifications/unregister-token', { + body: { deviceToken } + }); + + // 清除本地存储 + await AsyncStorage.removeItem(DEVICE_TOKEN_KEY); + await AsyncStorage.removeItem(TOKEN_REGISTERED_KEY); + + console.log('设备令牌注销成功'); + } catch (error) { + console.error('设备令牌注销失败:', error); + throw error; + } + } + + /** + * 获取本地存储的设备令牌 + */ + async getStoredDeviceToken(): Promise { + try { + return await AsyncStorage.getItem(DEVICE_TOKEN_KEY); + } catch (error) { + console.error('获取本地设备令牌失败:', error); + return null; + } + } + + /** + * 检查设备令牌是否已注册 + */ + async isTokenRegistered(): Promise { + try { + const registered = await AsyncStorage.getItem(TOKEN_REGISTERED_KEY); + return registered === 'true'; + } catch (error) { + console.error('检查令牌注册状态失败:', error); + return false; + } + } + + /** + * 清除本地设备令牌数据 + */ + async clearLocalTokenData(): Promise { + try { + await AsyncStorage.removeItem(DEVICE_TOKEN_KEY); + await AsyncStorage.removeItem(TOKEN_REGISTERED_KEY); + console.log('本地设备令牌数据已清除'); + } catch (error) { + console.error('清除本地令牌数据失败:', error); + } + } +} + +// 导出单例实例 +export const pushNotificationService = PushNotificationService.getInstance(); \ No newline at end of file