feat(push): 新增iOS APNs推送通知功能
- 添加推送通知管理器和设备令牌管理 - 实现推送通知权限请求和令牌注册 - 新增推送通知设置页面 - 集成推送通知初始化到应用启动流程 - 添加推送通知API服务和本地存储管理 - 更新个人页面添加推送通知设置入口
This commit is contained in:
@@ -311,6 +311,11 @@ export default function PersonalScreen() {
|
|||||||
switchValue: notificationEnabled,
|
switchValue: notificationEnabled,
|
||||||
onSwitchChange: handleNotificationToggle,
|
onSwitchChange: handleNotificationToggle,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: 'settings-outline' as const,
|
||||||
|
title: '推送通知设置',
|
||||||
|
onPress: () => pushIfAuthedElseLogin('/push-notification-settings'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// 开发者section(需要连续点击三次用户名激活)
|
// 开发者section(需要连续点击三次用户名激活)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
// 餐次映射保持不变
|
// 餐次映射保持不变
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
import { preloadUserData } from '@/store/userSlice';
|
import { preloadUserData } from '@/store/userSlice';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
@@ -12,6 +13,7 @@ export default function SplashScreen() {
|
|||||||
const backgroundColor = useThemeColor({}, 'background');
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
const primaryColor = useThemeColor({}, 'primary');
|
const primaryColor = useThemeColor({}, 'primary');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { initializePushNotifications } = usePushNotifications();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkOnboardingStatus();
|
checkOnboardingStatus();
|
||||||
@@ -24,6 +26,12 @@ export default function SplashScreen() {
|
|||||||
await preloadUserData();
|
await preloadUserData();
|
||||||
console.log('用户数据预加载完成');
|
console.log('用户数据预加载完成');
|
||||||
|
|
||||||
|
// 初始化推送通知(不阻塞应用启动)
|
||||||
|
console.log('开始初始化推送通知...');
|
||||||
|
initializePushNotifications().catch((error) => {
|
||||||
|
console.warn('推送通知初始化失败,但不影响应用正常使用:', error);
|
||||||
|
});
|
||||||
|
|
||||||
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||||
|
|
||||||
// if (onboardingCompleted === 'true') {
|
// if (onboardingCompleted === 'true') {
|
||||||
|
|||||||
328
app/push-notification-settings.tsx
Normal file
328
app/push-notification-settings.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<ThemedView style={[styles.container, { backgroundColor }]}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={primaryColor} />
|
||||||
|
<ThemedText style={styles.loadingText}>加载推送通知设置...</ThemedText>
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={[styles.container, { backgroundColor }]}>
|
||||||
|
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<ThemedText style={styles.title}>推送通知设置</ThemedText>
|
||||||
|
<ThemedText style={styles.subtitle}>
|
||||||
|
管理您的推送通知权限和设备令牌
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.card, { borderColor }]}>
|
||||||
|
<ThemedText style={styles.cardTitle}>状态信息</ThemedText>
|
||||||
|
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
<ThemedText style={styles.statusLabel}>初始化状态:</ThemedText>
|
||||||
|
<View style={styles.statusValue}>
|
||||||
|
<Text style={[styles.statusText, { color: isInitialized ? '#4CAF50' : '#F44336' }]}>
|
||||||
|
{isInitialized ? '已初始化' : '未初始化'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
<ThemedText style={styles.statusLabel}>令牌状态:</ThemedText>
|
||||||
|
<View style={styles.statusValue}>
|
||||||
|
<Text style={[styles.statusText, { color: getStatusColor() }]}>
|
||||||
|
{getTokenStatusDescription(tokenStatus)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
<ThemedText style={styles.statusLabel}>推送可用:</ThemedText>
|
||||||
|
<View style={styles.statusValue}>
|
||||||
|
<Text style={[styles.statusText, { color: isPushNotificationAvailable(tokenStatus) ? '#4CAF50' : '#F44336' }]}>
|
||||||
|
{isPushNotificationAvailable(tokenStatus) ? '是' : '否'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{currentToken && (
|
||||||
|
<View style={[styles.card, { borderColor }]}>
|
||||||
|
<ThemedText style={styles.cardTitle}>设备令牌</ThemedText>
|
||||||
|
<View style={styles.tokenContainer}>
|
||||||
|
<Text style={[styles.tokenText, { color: textColor }]}>
|
||||||
|
{currentToken.substring(0, 20)}...{currentToken.substring(currentToken.length - 10)}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.copyButton, { borderColor }]}
|
||||||
|
onPress={copyTokenToClipboard}
|
||||||
|
>
|
||||||
|
<Text style={[styles.copyButtonText, { color: primaryColor }]}>复制</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={[styles.card, { borderColor }]}>
|
||||||
|
<ThemedText style={styles.cardTitle}>操作</ThemedText>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, { backgroundColor: primaryColor }]}
|
||||||
|
onPress={handleRegisterToken}
|
||||||
|
disabled={isRegistering}
|
||||||
|
>
|
||||||
|
{isRegistering ? (
|
||||||
|
<ActivityIndicator size="small" color="#ffffff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.actionButtonText}>注册设备令牌</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.secondaryButton, { borderColor }]}
|
||||||
|
onPress={() => updateTokenStatus()}
|
||||||
|
>
|
||||||
|
<Text style={[styles.secondaryButtonText, { color: primaryColor }]}>刷新状态</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.dangerButton]}
|
||||||
|
onPress={handleClearData}
|
||||||
|
>
|
||||||
|
<Text style={styles.dangerButtonText}>清除所有数据</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.card, { borderColor }]}>
|
||||||
|
<ThemedText style={styles.cardTitle}>说明</ThemedText>
|
||||||
|
<ThemedText style={styles.description}>
|
||||||
|
• 推送通知需要在真实iOS设备上使用{'\n'}
|
||||||
|
• 设备令牌用于接收远程推送通知{'\n'}
|
||||||
|
• 如果令牌状态显示"权限被拒绝",请在系统设置中开启通知权限{'\n'}
|
||||||
|
• 清除数据后需要重新初始化推送通知
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
267
docs/push-notification-implementation.md
Normal file
267
docs/push-notification-implementation.md
Normal file
@@ -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 <access_token>
|
||||||
|
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 <access_token>
|
||||||
|
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 <access_token>
|
||||||
|
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. **技术改进**
|
||||||
|
- 实现令牌自动刷新
|
||||||
|
- 添加推送送达统计
|
||||||
|
- 优化错误恢复机制
|
||||||
138
hooks/usePushNotifications.ts
Normal file
138
hooks/usePushNotifications.ts
Normal file
@@ -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>(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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ROUTES } from '@/constants/Routes';
|
|||||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
|
import { pushNotificationManager } from './pushNotificationManager';
|
||||||
|
|
||||||
// 配置通知处理方式
|
// 配置通知处理方式
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -77,6 +78,24 @@ export class NotificationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.isIniting = true
|
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();
|
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||||
let finalStatus = existingStatus;
|
let finalStatus = existingStatus;
|
||||||
@@ -91,7 +110,6 @@ export class NotificationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 设置通知监听器
|
// 设置通知监听器
|
||||||
this.setupNotificationListeners();
|
this.setupNotificationListeners();
|
||||||
|
|
||||||
|
|||||||
330
services/pushNotificationManager.ts
Normal file
330
services/pushNotificationManager.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<TokenStatus> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await pushNotificationService.clearLocalTokenData();
|
||||||
|
this.currentToken = null;
|
||||||
|
this.isInitialized = false;
|
||||||
|
console.log('推送通知管理器数据已清除');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清除推送通知管理器数据失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const pushNotificationManager = PushNotificationManager.getInstance();
|
||||||
167
services/pushNotifications.ts
Normal file
167
services/pushNotifications.ts
Normal file
@@ -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<PushNotificationResponse> {
|
||||||
|
try {
|
||||||
|
console.log('注册设备令牌:', request.deviceToken.substring(0, 20) + '...');
|
||||||
|
|
||||||
|
const response = await api.post<PushNotificationResponse>(
|
||||||
|
'/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<PushNotificationResponse> {
|
||||||
|
try {
|
||||||
|
console.log('更新设备令牌:', request.newDeviceToken.substring(0, 20) + '...');
|
||||||
|
|
||||||
|
const response = await api.put<PushNotificationResponse>(
|
||||||
|
'/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<void> {
|
||||||
|
try {
|
||||||
|
console.log('注销设备令牌:', deviceToken.substring(0, 20) + '...');
|
||||||
|
|
||||||
|
await api.delete<void>('/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<string | null> {
|
||||||
|
try {
|
||||||
|
return await AsyncStorage.getItem(DEVICE_TOKEN_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取本地设备令牌失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查设备令牌是否已注册
|
||||||
|
*/
|
||||||
|
async isTokenRegistered(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const registered = await AsyncStorage.getItem(TOKEN_REGISTERED_KEY);
|
||||||
|
return registered === 'true';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查令牌注册状态失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除本地设备令牌数据
|
||||||
|
*/
|
||||||
|
async clearLocalTokenData(): Promise<void> {
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user